OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_proportional.gd

210 lines · gd ref:
extends SceneTree
## GATE — PROPORTIONAL EDITING, object level (O; Blender use_proportional_edit_objects;
## design on hyper-games #109, 2026-10-13). With it on, G moves what is near the
## selection too, by a falloff of its distance: w = falloff(1 - d / radius).
##   PR1  O toggles it, Shift+O cycles the falloff, Alt+O toggles connected-only
##   PR2  the falloffs, as Blender's: linear 1 at the selection, 0.5 at half the
##        radius, 0 at the radius; smooth 0.5 at half; constant 1 inside, 0 beyond
##   PR3  G (linear, radius 10): the selected brush moves d, a brush 5 m away 0.5·d, a
##        brush 20 m away not at all — ONE gesture, one undo puts all of it back
##   PR4  a parametric shape nearby moves as ONE unit (shape.xform), by its weight
##   PR5  a model nearby moves by its weight
##   PR6  PgUp during G grows the radius (×1.25), PgDn shrinks it
##   PR7  off: only the selection moves
##
## Run: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_proportional.gd

var ok := true
var checks := 0
var _frame := 0
var _map: Node
var _level: Node
var _persist: Node
var _stack: Node


func _p(c: bool, m: String, got: Variant = "") -> void:
	checks += 1
	print(("ok   " if c else "FAIL: ") + m + ("" if str(got) == "" else "  (" + str(got) + ")"))
	if not c:
		ok = false


func _process(_d: float) -> bool:
	_frame += 1
	if _frame == 1:
		_map = (load("res://scenes/maps/map_flat.tscn") as PackedScene).instantiate()
		root.add_child(_map)
		return false
	if _frame < 30:
		return false
	root.get_node("/root/GameMode").set_mode("build")
	_run()
	root.get_node("/root/GameMode").set_mode("play")
	if checks < 27:          # a gate that died early must not read as a pass (it did: 0 checks, PASS)
		ok = false
		print("FAIL: only %d checks ran -- the gate died before its assertions" % checks)
	print("\n%d checks — %s" % [checks, "PASS" if ok else "FAIL"])
	quit(0 if ok else 1)
	return true


func _key(code: int, shift := false, alt := false) -> InputEventKey:
	var e := InputEventKey.new()
	e.physical_keycode = code
	e.keycode = code
	e.shift_pressed = shift
	e.alt_pressed = alt
	e.pressed = true
	return e


## A unit box centred on (x, 0.5, 0).
func _cube(x: float) -> String:
	var dot: String = _persist.record("brush.create", {"planes": [1, 0, 0, x + 0.5, -1, 0, 0, -(x - 0.5),
		0, 1, 0, 1, 0, -1, 0, 0, 0, 0, 1, 0.5, 0, 0, -1, 0.5], "proj": [], "material_id": 2,
		"level_id": 0, "sky": false, "group": ""})
	_persist.refold()
	return dot


func _centre_of(key: String) -> Vector3:
	var d: Dictionary = _level.get_brush(_level.brush_by_key(key))
	return ((d["aabb_min"] as Vector3) + (d["aabb_max"] as Vector3)) * 0.5


## G on the selection, X, 4 grid steps, Enter. Returns the selection's move.
func _move_x(steps: int) -> Vector3:
	_map._unhandled_key_input(_key(KEY_G))
	var mv: Object = _stack.get("active")
	if not (mv is MoveOperator):
		return Vector3.ZERO
	mv.set_tool_setting("distance", steps)
	var d: Vector3 = mv.call("_delta")
	_stack.call("confirm")
	return d


