extends Node
## GATE — CURVES IN THE OUTLINER (#120 §16.3, §16.7 Q1; step 2: rows + rename).
## R1 a room with no curves has no Curves row
## R2 a free curve is a row "∿ <name>" under Curves
## R3 linked into a collection it is listed there only
## R4 a curve ONE cable uses is not a row of its own: it is the first row when the
## cable's row opens ("∿ path · <name>"), under the cable's collection row too
## R5 a curve two things use (a cable and a curve modifier) is its own row again
## R6 rename: F2 opens the editor on a curve row; the edit (the row's text, ∿ and all)
## is one curve.set {name}; the row shows the new name
## R7 renaming a COLLECTION that holds brushes keeps the name clean: its row reads
## "Hall (5)" and the edit used to save " (5)" into the name
##
## RUN AS A SCENE: "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_outliner_curves.tscn
var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var panel: Node
var cam: Camera3D
## Aim the crosshair at `at` from above (gate_curve_tool's recipe: a headless viewport keeps
## its pointer at (0, 0), so the wrap offset carries it to the centre).
func _aim_at(at: Vector3) -> void:
cam.global_position = at + Vector3(0.01, 4.0, 0.01)
cam.look_at(at, Vector3.UP)
await get_tree().process_frame
map.set("_wrap_offset", get_viewport().get_visible_rect().size * 0.5 - get_viewport().get_mouse_position())
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")
map = (load("res://scenes/maps/map_dev_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()
get_tree().root.get_node("/root/GameMode").set_mode("play")
if checks < 56:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE outliner_curves: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
func _fold() -> void:
persist.refold()
panel.refresh()
await get_tree().process_frame
func _rows() -> Dictionary:
return panel.get("_items")
func _text(id: String) -> String:
var it: TreeItem = _rows().get(id, null)
return it.get_text(0) if it != null else ""
func _parent_id(id: String) -> String:
var it: TreeItem = _rows().get(id, null)
if it == null or it.get_parent() == null:
return ""
var r: Variant = it.get_parent().get_metadata(0)
return String((r as Dictionary)["id"]) if r is Dictionary else ""
func _select_row(id: String) -> void:
var tree: Tree = panel.get("_tree")
tree.deselect_all()
var it: TreeItem = _rows().get(id, null)
if it != null:
it.select(0)
## Open a group row the way a click on its arrow does, and wait for the deferred fill.
func _open(id: String) -> void:
var it: TreeItem = _rows().get(id, null)
if it == null:
return
it.collapsed = false
await get_tree().process_frame
await get_tree().process_frame
## Edit a row's name in place: what _on_item_edited receives (a headless Tree cannot type).
func _edit(id: String, text: String) -> void:
var it: TreeItem = _rows().get(id, null)
if it == null:
return
it.set_text(0, text)
panel.call("_commit_name", it)
func _run() -> void:
persist = map.get("_persist")
panel = map.get("_outliner")
_ok("setup: journal and outliner", persist != null and panel != null)
if persist == null or panel == null:
return
var O0 := Vector3(200.137, 1.31, 199.873)
# ---- R1 ----
await _fold()
_ok("R1 no curves: no Curves row", not _rows().has("curves:"))
# ---- R2 ----
var f: String = map.create_curve([CurveMath.point(O0), CurveMath.point(O0 + Vector3(3.3, 0, 0))], false, "Rail")
await _fold()
_ok("R2 a free curve has a row", _rows().has("curve:" + f), f)
_ok("R2 ... under Curves", _parent_id("curve:" + f) == "curves:", _parent_id("curve:" + f))
_ok("R2 ... named with ∿", _text("curve:" + f) == "∿ Rail", _text("curve:" + f))
# ---- R3 ----
persist.begin_gesture("A")
var a: String = persist.record("col.create", {"name": "A", "parent": ""})
persist.record("col.link", {"target": a, "members": ["c:" + f]})
persist.end_gesture()
await _fold()
_ok("R3 linked into A: its row is under A", _rows().has("col:%s/c:%s" % [a, f]))
_ok("R3 ... and not under Curves", not _rows().has("curve:" + f))
_ok("R3 ... Curves is gone with nothing left in it", not _rows().has("curves:"))
# ---- R4 ----
var cable: String = map.add_cable([CurveMath.point(O0 + Vector3(0, 0, 6)), CurveMath.point(O0 + Vector3(5.1, 0, 6))],
false, {"radius": 0.05})
var u := String(persist.shape_state(cable)["params"]["curve"])
await _fold()
_ok("R4 the cable's curve has no row of its own", not _rows().has("curve:" + u))
await _open("group:" + cable)
var cid := "group:%s/c:%s" % [cable, u]
_ok("R4 opening the cable lists its curve", _rows().has(cid), cid)
_ok("R4 ... first, as its path", _rows().has(cid) and (_rows()["group:" + cable] as TreeItem).get_child(0) == _rows()[cid]
and _text(cid).begins_with("∿ path"), _text(cid))
persist.begin_gesture("B")
var b: String = persist.record("col.create", {"name": "B", "parent": ""})
persist.record("col.link", {"target": b, "members": ["g:" + cable]})
persist.end_gesture()
await _fold()
await _open("col:%s/g:%s" % [b, cable])
_ok("R4 ... and under the cable's row in a collection", _rows().has("col:%s/g:%s/c:%s" % [b, cable, u]))
# ---- R5 ----
var box: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, O0 + Vector3(0, 0, 12))), "size": [1, 1, 1],
"material_id": 2, "level_id": 0})
map.add_modifier("curve", box, {"curve": u})
await _fold()
_ok("R5 used by a cable AND a modifier: its own row", _rows().has("curve:" + u))
# ---- R6 ----
_select_row("curve:" + u)
_ok("R6 F2 opens the editor on a curve row", panel.rename_selected())
var g0: int = (persist.gestures() as Array).size()
_edit("curve:" + u, "∿ Wire")
await _fold()
_ok("R6 the edit is one gesture", (persist.gestures() as Array).size() == g0 + 1)
_ok("R6 ... naming the curve (no ∿ in the name)", String(map.curve_states()[u]["name"]) == "Wire", map.curve_states()[u]["name"])
_ok("R6 ... and the row says so", _text("curve:" + u) == "∿ Wire", _text("curve:" + u))
# ---- S (step 3): selection both ways, delete, drawn selected ----
# A fresh free curve, and u (cable + modifier: its own row "curve:<u>").
var s: String = map.create_curve([CurveMath.point(O0 + Vector3(0, 0, -6)), CurveMath.point(O0 + Vector3(2.7, 0, -6))],
false, "Spare")
await _fold()
_select_row("curve:" + s)
panel.call("_apply_tree_selection")
await get_tree().process_frame
_ok("S1 a curve row click selects the curve", map.selected_curve() == s and (map.get("_sel_brushes") as Array).is_empty(),
map.selected_curve())
_ok("S7 the selected curve is drawn in its own colour", int(map.curve_view_selected_surfaces()) > 0,
map.curve_view_selected_surfaces())
map.select_shape(box)
await get_tree().process_frame
await get_tree().process_frame
_ok("S7 ... and not once nothing curve is selected", int(map.curve_view_selected_surfaces()) == 0)
map.select_curve(u)
await get_tree().process_frame
await get_tree().process_frame
var lit: TreeItem = _rows().get("curve:" + u, null)
_ok("S2 a viewport select_curve lights its row", lit != null and lit.is_selected(0))
var data: Dictionary = panel.drag_data_for_selection()
_ok("S9 a curve row drags as c:<dot>", (data["members"] as Array) == ["c:" + u], data)
# The cable's OWN curve (only the cable uses it): its row is under the cable.
var cable2: String = map.add_cable([CurveMath.point(O0 + Vector3(0, 0, 18)), CurveMath.point(O0 + Vector3(4.4, 0, 18))],
false, {"radius": 0.05})
var u2 := String(persist.shape_state(cable2)["params"]["curve"])
await _fold()
map.select_curve(u2)
await get_tree().process_frame
await get_tree().process_frame
var lit2: TreeItem = _rows().get("group:%s/c:%s" % [cable2, u2], null)
_ok("S3 selecting a cable's own curve opens the cable and lights it", lit2 != null and lit2.is_selected(0))
# X on a USED curve: refused (§16.7 Q2).
var g1: int = (persist.gestures() as Array).size()
var n_used: int = map.delete_selection()
await _fold()
_ok("S5 X on a curve a cable uses deletes nothing", n_used == 0 and map.curve_states().has(u2)
and (persist.gestures() as Array).size() == g1)
_ok("S5 ... and says which cable", String(map.get("_last_refusal")).contains(cable2), map.get("_last_refusal"))
# X on the free one.
map.select_curve(s)
var n_free: int = map.delete_selection()
await _fold()
_ok("S4 X deletes a free curve, one gesture", n_free == 1 and not map.curve_states().has(s)
and (persist.gestures() as Array).size() == g1 + 1)
_ok("S8 ... and the selection lets go of it", map.selected_curve() == "")
persist.undo_last()
await _fold()
_ok("S4 Ctrl+Z brings it back", map.curve_states().has(s))
_select_row("curve:" + s)
_ok("S6 the outliner's Delete on a curve row deletes it", int(panel.delete_selected()) == 1 and not map.curve_states().has(s))
persist.undo_last()
await _fold()
map.select_curve(s)
map.remove_curve(s) # any fold that takes it (here the plain API)
await _fold()
_ok("S8 a fold that removes the selected curve drops the selection", map.selected_curve() == "", map.selected_curve())
# ---- R7 ----
persist.begin_gesture("C")
var c: String = persist.record("col.create", {"name": "Hall", "parent": ""})
persist.record("col.link", {"target": c, "members": ["g:" + box]})
persist.end_gesture()
await _fold()
var shown := _text("col:" + c)
_ok("R7 setup: the row shows a count", shown.ends_with(")"), shown)
_edit("col:" + c, shown.replace("Hall", "Lobby"))
await _fold()
_ok("R7 renaming keeps the count out of the name", String(persist.collection_tree()["cols"][c]["name"]) == "Lobby",
persist.collection_tree()["cols"][c]["name"])
# ---- H (step 4): hide; Edit curve on the selection; Shift+D ----
# Live now: f (in A), u (cable + modifier: "curve:<u>"), u2 (cable2's own).
var drawn0: int = map.curve_view_surfaces()
panel.model.set_row_hidden("curve:" + u, true)
panel.model.apply()
await _fold()
_ok("H1 the eye on a curve row hides its wire", map.is_curve_hidden(u) and int(map.curve_view_surfaces()) < drawn0,
[map.is_curve_hidden(u), drawn0, map.curve_view_surfaces()])
panel.model.set_row_hidden("curve:" + u, false)
panel.model.set_row_hidden("col:" + a, true)
panel.model.apply()
await _fold()
_ok("H2 hiding a collection hides its curves", map.is_curve_hidden(f) and not map.is_curve_hidden(u))
panel.model.set_row_hidden("col:" + a, false)
panel.model.set_row_hidden("group:" + cable2, true)
panel.model.apply()
await _fold()
_ok("H3 hiding a cable hides its own curve", map.is_curve_hidden(u2))
panel.model.set_row_hidden("group:" + cable2, false)
panel.model.apply()
await _fold()
map.select_curve(u)
map.hide_selection()
await _fold()
_ok("H4 H on a selected curve hides it, and deselects", map.is_curve_hidden(u) and map.selected_curve() == ""
and panel.model.hidden_rows.has("curve:" + u))
map.unhide_all()
await _fold()
_ok("H4 ... Alt+H shows it again", not map.is_curve_hidden(u) and int(map.curve_view_surfaces()) == drawn0)
panel.model.save(String(panel.get("_save_key"))) # leave the saved view as found
# E1: aimed right at f's first point, with u selected, Edit curve starts on u.
var fp: Vector3 = (map.curve_states()[f]["points"] as Array)[0]["p"]
await _aim_at(fp)
map.select_curve(u)
var ops: Node = map.call("_ensure_ops")
var ce: Node = map.get("_curve_edit")
var started: bool = ops.activate(ce)
_ok("E1 Edit curve starts on the SELECTED curve, not the aimed one", started and String(ce.get("_dot")) == u,
[started, ce.get("_dot")])
ops.cancel()
await get_tree().process_frame
# D1: Shift+D copies the curve into the same collections and selects the copy.
persist.begin_gesture("link u")
persist.record("col.link", {"target": a, "members": ["c:" + u]})
persist.end_gesture()
await _fold()
map.select_curve(u)
var g2: int = (persist.gestures() as Array).size()
var before: Array = map.curve_states().keys()
map.duplicate_selection()
await _fold()
var made: Array = map.curve_states().keys().filter(func(d: String) -> bool: return not before.has(d))
_ok("D1 Shift+D makes one curve, one gesture", made.size() == 1 and (persist.gestures() as Array).size() == g2 + 1,
[made, (persist.gestures() as Array).size() - g2])
if made.size() == 1:
var cp: String = made[0]
_ok("D1 ... the same points", ((map.curve_states()[cp]["points"] as Array)[1]["p"] as Vector3).is_equal_approx(
(map.curve_states()[u]["points"] as Array)[1]["p"]))
_ok("D1 ... in the same collection, and selected", (persist.collection_tree()["cols"][a]["members"] as Array).has("c:" + cp)
and map.selected_curve() == cp)
# Shift+D then MOVES the copy (Blender's; every duplicate since §17 step 5c): Esc leaves
# it where it was made.
var ops_d: Node = map.call("_ensure_ops")
_ok("D1 ... and Move is up on the copy", ops_d.get("active") == map.get("_move"))
ops_d.cancel()
await get_tree().process_frame
# ---- G (step 4b): G / R on a whole selected curve ----
var gc: String = map.create_curve([CurveMath.point(O0 + Vector3(0, 0, 30)), CurveMath.point(O0 + Vector3(3.1, 0, 30)),
CurveMath.point(O0 + Vector3(3.1, 0, 33.7))], false, "Moved")
await _fold()
var p0: Array = (map.curve_states()[gc]["points"] as Array).duplicate(true)
map.select_curve(gc)
var ops2: Node = map.call("_ensure_ops")
var mv: Node = map.get("_move")
var g3: int = (persist.gestures() as Array).size()
_ok("G1 G starts on a selected curve", ops2.activate(mv))
mv.set_tool_setting("axis", 0)
mv.set_tool_setting("distance", 4)
ops2.confirm()
await _fold()
var p1: Array = map.curve_states()[gc]["points"]
var dd: Vector3 = (p1[0]["p"] as Vector3) - (p0[0]["p"] as Vector3)
var same := true
for i in p0.size():
same = same and ((p1[i]["p"] as Vector3) - (p0[i]["p"] as Vector3)).is_equal_approx(dd)
_ok("G1 ... every point moves by the same +x", dd.x > 0.0 and is_zero_approx(dd.y) and is_zero_approx(dd.z) and same, dd)
_ok("G1 ... one gesture, and the curve stays selected", (persist.gestures() as Array).size() == g3 + 1
and map.selected_curve() == gc)
# R: 90° about Y through the curve's centre.
var rt: Node = map.get("_rotate")
var c0: Vector3 = map.curve_centre(gc)
var len0 := ((p1[1]["p"] as Vector3) - (p1[0]["p"] as Vector3)).length()
_ok("G2 R starts on a selected curve", ops2.activate(rt))
rt.set_tool_setting("angle", 90.0)
ops2.confirm()
await _fold()
var p2: Array = map.curve_states()[gc]["points"]
var seg: Vector3 = (p2[1]["p"] as Vector3) - (p2[0]["p"] as Vector3)
_ok("G2 ... turned 90° about Y (the first span now runs along z), same length",
absf(seg.x) < 1.0e-3 and absf(absf(seg.z) - len0) < 1.0e-3, seg)
_ok("G2 ... about its centre", map.curve_centre(gc).is_equal_approx(c0), [c0, map.curve_centre(gc)])
# EVERY handle, exactly: an open end's auto handle is vertical (along its normal), so
# one end alone cannot tell a turned handle from an unturned one.
var rot := Basis(Vector3.UP, deg_to_rad(90.0))
var turned := true
for i2 in p2.size():
turned = turned and (p2[i2]["h_out"] as Vector3).is_equal_approx(rot * (p1[i2]["h_out"] as Vector3))
_ok("G2 ... its handles turned with it", turned and not (p1[1]["h_out"] as Vector3).is_equal_approx(rot * (p1[1]["h_out"] as Vector3)),
[p1[1]["h_out"], p2[1]["h_out"]])
# Cancel writes nothing.
var g4: int = (persist.gestures() as Array).size()
ops2.activate(mv)
mv.set_tool_setting("distance", 3)
ops2.cancel()
await _fold()
_ok("G3 Esc writes nothing", (persist.gestures() as Array).size() == g4
and (map.curve_states()[gc]["points"] as Array)[0]["p"].is_equal_approx(p2[0]["p"]))
# ---- P (step 5): click a curve's wire to select it (§16.7 Q4: nearer wins) ----
var was_all: bool = map.overlays_all_on()
map.set_overlays_all(true)
map.set_overlay("curves", true)
map.clear_selection()
var wire: PackedVector3Array = map.curve_wire(gc)
var on_wire: Vector3 = wire[wire.size() / 2]
await _aim_at(on_wire)
map.call("_pick_element")
await get_tree().process_frame
_ok("P1 a click on a curve's wire selects it", map.selected_curve() == gc, map.selected_curve())
await _aim_at(on_wire + Vector3(6.0, 0, 6.0)) # well off every wire
map.clear_selection() # a click on nothing keeps a selection
map.call("_pick_element")
await get_tree().process_frame
_ok("P2 a click away from every wire picks no curve", map.selected_curve() == "", map.selected_curve())
panel.model.set_row_hidden("curve:" + gc, true)
panel.model.apply()
await _aim_at(on_wire)
map.clear_selection()
map.call("_pick_element")
await get_tree().process_frame
_ok("P3 a hidden curve cannot be picked", map.selected_curve() == "", map.selected_curve())
panel.model.set_row_hidden("curve:" + gc, false)
panel.model.apply()
panel.model.save(String(panel.get("_save_key")))
map.set_overlay("curves", false)
map.clear_selection()
map.call("_pick_element")
await get_tree().process_frame
_ok("P4 ... nor one whose overlay is off", map.selected_curve() == "", map.selected_curve())
map.set_overlay("curves", true)
map.set_overlays_all(was_all)
# P5 / P6: NEARER WINS over the brush hit. The curves are built at the surface the ray
# ACTUALLY hits (the first brush's top is not the surface everywhere): one resting 0.1 m
# above it — the floor is hit just behind the wire — is picked; one 2 m UNDER it is not.
# STRAIGHT (auto = false, zero handles): an open curve's auto end handles leave along the
# normal, and a 3 m one bows ~1.75 m UP — "under" would cross back above the floor.
var lvl: Node = map.get("level")
var plate: int = (lvl.get_live_brush_ids() as PackedInt32Array)[0]
map.select_brushes(PackedInt32Array([plate]))
var top: Vector3 = map.selection_centre() + Vector3(0, map.selection_size().y * 0.5, 0)
map.clear_selection()
await _aim_at(top + Vector3(0.0, 0.0, 2.17))
var hit0: Dictionary = map.call("_crosshair_hit")
var surf: Vector3 = hit0["position"] if not hit0.is_empty() else top
var on_floor: String = map.create_curve([CurveMath.point(surf + Vector3(-1.53, 0.1, 0)),
CurveMath.point(surf + Vector3(1.47, 0.1, 0))], false, "On floor", false)
# 3 m aside in z, so the ray down to it does not pass through "On floor" as well.
var under: String = map.create_curve([CurveMath.point(surf + Vector3(-1.53, -2.0, 3.0)),
CurveMath.point(surf + Vector3(1.47, -2.0, 3.0))], false, "Under floor", false)
await _fold()
var w5: PackedVector3Array = map.curve_wire(on_floor)
await _aim_at(w5[w5.size() / 2])
var hit5: Dictionary = map.call("_crosshair_hit")
map.clear_selection()
map.call("_pick_element")
await get_tree().process_frame
_ok("P5 a curve ON the floor is picked though the floor is hit behind it", not hit5.is_empty()
and map.selected_curve() == on_floor, [hit5.is_empty(), map.selected_curve()])
var w6: PackedVector3Array = map.curve_wire(under)
await _aim_at(w6[w6.size() / 2] + Vector3(0, 2.0, 0)) # the camera stays above the floor
var h6: Dictionary = map.call("_crosshair_hit")
map.clear_selection()
map.call("_pick_element")
await get_tree().process_frame
_ok("P6 a curve UNDER the floor loses to the floor", not h6.is_empty() and map.selected_curve() == "",
[h6.is_empty(), map.selected_curve()])