extends SceneTree
## GATE — the scale tool (O; demo sprint §3) on a real map_flat, through the real keys.
## K1 O starts it on the selection; the brush is shown as its proxy
## K2 ] moves every scaled face one grid step, about the BASE (y min fixed)
## K3 Enter commits one brush.scale per brush; a refold reproduces it; one undo reverts
## K4 X limits it to X; X again on a plain brush = all (local scale is a shape's);
## Shift+X = X locked (Y Z scale), again = all; C = about
## the centre; no Step setting (the step is the grid), [ pulls the faces in
## K5 Escape cancels: unchanged, visible
## K6 a group scales about ONE pivot (they spread, each keeps its own proportions)
## K7 nothing selected or aimed: O refuses (no stray tool), nothing else changes
## K8 a MODEL scales uniformly (×1.25 a step) through its Scale prop, live, cancel restores, confirm
## journals, one undo reverts; a marker (no Scale) refuses
## K9 a GROUP of models spreads about the group base
## K10 a brush on the lattice stays on it (base and centre), shrink floors at a step
##
## Run: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_scale.gd
var ok := true
var checks := 0
var _frame := 0
var _map: Node
var _level: Node
var _persist: 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:
var dock := root.get_node("/root/BuildDock")
dock.set_pref("snap", true)
dock.set_pref("grid", 5)
_map = (load("res://scenes/maps/map_flat.tscn") as PackedScene).instantiate()
root.add_child(_map)
return false
if _frame < 30:
return false
_run()
print("\n%d checks — %s" % [checks, "PASS" if ok else "FAIL"])
quit(0 if ok else 1)
return true
func _box(lo: Vector3, hi: Vector3) -> Array:
return [1, 0, 0, hi.x, -1, 0, 0, -lo.x, 0, 1, 0, hi.y, 0, -1, 0, -lo.y, 0, 0, 1, hi.z, 0, 0, -1, -lo.z]
func _key(code: int, shift := false) -> InputEventKey:
var e := InputEventKey.new()
e.physical_keycode = code
e.keycode = code
e.shift_pressed = shift
e.pressed = true
return e
func _make(lo: Vector3, hi: Vector3) -> int:
var ft: Array = []
for i in 6:
ft.append({"tex": "kenney-prototype/dark_01", "mode": 1})
var dot: String = _persist.record("brush.create", {"planes": _box(lo, hi), "proj": [], "material_id": 1,
"level_id": 0, "sky": false, "face_tex": ft})
_persist.refold()
return _level.brush_by_key(dot)
func _bounds(ids: Array) -> AABB:
var lo := Vector3.INF
var hi := -Vector3.INF
for b: int in ids:
var d: Dictionary = _level.get_brush(b)
lo = lo.min(d["aabb_min"] as Vector3)
hi = hi.max(d["aabb_max"] as Vector3)
return AABB(lo, hi - lo)
func _proxies() -> Array:
var out: Array = []
for c in _level.get_children():
# contains, not begins_with: a proxy lifted in the frame the last one was freed
# is renamed "@MoveProxy_…@2" (the freed one still holds the name).
if String(c.name).contains("MoveProxy_") and not c.is_queued_for_deletion():
out.append(c)
return out
func _count(kind: String) -> int:
var n := 0
for r: Dictionary in _persist.records():
if String(r.get("kind", "")) == kind:
n += 1
return n
func _approx(a: AABB, b: AABB) -> bool:
return a.position.distance_to(b.position) < 1.0e-3 and a.size.distance_to(b.size) < 1.0e-3
func _run() -> void:
_level = _map.get_node("DCLevel")
_persist = _map.get("_persist")
var keet: Node = root.get_node_or_null("/root/KeetService")
if keet != null:
keet.current_member_id = "gate-author"
var gs: Node = root.get_node_or_null("/root/GameState")
if gs != null:
gs.route("world:level:*", 1, "peer")
root.get_node("/root/GameMode").set_mode("build")
var kc: int = int(root.get_node("/root/Keymap").binding_of("builder.scale").get("key", 0))
_p(kc == KEY_S, "builder.scale is bound to S (Blender's, keymap step 3)", kc)
var b := _make(Vector3(0, 0, 0), Vector3(2, 1, 1))
var b0 := _bounds([b])
# ---- K1 ----
_map.select_brushes(PackedInt32Array([b]))
_map._unhandled_key_input(_key(KEY_S))
var stack: Node = _map.call("_ensure_ops")
_p(stack.active is ScaleOperator, "K1 S starts the scale tool", stack.active)
if not (stack.active is ScaleOperator):
return
_p(_proxies().size() == 1 and _level.is_brush_hidden(b), "K1 the brush is shown as its proxy")
# ---- K2 ----
var op: ScaleOperator = stack.active
op.set_tool_setting("pivot", 0)
var step: float = op.grid_step()
_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
var t: Transform3D = (_proxies()[0] as Node3D).transform
# THE GRID FIT (user 2026-10-18: "scale is not following the current grid unit"):
# every scaled FACE moves one whole grid step — about the base, X / Z grow on both
# sides (2 steps) and Y only at the top. Half-step faces fell off the lattice.
var want := AABB(Vector3(-step, 0, -step), Vector3(2 + 2 * step, 1 + step, 1 + 2 * step))
_p(_approx(t * b0, want), "K2 ] moves every scaled face one grid step, about the base", t * b0)
# ---- K3 ----
var nrec := _count("brush.scale")
# THE REAL KEY, not stack.confirm() (#109, 2026-10-12: "pressing enter should apply
# the scale, currently you have to manually click SCALE") — calling confirm() proved
# the operator commits when told, never that Enter tells it.
_map._unhandled_key_input(_key(KEY_ENTER))
_p(stack.active == null, "K3 Enter commits (the tool closes)", stack.active)
var b1 := _bounds([b])
_p(_approx(b1, want), "K3 the brush has the previewed bounds", b1)
_p(_count("brush.scale") == nrec + 1, "K3 one brush.scale record", _count("brush.scale") - nrec)
_p(not _level.is_brush_hidden(b) and _proxies().is_empty(), "K3 brush back, proxy freed")
_persist.refold()
_p(_approx(_bounds([b]), want), "K3 a refold reproduces it", _bounds([b]))
_persist.undo_gesture(_persist.newest_live_gesture())
_persist.refold()
_p(_approx(_bounds([b]), b0), "K3 one undo takes it back", _bounds([b]))
# ---- K4 ----
_map.select_brushes(PackedInt32Array([b]))
_map._unhandled_key_input(_key(KEY_S))
op = stack.active
_map._unhandled_key_input(_key(KEY_X))
_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
_p(op.factor().is_equal_approx(Vector3((2 + 2 * step) / 2.0, 1, 1)), "K4 X limits it to X", op.factor())
# Blender's X X = LOCAL (user 2026-10-13); local scale is a SHAPE's, so on this plain
# brush X again says so and goes to all axes, still world.
_map._unhandled_key_input(_key(KEY_X))
_p(String(op.get("_axis")) == "all" and String(op.get("_space")) == "global",
"K4 X again on a plain brush: no local scale, all axes (world)")
# The lock moved to Shift+X (Blender's exclude-X): Y and Z scale, X stays.
_map._unhandled_key_input(_key(KEY_X, true))
_p(op.factor().is_equal_approx(Vector3(1, 1 + step, 1 + 2 * step)), "K4 Shift+X = X locked: Y and Z scale", op.factor())
_p(String(op.status()).contains("X locked"), "K4 the status says X is locked", op.status())
_map._unhandled_key_input(_key(KEY_X, true))
_p(String(op.get("_axis")) == "all", "K4 Shift+X again = all axes")
# ONE STEP, THE GRID (user 2026-10-18: Grid / ×2 / ×1.25 / ×1.1 was confusing): no
# Step setting, and the status says what a step is.
var ids: Array = op.tool_settings().map(func(t: Dictionary) -> String: return String(t["id"]))
_p(not ids.has("step"), "K4 no Step setting: the step is the grid", ids)
_p(String(op.status()).contains("steps of"), "K4 the status names the grid step", op.status())
_map._unhandled_key_input(_key(KEY_C))
_p(op.pivot().is_equal_approx(Vector3(1, 0.5, 0.5)), "K4 C = about the centre", op.pivot())
_p(op.factor().is_equal_approx(Vector3((2 + 2 * step) / 2.0, 1 + 2 * step, 1 + 2 * step)),
"K4 about the centre one ] moves both faces a step", op.factor())
_map._unhandled_key_input(_key(KEY_BRACKETLEFT))
_map._unhandled_key_input(_key(KEY_BRACKETLEFT))
_p(op.factor().is_equal_approx(Vector3((2 - 2 * step) / 2.0, 1 - 2 * step, 1 - 2 * step)),
"K4 [ [ pulls both faces in a step", op.factor())
# ---- K5 ----
stack.cancel()
_p(_approx(_bounds([b]), b0) and not _level.is_brush_hidden(b) and _proxies().is_empty(),
"K5 cancel: unchanged and visible")
# ---- K6 ----
var c1 := _make(Vector3(5, 0, 0), Vector3(6, 1, 1))
var c2 := _make(Vector3(8, 0, 0), Vector3(9, 1, 1))
_map.select_brushes(PackedInt32Array([c1, c2]))
_map._unhandled_key_input(_key(KEY_S))
op = stack.active
op.set_tool_setting("pivot", 0) # base: (7, 0, 0.5)
_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
stack.confirm()
var u := _bounds([c1, c2])
_p(_approx(u, AABB(Vector3(5 - step, 0, -step), Vector3(4 + 2 * step, 1 + step, 1 + 2 * step))),
"K6 the pair scaled as one about the shared base", u)
var kf := Vector3((4 + 2 * step) / 4.0, 1 + step, 1 + 2 * step)
_p(_bounds([c1]).size.is_equal_approx(kf), "K6 each brush kept its proportions", [_bounds([c1]), kf])
# ---- K7 ----
_map._clear_face_selection()
var hb := _make(Vector3(20, 0, 20), Vector3(21, 1, 21))
# No crosshair camera headless, so the aimed-brush path cannot be aimed here: with
# nothing selected and nothing aimed, O must refuse rather than leave a stray tool.
_map._unhandled_key_input(_key(KEY_S))
_p(stack.active == null or (stack.active is ScaleOperator and not (stack.active.get("_ids") as Array).is_empty()),
"K7 nothing selected or aimed: O leaves no stray tool")
if stack.active != null:
stack.cancel()
_p(_bounds([hb]).size.is_equal_approx(Vector3.ONE), "K7 an unrelated brush is untouched")
# ---- K8 ----
var mk: String = _map.create_object("marker", Transform3D(Basis(), Vector3(3, 0, 3)))
_map.select_object(mk)
_map._unhandled_key_input(_key(KEY_S))
_p(stack.active == null, "K8 a marker (no Scale setting) refuses")
var md: String = _map.create_object("model", Transform3D(Basis(), Vector3(10, 0, 10)))
_p(md != "", "K8 a model object exists")
_map.select_object(md)
_map._unhandled_key_input(_key(KEY_S))
_p(stack.active is ScaleOperator and (stack.active.get("_objects") as Array) == [md], "K8 O on a model scales the model")
if not (stack.active is ScaleOperator):
return
op = stack.active
_map._unhandled_key_input(_key(KEY_X)) # ignored for objects
_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
var mn := _map.object_node(md) as Node3D
# The model builds at the END of the frame (deferred); this gate runs inside one.
if mn.get("_content") == null:
mn.call("_rebuild")
op.update_preview()
var content: Node3D = mn.get("_content")
_p(content != null and content.transform.basis.is_equal_approx(Basis().scaled(Vector3.ONE * 1.25)),
"K8 it scales LIVE, uniformly, ×1.25 a step (axis ignored)", content.transform if content != null else "no content")
_p(mn.transform.origin.is_equal_approx(Vector3(10, 0, 10)), "K8 a single model stays where it stands")
stack.cancel()
_p(content.transform.basis.is_equal_approx(Basis()), "K8 cancel puts it back")
_map.select_object(md)
_map._unhandled_key_input(_key(KEY_S))
_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
stack.confirm()
_persist.refold()
_p(is_equal_approx(float(_map.object_props(md).get("scale", 0.0)), 1.25), "K8 confirm: Scale prop is 1.25 after a refold",
_map.object_props(md).get("scale"))
_persist.undo_gesture(_persist.newest_live_gesture())
_persist.refold()
_p(is_equal_approx(float(_map.object_props(md).get("scale", 0.0)), 1.0), "K8 one undo takes it back")
# ---- K9 ----
var m2: String = _map.create_object("model", Transform3D(Basis(), Vector3(14, 0, 10)))
_map.select_objects([md, m2], m2)
_map._unhandled_key_input(_key(KEY_S))
stack.active.set_tool_setting("pivot", 0) # base of origins: (12, 0, 10)
_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
stack.confirm()
_persist.refold()
var p1: Vector3 = (_map.object_node(md) as Node3D).transform.origin
var p2: Vector3 = (_map.object_node(m2) as Node3D).transform.origin
_p(p1.is_equal_approx(Vector3(9.5, 0, 10)) and p2.is_equal_approx(Vector3(14.5, 0, 10)),
"K9 a group of models spreads about the group base", "%s %s" % [p1, p2])
_p(is_equal_approx(float(_map.object_props(m2).get("scale", 0.0)), 1.25), "K9 and each is scaled")
_persist.undo_gesture(_persist.newest_live_gesture())
_persist.refold()
_p((_map.object_node(md) as Node3D).transform.origin.is_equal_approx(Vector3(10, 0, 10))
and is_equal_approx(float(_map.object_props(m2).get("scale", 0.0)), 1.0), "K9 one undo takes the whole group back")
_k10(stack)
root.get_node("/root/GameMode").set_mode("play")
## K10 THE LATTICE: a brush on the grid stays on it about the base and the centre,
## and shrinking stops at one step.
func _on_lattice(a: AABB, step: float) -> bool:
for v: Vector3 in [a.position, a.end]:
for i in 3:
if absf(v[i] / step - roundf(v[i] / step)) > 1.0e-3:
return false
return true
func _k10(stack: Node) -> void:
var probe := ScaleOperator.new()
probe.map = _map
var s: float = probe.grid_step()
# A FRESH brush per pass: this gate runs inside one frame, and a brush unhidden by a
# cancel has no mesh to lift as a proxy until the level rebuilds on a later frame.
var lb := -1
var l0 := AABB()
for cfg: Array in [[0, "base"], [1, "centre"]]:
lb = _make(Vector3(0, 0, 40 * s), Vector3(3 * s, 2 * s, 45 * s))
l0 = _bounds([lb])
_p(_on_lattice(l0, s), "K10 the probe brush starts on the lattice", l0)
_map.select_brushes(PackedInt32Array([lb]))
_map._unhandled_key_input(_key(KEY_S))
var op: ScaleOperator = stack.active
op.set_tool_setting("pivot", cfg[0])
for i in 3:
_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
var a := _preview(l0)
_p(_on_lattice(a, s), "K10 %s pivot, +%d: every face on the lattice" % [cfg[1], i + 1], a)
for i in 8:
_map._unhandled_key_input(_key(KEY_BRACKETLEFT))
var shr := _preview(l0)
_p(_on_lattice(shr, s) and shr.size.x >= s - 1.0e-4 and shr.size.y >= s - 1.0e-4 and shr.size.z >= s - 1.0e-4,
"K10 %s pivot: shrinking stops at one step, on the lattice" % cfg[1], shr)
stack.cancel()
# The commit lands where the preview showed, on the lattice.
lb = _make(Vector3(0, 0, 40 * s), Vector3(3 * s, 2 * s, 45 * s))
l0 = _bounds([lb])
_map.select_brushes(PackedInt32Array([lb]))
_map._unhandled_key_input(_key(KEY_S))
stack.active.set_tool_setting("pivot", 1)
_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
var pv := _preview(l0)
_map._unhandled_key_input(_key(KEY_ENTER))
var got := _bounds([lb])
_p(_approx(got, pv) and _on_lattice(got, s), "K10 the commit matches the preview, on the lattice", got)
probe.free()
func _preview(b0: AABB) -> AABB:
return ((_proxies()[0] as Node3D).transform) * b0