extends Node
## GATE — E on a PARAMETRIC SHAPE asks "Make Real or Cancel" (#109, user 2026-10-13, A).
##
## A shape's pieces are read-only (docs/parametric-shapes-design.md §7): the fold
## regenerates them from the shape's params, so a face edit on one is an edit the
## generator cannot reproduce. Extrude used to take the brush under the aim WITHOUT
## that check — the preview pushed the face along its normal, and what landed after
## Enter was not that. Now:
## P1 E on a shape piece does NOT start Extrude; the prompt opens with the two rows
## P2 Cancel writes nothing and the shape is still live
## P3 E again → "Make Real, then extrude": the shape is no longer live (Make Real
## ran) and Extrude IS the active tool, on the now-plain brush
## P4 confirming 2 steps writes ONE face.set whose normal is the aimed face's own
## normal and whose d moved by 2 grid steps along it — the commit follows the
## normal the preview showed, on a TILTED face (a world axis would differ)
## Keys go through Keymap.run, the real input path (#113 §3).
##
## RUN AS A SCENE: "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_extrude_shape_prompt.tscn
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
_cam.global_position = Vector3(40, 14, 28)
_cam.look_at(Vector3(40, 0, 40), Vector3.UP)
for i in 6:
await get_tree().process_frame
await _run(map)
get_tree().root.get_node("/root/GameMode").set_mode("play")
if checks < 9:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE extrude_shape_prompt: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
func _frames(n: int) -> void:
for i in n:
await get_tree().process_frame
func _kinds_since(persist: Node, since: int) -> Array:
var out: Array = []
for r: Dictionary in (persist.records() as Array):
if int(r["seq"]) > since:
out.append(r)
return out
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 q: Node = map.get("_q_menu")
# A TILTED box shape sitting on the map's own aim ray (whatever the headless pointer
# reports), so the crosshair lands on one of its pieces.
var ray: Dictionary = map.call("_crosshair_ray")
var target: Vector3 = (ray["from"] as Vector3) + (ray["dir"] as Vector3) * 16.0
var basis := Basis(Vector3.RIGHT, deg_to_rad(25.0))
var dot: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
"frame": SceneObjects.xform_to(Transform3D(basis, target - basis * Vector3(3, 3, 3))),
"size": [6, 6, 6], "material_id": 2, "level_id": 0})
persist.refold()
await _frames(4)
var hit: Dictionary = map.call("_crosshair_hit")
var hb: int = int(map.call("_brush_from_hit", hit)) if not hit.is_empty() else -1
_ok("setup: the aim lands on a piece of the shape", hb >= 0 and String(map.call("shape_of_brush", hb)) == dot,
[dot, hb])
if hb < 0:
return
# ---- P1 ----
var seq0: int = persist._max_seq_seen()
Keymap.run("builder.extrude")
await _frames(2)
_ok("P1 E on a shape piece does not start Extrude", stack.get("active") != map.get("_extrude"),
stack.get("active"))
var rows: Array = q.get("_actions")
_ok("P1 the prompt is open with Make Real + Cancel", bool(q.call("is_open"))
and rows.has("builder.extrude_make_real") and rows.has("builder.prompt_cancel"), rows)
# ---- P2 ----
q.call("pick", "builder.prompt_cancel")
await _frames(2)
_ok("P2 Cancel writes nothing", _kinds_since(persist, seq0).is_empty(), _kinds_since(persist, seq0).size())
_ok("P2 the shape is still live", (persist.live_shapes() as Dictionary).has(dot))
# ---- P3 ----
Keymap.run("builder.extrude")
await _frames(2)
q.call("pick", "builder.extrude_make_real")
await _frames(4)
_ok("P3 Make Real ran: the shape is no longer live", not (persist.live_shapes() as Dictionary).has(dot))
var ex: Object = map.get("_extrude")
_ok("P3 Extrude is the active tool", stack.get("active") == ex, stack.get("active"))
if stack.get("active") != ex:
return
# ---- P4 ----
var brush: int = int(ex.get("_brush"))
var fkey: String = String(ex.get("_face_key"))
var n0: Vector3 = ex.get("_normal")
var d0: float = float(ex.get("_base_d"))
var seq1: int = persist._max_seq_seen()
ex.call("set_tool_setting", "distance", 2)
_ok("P4 confirming extrudes", bool(stack.call("confirm")))
await _frames(2)
var fs: Array = []
for r: Dictionary in _kinds_since(persist, seq1):
if String(r["kind"]) == "face.set":
fs.append(r)
_ok("P4 ONE face.set was written", fs.size() == 1, fs.size())
if fs.size() != 1:
return
var n1: Vector3 = fs[0]["n"]
var step: float = float(ex.call("grid_step"))
_ok("P4 its normal is the aimed face's own (tilted, not a world axis)",
n1.is_equal_approx(n0) and absf(n0.y) < 0.99 and absf(n0.y) > 0.01, [n0, n1])
_ok("P4 and the face moved 2 grid steps along it", absf(float(fs[0]["d"]) - (d0 + 2.0 * step)) < 1e-3,
[d0, fs[0]["d"], step])
var pl: PackedFloat32Array = (level.get_brush(brush) as Dictionary).get("planes", PackedFloat32Array())
var f: int = int(level.face_by_key(brush, fkey))
_ok("P4 the brush's plane is that plane", f >= 0 and absf(pl[f * 4 + 3] - (d0 + 2.0 * step)) < 1e-3,
[f, pl[f * 4 + 3] if f >= 0 else null])