extends Node
## GATE — parametric shapes IN THE EDITOR (docs/parametric-shapes-design.md §4, §6, §12
## steps 4-5; user 2026-10-05 "wire shapes into the editor first").
##
## 1. CREATE: the box tool's shape list carries the parametric types; confirming one
## writes ONE shape.create (one gesture "Add Stairs"), and every piece it generates
## is in the shape's group
## 2. SELECT: in brush mode clicking a piece selects the WHOLE shape; in face mode it
## selects that face (the pieces are read-only: editing it asks Make Real first —
## gate_shape_parts)
## 3. PANEL: the Shape panel lists the type's params; setting steps writes one
## shape.set, the piece count follows, one Ctrl+Z restores it
## 4. MOVE: translating the selected shape writes shape.xform (no brush.translate) and
## every piece moves by the delta; the shape stays selected
## 5. ROTATE: 90° about Y writes shape.xform; the footprint's x/z extents swap
## 6. SCALE: x2 along the run writes shape.size; that extent doubles
## 7. MAKE REAL: the shape stops generating, the SAME geometry is now plain brushes
## (no live shape), a face pick on one now selects a face; Ctrl+Z brings the shape
## 8. OUTLINER (user 2026-10-05 "the diamond icon next to every parametric shape
## group"): a shape's group row reads "◇ <Type>", and clicking it selects the
## SHAPE (the Shape panel follows), not merely its pieces
## 9. G THROUGH MoveOperator on a ONE-piece shape (every new box): shape.xform, the frame
## moves — its one-brush path once wrote brush.translate on the generated piece
##
## RUN AS A SCENE: "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_shape_editor.tscn
const FH := preload("res://scenes/test/gate_flatten_history.gd")
var fails := 0
var checks := 0
var _cam: Camera3D = null
func _ok(what: String, cond: bool, note: Variant = "") -> void:
checks += 1
if not cond:
fails += 1
print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
func _ready() -> void:
var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
gs.route("world:level:*", 1, "peer")
var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
if keet != null:
keet.current_member_id = "gate-author"
get_tree().root.get_node("/root/GameMode").set_mode("build")
var map: Node3D = (load("res://scenes/maps/map_test.tscn") as PackedScene).instantiate()
add_child(map)
_cam = Camera3D.new()
add_child(_cam)
_cam.current = true
for i in 6:
await get_tree().process_frame
await _run(map)
get_tree().root.get_node("/root/GameMode").set_mode("play")
if checks < 20:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE shape_editor: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
func _kinds(persist: Node, since: int) -> Array:
var out: Array = []
for r: Dictionary in (persist.records() as Array):
if int(r["seq"]) > since:
out.append(String(r["kind"]))
return out
func _bounds(level: Node, ids: PackedInt32Array) -> AABB:
var lo := Vector3.INF
var hi := -Vector3.INF
for id: int in ids:
var d: Dictionary = level.get_brush(id)
if bool(d.get("clearance", false)):
continue
lo = lo.min(d["aabb_min"] as Vector3)
hi = hi.max(d["aabb_max"] as Vector3)
return AABB(lo, hi - lo)
func _click(map: Node, at: Vector3) -> void:
_cam.global_position = at + Vector3(0, 6, 0.01)
_cam.look_at(at, Vector3.UP)
map._pick_element(false, get_viewport().get_visible_rect().size * 0.5)
func _run(map: Node3D) -> void:
var persist: Node = map.get("_persist")
var level: Node = map.get("level")
var stack: Node = map.call("_ensure_ops")
var bx: CreateBoxOperator = map.get("_box")
# ---- 1. create ----
var ids: Array = bx.shape_ids()
var si := ids.find("param:stairs")
_ok("1. the box tool offers the parametric types", si >= 0 and ids.has("param:spiral_stairs")
and ids.has("param:tunnel"), ids)
if si < 0:
return
bx.set_tool_setting("shape", si)
bx.set_tool_setting("size", 8)
bx.set_tool_setting("height", 4)
bx.set_tool_setting("p.steps", 4)
bx.set("_origin", Vector3(40, 0, 40))
bx.set("_normal", Vector3.UP)
bx.set("_axis", 1)
bx.set("_view", Vector3(1, 0, 0.01).normalized())
bx.set("_valid", true)
var seq0: int = persist._max_seq_seen()
stack._keymap_tool(true)
stack.active = bx
_ok("1. confirming creates", stack.confirm())
persist.refold()
var made: Array = _kinds(persist, seq0)
_ok("1. ONE record: a shape.create", made == ["shape.create"], made)
var g0: Dictionary = (persist.gestures() as Array)[0]
_ok("1. one gesture 'Add Stairs'", String(g0["label"]) == "Add Stairs" and int(g0["count"]) == 1, g0)
var shapes: Dictionary = persist.live_shapes()
var dot := ""
for d: String in shapes:
dot = d
var pieces: PackedInt32Array = level.get_group_brush_ids(dot)
_ok("1. its pieces are in the shape's group (4 treads + headroom)", pieces.size() == 5, "%d" % pieces.size())
# ---- 2. select ----
var tread: int = -1
for id: int in pieces:
if not bool((level.get_brush(id) as Dictionary).get("clearance", false)):
tread = id
break
var tb: AABB = _bounds(level, PackedInt32Array([tread]))
map.set("_sel_mode", "brush")
_click(map, tb.get_center() + Vector3(0, tb.size.y * 0.5, 0))
_ok("2. a click on a piece selects the shape", String(map.selected_shape()) == dot, map.selected_shape())
_ok("2. ... all its pieces", (map.get("_sel_brushes") as Array).size() == pieces.size(),
"%d of %d" % [(map.get("_sel_brushes") as Array).size(), pieces.size()])
map.clear_selection()
map.set("_sel_mode", "face")
_click(map, tb.get_center() + Vector3(0, tb.size.y * 0.5, 0))
# Since #109 "select, prompt on edit" (user 2026-10-13): face mode selects the FACE of
# the piece; an EDIT of it asks Make Real first (gate_shape_parts).
_ok("2. a FACE-mode click on a piece selects that face (not the shape)",
String(map.selected_shape()) == "" and String(map.get("_sel_kind")) == "face", map.get("_sel_kind"))
map.set("_sel_mode", "brush")
_click(map, tb.get_center() + Vector3(0, tb.size.y * 0.5, 0)) # the shape again, for 3-6
# ---- 3. panel ----
var panel: Node = map.get("_shape_panel")
_ok("3. the Shape panel shows the stairs' params", panel != null and panel.call("shown_dot") == dot
and (panel.call("setting_ids") as Array).has("p.steps"), panel.call("setting_ids") if panel != null else null)
seq0 = persist._max_seq_seen()
if panel != null:
panel.call("apply", "p.steps", 7)
persist.refold()
_ok("3. setting steps writes one shape.set", _kinds(persist, seq0) == ["shape.set"], _kinds(persist, seq0))
_ok("3. the pieces follow (7 treads + headroom)", level.get_group_brush_ids(dot).size() == 8,
"%d" % level.get_group_brush_ids(dot).size())
persist.undo_last()
persist.refold()
_ok("3. one Ctrl+Z restores 4 treads", level.get_group_brush_ids(dot).size() == 5,
"%d" % level.get_group_brush_ids(dot).size())
# ---- 4. move ----
map.select_shape(dot)
var b0 := _bounds(level, level.get_group_brush_ids(dot))
seq0 = persist._max_seq_seen()
map.translate_selection(Vector3(0, 0, 3))
persist.refold()
var b1 := _bounds(level, level.get_group_brush_ids(dot))
_ok("4. a move writes shape.xform only", _kinds(persist, seq0) == ["shape.xform"], _kinds(persist, seq0))
_ok("4. every piece moved by the delta", b1.position.is_equal_approx(b0.position + Vector3(0, 0, 3))
and b1.size.is_equal_approx(b0.size), "%s -> %s" % [b0, b1])
_ok("4. the shape stays selected", String(map.selected_shape()) == dot
and (map.get("_sel_brushes") as Array).size() == level.get_group_brush_ids(dot).size())
# ---- 5. scale (first: a non-square footprint makes the rotate check mean something) ----
seq0 = persist._max_seq_seen()
map.scale_selection(Vector3(1, 1, 2), b1.get_center())
persist.refold()
var b3 := _bounds(level, level.get_group_brush_ids(dot))
_ok("5. a scale writes shape.size (+ xform)", _kinds(persist, seq0).has("shape.size")
and not _kinds(persist, seq0).has("brush.scale"), _kinds(persist, seq0))
_ok("5. that extent doubled", is_equal_approx(b3.size.z, b1.size.z * 2.0) and is_equal_approx(b3.size.x, b1.size.x),
"%s -> %s" % [b1.size, b3.size])
# ---- 6. rotate ----
seq0 = persist._max_seq_seen()
map.rotate_brushes(map.get("_sel_brushes"), Vector3.UP, 90.0, b3.get_center())
persist.refold()
var b2 := _bounds(level, level.get_group_brush_ids(dot))
_ok("6. a rotate writes shape.xform only", _kinds(persist, seq0) == ["shape.xform"], _kinds(persist, seq0))
_ok("6. the footprint turned (x and z extents swap)", absf(b3.size.x - b3.size.z) > 0.5
and is_equal_approx(b2.size.x, b3.size.z) and is_equal_approx(b2.size.z, b3.size.x), "%s -> %s" % [b3.size, b2.size])
# ---- 7. make real ----
var geo0 := FH.geometry(level)
var n0: int = level.get_group_brush_ids(dot).size()
_ok("7. Make Real", int(map.make_real(dot)) == n0, "%d pieces" % n0)
persist.refold()
_ok("7. the shape no longer generates", not (persist.live_shapes() as Dictionary).has(dot))
_ok("7. the same geometry is now plain brushes", FH.geometry(level) == geo0)
map.clear_selection()
map.set("_sel_mode", "face")
var plain: int = -1
for id: int in (level.get_live_brush_ids() as PackedInt32Array):
var d: Dictionary = level.get_brush(id)
if not bool(d.get("clearance", false)) and (d["aabb_min"] as Vector3).distance_to(b2.position) < 30.0 \
and String(level.get_brush_key(id)).begins_with("gate-author:"):
plain = id
var pb := _bounds(level, PackedInt32Array([plain]))
_click(map, pb.get_center() + Vector3(0, pb.size.y * 0.5, 0))
_ok("7. a face pick on it now selects a FACE", String(map.get("_sel_kind")) == "face"
and String(map.selected_shape()) == "", "%s / %s" % [map.get("_sel_kind"), map.selected_shape()])
map.set("_sel_mode", "brush")
persist.undo_last()
persist.refold()
_ok("7. Ctrl+Z brings the shape back", (persist.live_shapes() as Dictionary).has(dot)
and FH.geometry(level) == geo0)
# ---- 8. outliner ----
map.clear_selection()
var ol: Node = map.get("_outliner")
_ok("8. map_test has an Outliner", ol != null)
if ol == null:
return
ol.refresh()
var srow: Dictionary = {}
var plain_groups := 0
for r: Dictionary in ol.model.rows():
if String(r["id"]) == "group:%s" % dot:
srow = r
elif String(r["id"]).begins_with("group:") and String(r["name"]).begins_with("◇"):
plain_groups += 1
_ok("8. the shape's group row is marked ◇ with its type", String(srow.get("name", "")) == "◇ Stairs",
srow.get("name", "(no row)"))
_ok("8. ... and names the shape it is", String(srow.get("shape_dot", "")) == dot, srow.get("shape_dot", ""))
_ok("8. no non-shape group is marked ◇", plain_groups == 0, "%d" % plain_groups)
var tree: Tree = ol.get("_tree")
tree.deselect_all()
var sit: TreeItem = (ol.get("_items") as Dictionary).get("group:%s" % dot, null)
if sit != null:
sit.select(0)
ol._apply_tree_selection() # select() on a MULTI tree emits nothing
_ok("8. clicking the ◇ row selects the SHAPE, not just its pieces",
String(map.selected_shape()) == dot, map.selected_shape())
_ok("8. ... so the Shape panel edits it", panel != null and panel.call("shown_dot") == dot,
panel.call("shown_dot") if panel != null else null)
# ---- 9. G THROUGH THE OPERATOR on a ONE-piece shape (every new box) ----
# The single-brush path of MoveOperator.confirm journalled brush.translate on the
# generated piece: the frame stayed, and the next regeneration lost the move.
persist.begin_gesture("box")
var box9: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, Vector3(30, 0, 30))), "size": [2, 2, 2],
"material_id": 2, "level_id": 0})
persist.end_gesture()
persist.refold()
map.select_shape(box9)
var one: int = (map.get("_sel_brushes") as Array).size()
var bf0: Transform3D = persist.shape_state(box9)["frame"]
var seq9: int = persist.records().reduce(func(m: int, r: Dictionary) -> int: return maxi(m, int(r["seq"])), 0)
var stk: Node = map.call("_ensure_ops")
var mv: Object = map.get("_move")
var on: bool = stk.activate(mv)
mv.set_tool_setting("distance", 3)
mv.update_preview()
mv.set_tool_setting("move", true)
persist.refold()
_ok("9. G (MoveOperator) on a ONE-piece shape writes shape.xform, never brush.translate",
one == 1 and on and _kinds(persist, seq9) == ["shape.xform"], [one, _kinds(persist, seq9)])
_ok("9. ... and the shape's frame moved", not (persist.shape_state(box9)["frame"] as Transform3D).origin.is_equal_approx(bf0.origin))