func _run() -> void:
	_persist = _map.get("_persist")
	_level = _map.get("level")
	_stack = _map.call("_ensure_ops")

	print("-- PR1: the keys --")
	var was := bool(_map.get("proportional"))
	_map._unhandled_key_input(_key(KEY_O))
	_p(bool(_map.get("proportional")) != was, "PR1 O toggles proportional editing", _map.get("proportional"))
	_map._unhandled_key_input(_key(KEY_O))
	_p(bool(_map.get("proportional")) == was, "PR1 O again toggles it back")
	var f0 := String(_map.get("prop_falloff"))
	_map._unhandled_key_input(_key(KEY_O, true))
	_p(String(_map.get("prop_falloff")) != f0, "PR1 Shift+O cycles the falloff", [f0, _map.get("prop_falloff")])
	var c0 := bool(_map.get("prop_connected"))
	_map._unhandled_key_input(_key(KEY_O, false, true))
	_p(bool(_map.get("prop_connected")) != c0, "PR1 Alt+O toggles connected-only")
	_map._unhandled_key_input(_key(KEY_O, false, true))

	print("-- PR2: the falloffs --")
	var w: Callable = Callable(_map, "prop_weight")
	_p(is_equal_approx(w.call("linear", 0.0, 10.0), 1.0) and is_equal_approx(w.call("linear", 5.0, 10.0), 0.5)
		and is_equal_approx(w.call("linear", 10.0, 10.0), 0.0), "PR2 linear: 1, 0.5, 0")
	_p(is_equal_approx(w.call("smooth", 5.0, 10.0), 0.5), "PR2 smooth: 0.5 at half the radius", w.call("smooth", 5.0, 10.0))
	_p(is_equal_approx(w.call("constant", 9.0, 10.0), 1.0) and is_equal_approx(w.call("constant", 11.0, 10.0), 0.0),
		"PR2 constant: 1 inside, 0 beyond")

	print("-- PR3-PR5: G with proportional on --")
	var a := _cube(0.0)
	var b := _cube(5.0)
	var c := _cube(20.0)
	var sdot: String = _persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
		"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, Vector3(-0.5, 0, 4.5))),
		"size": [1, 1, 1], "material_id": 2, "level_id": 0})       # centre (0, 0.5, 5): 5 m from A
	_persist.refold()
	var md: String = _map.create_object("model", Transform3D(Basis(), Vector3(0, 0.5, -5)))   # 5 m from A
	var a0 := _centre_of(a)
	var b0 := _centre_of(b)
	var c0p := _centre_of(c)
	var s0: Vector3 = (_persist.shape_state(sdot)["frame"] as Transform3D).origin
	var m0: Vector3 = (_map.object_node(md) as Node3D).transform.origin
	_map.set("proportional", true)
	_map.set("prop_falloff", "linear")
	_map.set("prop_radius", 10.0)
	_map.select_brushes(PackedInt32Array([_level.brush_by_key(a)]))
	var g0: String = _persist.newest_live_gesture()
	var d := _move_x(4)
	_p(not d.is_zero_approx(), "PR3 (setup) G moved the selection", d)
	_p(_centre_of(a).distance_to(a0 + d) < 1e-3, "PR3 the selected brush moved d", _centre_of(a) - a0)
	_p(_centre_of(b).distance_to(b0 + d * 0.5) < 1e-3, "PR3 the brush 5 m away moved 0.5·d", _centre_of(b) - b0)
	_p(_centre_of(c).distance_to(c0p) < 1e-3, "PR3 the brush 20 m away did not move", _centre_of(c) - c0p)
	var s1: Vector3 = (_persist.shape_state(sdot)["frame"] as Transform3D).origin
	_p(s1.distance_to(s0 + d * 0.5) < 1e-3, "PR4 the shape 5 m away moved 0.5·d, as one unit", s1 - s0)
	_persist.refold()
	var m1: Vector3 = (_map.object_node(md) as Node3D).transform.origin
	_p(m1.distance_to(m0 + d * 0.5) < 1e-3, "PR5 the model 5 m away moved 0.5·d", m1 - m0)
	_p(_persist.newest_live_gesture() != g0, "PR3 one gesture")
	_persist.undo_gesture(_persist.newest_live_gesture())
	_persist.refold()
	_p(_centre_of(a).distance_to(a0) < 1e-3 and _centre_of(b).distance_to(b0) < 1e-3
		and (_persist.shape_state(sdot)["frame"] as Transform3D).origin.distance_to(s0) < 1e-3
		and (_map.object_node(md) as Node3D).transform.origin.distance_to(m0) < 1e-3, "PR3 one undo puts all of it back")

	print("-- PR6: the radius keys --")
	_map.select_brushes(PackedInt32Array([_level.brush_by_key(a)]))
	_map._unhandled_key_input(_key(KEY_G))
	var mv: Object = _stack.get("active")
	_map._unhandled_key_input(_key(KEY_PAGEUP))
	_p(is_equal_approx(float(_map.get("prop_radius")), 12.5), "PR6 PgUp during G: radius x1.25", _map.get("prop_radius"))
	_map._unhandled_key_input(_key(KEY_PAGEDOWN))
	_p(is_equal_approx(float(_map.get("prop_radius")), 10.0), "PR6 PgDn: back", _map.get("prop_radius"))
	if mv != null:
		_stack.call("cancel")

	print("-- PR8: R with proportional on --")
	_map.set("proportional", true)
	_map.set_pivot_mode("bbox")
	_map.select_brushes(PackedInt32Array([_level.brush_by_key(a)]))
	_map._unhandled_key_input(_key(KEY_R))
	var rot: Object = _stack.get("active")
	_p(rot is RotateOperator, "PR8 (setup) R starts Rotate", rot)
	if rot is RotateOperator:
		rot.set_tool_setting("axis", rot.axis_index("y"))
		rot.set_tool_setting("angle", 90.0)
		_stack.call("confirm")
		# B at (5, .5, 0), w = 0.5: turned 45° about Y round the pivot (0, .5, 0).
		_p(_centre_of(b).distance_to(Vector3(3.5355, 0.5, -3.5355)) < 1e-2, "PR8 the brush 5 m away turned half the angle",
			_centre_of(b))
		_p(_centre_of(c).distance_to(c0p) < 1e-3, "PR8 the brush 20 m away did not turn", _centre_of(c))
		_persist.undo_gesture(_persist.newest_live_gesture())
		_persist.refold()
		_p(_centre_of(b).distance_to(b0) < 1e-3, "PR8 one undo puts it back", _centre_of(b))

	print("-- PR9: S with proportional on --")
	_map.select_brushes(PackedInt32Array([_level.brush_by_key(a)]))
	_map._unhandled_key_input(_key(KEY_S))
	var sc: Object = _stack.get("active")
	_p(sc is ScaleOperator, "PR9 (setup) S starts Scale", sc)
	if sc is ScaleOperator:
		sc.set_tool_setting("pivot", 1)          # the editor pivot (bbox centre), not the base
		sc.set_tool_setting("steps", 1)
		# The step is the grid (2026-10-18): about its centre the 1 m cube's faces each move
		# one step g, so it scales by k = 1 + 2g; at w = 0.5 the neighbour takes 1 + g.
		var g: float = float(sc.call("grid_step"))
		var kw := 1.0 + g
		_stack.call("confirm")
		var bs: Dictionary = _level.get_brush(_level.brush_by_key(b))
		var bsize: Vector3 = (bs["aabb_max"] as Vector3) - (bs["aabb_min"] as Vector3)
		_p(_centre_of(b).distance_to(Vector3(5.0 * kw, 0.5, 0)) < 1e-2, "PR9 the brush 5 m away scaled ×(1+g) about the pivot",
			[_centre_of(b), 5.0 * kw])
		_p(bsize.distance_to(Vector3.ONE * kw) < 1e-2, "PR9 its extents ×(1+g)", [bsize, kw])
		_p(_centre_of(c).distance_to(c0p) < 1e-3, "PR9 the brush 20 m away did not scale", _centre_of(c))
		_persist.undo_gesture(_persist.newest_live_gesture())
		_persist.refold()
		_p(_centre_of(b).distance_to(b0) < 1e-3, "PR9 one undo puts it back", _centre_of(b))

	print("-- PR7: off --")
	_map.set("proportional", false)
	_map.select_brushes(PackedInt32Array([_level.brush_by_key(a)]))
	var d7 := _move_x(4)
	_p(not d7.is_zero_approx() and _centre_of(b).distance_to(b0) < 1e-3, "PR7 off: the brush 5 m away stays put",
		_centre_of(b) - b0)