extends Node
## GATE — THE Q MENU and H = HIDE (docs/cutting-and-operations.md §9c).
##
## 1. Q with nothing selected: Add shape + 3D cursor, no CSG
## 2. Q on a shape with a cut: Make Real, Clear its cuts, Hide — no CSG
## 3. Q on a CUTTER: change op, select its target, clear its cuts; a pick of
## "→ intersect" turns its op into an intersect
## 4. Q with two things (active = target): Subtract / Intersect / Slice / Convex merge;
## a pick of Slice writes a live slice op
## 5. H hides the selected shape through the Outliner's rows (its group row), Alt+H
## unhides all, Shift+H hides everything else
## 6. keys: H = hide, Q = the menu, subtract has NO key until the keymap rework
##
## RUN AS A SCENE: "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_q_menu.tscn
var fails := 0
var checks := 0
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)
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 q_menu: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
func _box(persist: Node, origin: Vector3, size: Vector3) -> String:
return persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, origin)),
"size": [size.x, size.y, size.z], "material_id": 2, "level_id": 0})
func _frames(n: int) -> void:
for i in n:
await get_tree().process_frame
## Q through the keymap (the same path as the key), then the menu node.
func _open_q(map: Node) -> Node:
var qm: Node = map.get("_q_menu")
if qm != null and bool(qm.is_open()):
(qm.get("_menu") as PopupMenu).hide()
await _frames(2)
Keymap.run("builder.q_menu")
await _frames(3)
return map.get("_q_menu")
func _close(qm: Node) -> void:
if qm != null and bool(qm.is_open()):
(qm.get("_menu") as PopupMenu).hide()
await _frames(2)
func _hidden(level: Node, ids: PackedInt32Array) -> bool:
if ids.is_empty():
return false
for id: int in ids:
if not bool(level.is_brush_hidden(id)):
return false
return true
func _run(map: Node3D) -> void:
var persist: Node = map.get("_persist")
var level: Node = map.get("level")
# ⛔ ISOLATE THE OUTLINER'S SAVE FIRST. map_test has no source hash, so the Outliner
# saves to user://outliner/default.dat — the USER's file, and CI=true does not
# isolate it. A hide here once cleared two rows the user had hidden (2026-10-12).
var ol0: Node = map.get("_outliner")
if ol0 != null:
ol0.set("_save_key", "gate_q_menu")
(ol0.get("model").get("hidden_rows") as Dictionary).clear()
ol0.get("model").apply()
const W := Vector3(60, 0, 60)
var wall: String = _box(persist, W, Vector3(8, 4, 1))
var c1: String = _box(persist, W + Vector3(1, -1, -1), Vector3(2, 4, 3))
var c2: String = _box(persist, W + Vector3(5, -1, -1), Vector3(2, 4, 3))
persist.record("op.create", {"op": "subtract", "cutter": c1, "targets": [wall]})
var plain: String = persist.record("brush.create", {"planes": [-1, 0, 0, -60, 1, 0, 0, 62,
0, -1, 0, 0, 0, 1, 0, 2, 0, 0, -1, -70, 0, 0, 1, 72], "proj": [], "material_id": 2,
"level_id": 0, "sky": false, "group": ""})
persist.refold()
# ---- 6. keys (first: they decide whether the rest can be driven at all) ----
_ok("6. H is hide", int(Keymap.binding_of("builder.hide").get("key", 0)) == KEY_H, Keymap.binding_of("builder.hide"))
_ok("6. Shift+H hides the rest", bool(Keymap.binding_of("builder.hide_unselected").get("shift", false)))
_ok("6. Alt+H unhides all", bool(Keymap.binding_of("builder.unhide_all").get("alt", false)))
_ok("6. Q is the Q menu", int(Keymap.binding_of("builder.q_menu").get("key", 0)) == KEY_Q)
# Keymap step 1 landed: subtract is Ctrl+Numpad − (Bool Tool), never H again.
var sb: Dictionary = Keymap.binding_of("build_mode.csg_subtract")
_ok("6. subtract is Ctrl+Numpad −, not H", int(sb.get("key", 0)) == KEY_KP_SUBTRACT and bool(sb.get("ctrl", false)), sb)
# ---- 1. nothing selected ----
map.clear_selection()
var qm: Node = await _open_q(map)
_ok("1. Q opens the Q menu", qm != null and bool(qm.is_open()))
if qm == null:
return
_ok("1. nothing selected: Add shape + 3D cursor", qm.has_action("builder.create_box") and qm.has_action("cursor.to_origin"))
_ok("1. ... and no CSG", not qm.has_action("build_mode.csg_subtract"))
await _close(qm)
# ---- 2. one shape with a cut ----
map.select_shape(wall)
qm = await _open_q(map)
_ok("2. a shape: Make Real, Clear its cuts, Hide", qm.has_action("builder.make_real")
and qm.has_action("builder.cuts_clear") and qm.has_action("builder.hide"))
_ok("2. ... and no CSG for one thing", not qm.has_action("build_mode.csg_subtract"))
await _close(qm)
# ---- 3. a cutter ----
map.select_shape(c1)
qm = await _open_q(map)
_ok("3. a cutter: change op, select target, clear", qm.has_action("builder.cut_as_intersect")
and qm.has_action("builder.select_cut_target") and qm.has_action("builder.cuts_clear"))
qm.pick("builder.cut_as_intersect")
await _frames(4)
var st: Array = map.op_stack(wall)
_ok("3. the pick turned its op into an intersect", st.size() == 1 and String(st[0]["kind"]) == "intersect", st)
# ---- 4. two things: active = target ----
var sel := PackedInt32Array(level.get_group_brush_ids(c2))
var wall_ids: PackedInt32Array = level.get_group_brush_ids(wall)
sel.append(wall_ids[0])
map.select_brushes(sel)
qm = await _open_q(map)
_ok("4. two things: Subtract / Intersect / Slice / Convex merge", qm.has_action("build_mode.csg_subtract")
and qm.has_action("build_mode.csg_intersect") and qm.has_action("build_mode.csg_slice")
and qm.has_action("build_mode.csg_merge"))
qm.pick("build_mode.csg_slice")
await _frames(4)
st = map.op_stack(wall)
var kinds: Array = []
for s: Dictionary in st:
kinds.append(String(s["kind"]))
_ok("4. Slice wrote a live slice op on the wall", kinds.has("slice") and st.size() == 2, st)
# ---- 5. hide ----
map.select_shape(wall)
Keymap.run("builder.hide")
await _frames(3)
var ol: Node = map.get("_outliner")
var om: Object = ol.get("model") if ol != null else null
_ok("5. H hides the wall's pieces", _hidden(level, level.get_group_brush_ids(wall)))
_ok("5. ... through the Outliner's group row", om != null and (om.get("hidden_rows") as Dictionary).has("group:" + wall),
om.get("hidden_rows") if om != null else "no outliner")
Keymap.run("builder.unhide_all")
await _frames(3)
_ok("5. Alt+H unhides all", (level.get_hidden_brush_ids() as PackedInt32Array).is_empty(), level.get_hidden_brush_ids())
map.select_shape(wall)
Keymap.run("builder.hide_unselected")
await _frames(3)
var pid: int = level.brush_by_key(plain)
var holder: Array = []
for r: Dictionary in (om.rows() as Array):
var bs: Variant = r.get("brushes", null)
if bs != null and Array(bs).has(pid):
holder.append([r["id"], r["kind"], r["parent"], type_string(typeof(bs))])
_ok("5. Shift+H hides an unselected plain brush", pid > 0 and bool(level.is_brush_hidden(pid)),
[pid, holder, om.get("hidden_rows")])
_ok("5. ... and not the selection", not _hidden(level, level.get_group_brush_ids(wall))
and not bool(level.is_brush_hidden(level.get_group_brush_ids(wall)[0])))
Keymap.run("builder.unhide_all")
await _frames(3)
# H on an UNGROUPED brush: it has no row of its own until Unsorted is opened, so it
# hides by its member id, applied by key (OutlinerModel.apply).
map.select_brushes(PackedInt32Array([pid]))
Keymap.run("builder.hide")
await _frames(3)
_ok("5. H hides an ungrouped brush (Unsorted member row, by key)", bool(level.is_brush_hidden(pid)),
om.get("hidden_rows"))
Keymap.run("builder.unhide_all")
await _frames(3)