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)