extends Node
## GATE — CABLES FROM EDGES (#120 L3, docs/node-tree-and-stacks.md §14.5).
## pure (EdgeCables, edges as selected_edges() gives them: {a, e, normals}):
## C1 a box top's 4 edges, in any order and direction: ONE closed chain of 4
## C2 two disjoint edges: two open chains
## C3 three edges meeting at one corner: the chain stops there (three chains)
## C4 an L of two edges, listed backwards: one open chain of 3, ends at the free ends
## C5 vertices, offset 0.1: a top corner moves 0.1 along its mean normal, n = that normal
## C6 centres: a closed chain -> the 4 edge midpoints; an open one -> end, mids, end
## C7 corners round = auto, sharp = vector; an open chain's ends are always vector
## in the map:
## M1 4 top edges selected, Alt+C, Enter: one closed cable of 4 points, ONE gesture
## M2 two disjoint edges: two cables in one gesture; one Ctrl+Z takes both (M2b: the
## map API called directly, not through the operator stack)
## M3 Adjust Last with another offset: the curve moves, still one live gesture
## M4 no edges selected: the tool refuses
## M5 the Add menu and the edge Q menu offer it; Alt+C is bound
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_edges.tscn
var fails := 0
var checks := 0
var EC: GDScript = null
func _ok(what: String, cond: bool, note: Variant = "") -> void:
checks += 1
if not cond:
fails += 1
print(" %-66s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
func _ready() -> void:
if ResourceLoader.exists("res://scenes/maps/curves/edge_cables.gd"):
EC = load("res://scenes/maps/curves/edge_cables.gd")
_ok("setup: EdgeCables exists", EC != null)
if EC != null:
_pure()
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 < 18:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE cable_edges: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
## An edge as selected_edges() gives it.
func _e(a: Vector3, e: Vector3, n1: Vector3, n2: Vector3) -> Dictionary:
return {"a": a, "e": e, "normals": [n1, n2]}
## The top face of the unit box [0,1]^3: four edges, mixed order and direction.
func _top() -> Array:
var U := Vector3.UP
return [_e(Vector3(0, 1, 0), Vector3(1, 1, 0), U, Vector3.FORWARD),
_e(Vector3(1, 1, 1), Vector3(0, 1, 1), U, Vector3.BACK),
_e(Vector3(1, 1, 0), Vector3(1, 1, 1), U, Vector3.RIGHT),
_e(Vector3(0, 1, 0), Vector3(0, 1, 1), U, Vector3.LEFT)]
func _pure() -> void:
# ---- C1 ----
var c1: Array = EC.chains(_top())
_ok("C1 a box top: one closed chain of 4", c1.size() == 1 and bool(c1[0]["closed"])
and (c1[0]["verts"] as Array).size() == 4, c1)
# ---- C2 ----
var c2: Array = EC.chains([_e(Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3.UP, Vector3.FORWARD),
_e(Vector3(0, 0, 5), Vector3(1, 0, 5), Vector3.UP, Vector3.BACK)])
_ok("C2 two disjoint edges: two open chains", c2.size() == 2 and not bool(c2[0]["closed"])
and not bool(c2[1]["closed"]), c2.size())
# ---- C3 ----
# A FREE end first, so the walk starts there and must STOP when it reaches the corner
# (listed from the corner, every walk would start at it and never test the stop).
var c3: Array = EC.chains([_e(Vector3(0, 1, 0), Vector3(1, 1, 0), Vector3.UP, Vector3.FORWARD),
_e(Vector3(1, 1, 0), Vector3(1, 1, 1), Vector3.UP, Vector3.RIGHT),
_e(Vector3(1, 0, 0), Vector3(1, 1, 0), Vector3.RIGHT, Vector3.FORWARD)])
var ones := c3.size() == 3
for ch: Dictionary in c3:
ones = ones and (ch["verts"] as Array).size() == 2
_ok("C3 three edges at one corner: three chains of one edge", ones, c3.size())
# ---- C4 ----
var c4: Array = EC.chains([_e(Vector3(2, 0, 0), Vector3(2, 0, 2), Vector3.UP, Vector3.RIGHT),
_e(Vector3(0, 0, 0), Vector3(2, 0, 0), Vector3.UP, Vector3.FORWARD)])
var v4: Array = c4[0]["verts"] if c4.size() == 1 else []
var ends_ok := v4.size() == 3 and ((v4[0] as Vector3).is_equal_approx(Vector3(0, 0, 0)) and (v4[2] as Vector3).is_equal_approx(Vector3(2, 0, 2))
or (v4[2] as Vector3).is_equal_approx(Vector3(0, 0, 0)) and (v4[0] as Vector3).is_equal_approx(Vector3(2, 0, 2)))
_ok("C4 an L listed backwards: one open chain, ends at the free ends", ends_ok, v4)
# ---- C5 ----
var p5: Array = EC.curve_points(c1[0], false, 0.1, false) if c1.size() == 1 else []
var corner_ok := false
var want_n := (Vector3.UP * 2.0 + Vector3.RIGHT + Vector3.FORWARD).normalized()
for q: Dictionary in p5:
if (q["p"] as Vector3).distance_to(Vector3(1, 1, 0) + want_n * 0.1) < 1.0e-4 \
and (q["n"] as Vector3).distance_to(want_n) < 1.0e-4:
corner_ok = true
_ok("C5 vertices, offset 0.1: corner (1,1,0) moves 0.1 along its mean normal", p5.size() == 4 and corner_ok, p5.size())
# ---- C6 ----
var p6: Array = EC.curve_points(c1[0], true, 0.0, false) if c1.size() == 1 else []
var mids := 0
for q2: Dictionary in p6:
for m: Vector3 in [Vector3(0.5, 1, 0), Vector3(1, 1, 0.5), Vector3(0.5, 1, 1), Vector3(0, 1, 0.5)]:
if (q2["p"] as Vector3).distance_to(m) < 1.0e-4:
mids += 1
_ok("C6 centres, closed: the 4 edge midpoints", p6.size() == 4 and mids == 4, p6.size())
var p6o: Array = EC.curve_points(c4[0], true, 0.0, false) if c4.size() == 1 else []
_ok("C6 centres, open: end, 2 midpoints, end", p6o.size() == 4, p6o.size())
# ---- C7 ----
var round_pts: Array = EC.curve_points(c4[0], false, 0.0, false) if c4.size() == 1 else []
var sharp_pts: Array = EC.curve_points(c4[0], false, 0.0, true) if c4.size() == 1 else []
var types_r: Array = round_pts.map(func(q: Dictionary) -> String: return CurveMath.handle_type(q))
var types_s: Array = sharp_pts.map(func(q: Dictionary) -> String: return CurveMath.handle_type(q))
_ok("C7 round: ends vector, the corner auto", types_r == ["vector", "auto", "vector"], types_r)
_ok("C7 sharp: all vector", types_s == ["vector", "vector", "vector"], types_s)
func _box_planes(lo: Vector3, hi: Vector3) -> Array:
return [-1, 0, 0, -lo.x, 1, 0, 0, hi.x, 0, -1, 0, -lo.y, 0, 1, 0, hi.y, 0, 0, -1, -lo.z, 0, 0, 1, hi.z]
func _cables(persist: Node) -> Array:
var out: Array = []
for d: String in (persist.live_shapes() as Dictionary):
if String(persist.shape_state(d)["type"]) == "cable":
out.append(d)
return out
func _curve_of(persist: Node, dot: String) -> Dictionary:
var cd := String((persist.shape_state(dot)["params"] as Dictionary).get("curve", ""))
return (persist.curve_states() as Dictionary).get(cd, {})
func _live_n(persist: Node) -> int:
var n := 0
for r: Dictionary in (persist.records(false) as Array):
if not bool(r.get("undone", false)):
n += 1
return n
## The map's edge set, replaced IN PLACE: it is an Array[Dictionary], and set() with an
## untyped Array would not convert.
func _set_edges(map: Node, list: Array) -> void:
var a: Array = map.get("_sel_edges")
a.clear()
for e: Dictionary in list:
a.append(e)
## Edge keys resolved as selected_edges() would.
func _resolved(map: Node, keys: Array) -> Array:
_set_edges(map, keys)
return map.selected_edges()
func _key(map: Node, ev_action: String) -> void:
var ev := InputEventAction.new()
ev.action = ev_action
ev.pressed = true
map._unhandled_input(ev)
func _run(map: Node3D) -> void:
map._ensure_persistence()
var persist: Node = map.get("_persist")
var level: Node = map.get("level")
var ops: Node = map.call("_ensure_ops")
var has := map.has_method("cables_from_edges") and map.get("_edges_op") != null
_ok("setup: map.cables_from_edges + the tool", has)
if not has:
return
var y0 := 60.0
var w: int = map._create_brush_journalled({"planes": _box_planes(Vector3(200, y0, 0), Vector3(202, y0 + 1, 2)),
"proj": [0, 0, 0, 0, 0, 0], "material_id": 7, "level_id": 0})
level.rebuild()
var T := y0 + 1
map.set_select_mode("edge")
# ---- M4 (empty first) ----
_set_edges(map, [])
_key(map, "builder.cable_from_edges")
_ok("M4 no edges: the tool refuses", ops.get("active") == null, ops.get("active"))
# ---- M1 ----
var top: Array = []
for pr: Array in [[Vector3(200, T, 0), Vector3(202, T, 0)], [Vector3(202, T, 0), Vector3(202, T, 2)],
[Vector3(202, T, 2), Vector3(200, T, 2)], [Vector3(200, T, 2), Vector3(200, T, 0)]]:
top.append(map._edge_key(w, pr[0], pr[1]))
_set_edges(map, top)
var c0 := _cables(persist).size()
var g0: int = _live_n(persist)
_key(map, "builder.cable_from_edges")
var op: Object = map.get("_edges_op")
_ok("M1 Alt+C with 4 edges: the tool is up", ops.get("active") == op, ops.get("active"))
if ops.get("active") == op:
op.set_tool_setting("offset", 0.0)
ops.confirm()
var new1: Array = _cables(persist).slice(c0)
var cv1: Dictionary = _curve_of(persist, new1[0]) if new1.size() == 1 else {}
_ok("M1 one closed cable of 4 points", new1.size() == 1 and bool(cv1.get("closed", false))
and (cv1.get("points", []) as Array).size() == 4, [new1.size(), cv1.get("closed"), (cv1.get("points", []) as Array).size()])
_ok("M1 ... two records (curve + shape)", _live_n(persist) == g0 + 2, _live_n(persist) - g0)
# ---- M3 ----
var adj: bool = ops.adjust("offset", 0.2)
var cv3: Dictionary = _curve_of(persist, _cables(persist)[-1]) if _cables(persist).size() > c0 else {}
var lifted := false
for q: Dictionary in (cv3.get("points", []) as Array):
lifted = lifted or (q["p"] as Vector3).y > T + 0.1
_ok("M3 Adjust Last offset 0.2: the curve lifts, still one cable, one gesture", adj and lifted
and _cables(persist).size() == c0 + 1 and _live_n(persist) == g0 + 2, [adj, lifted, _cables(persist).size() - c0])
# ---- M2 ----
_set_edges(map, [top[0], top[2]])
var c2n := _cables(persist).size()
var g2: int = _live_n(persist)
_key(map, "builder.cable_from_edges")
if ops.get("active") == op:
ops.confirm()
_ok("M2 two disjoint edges: two cables, four records", _cables(persist).size() == c2n + 2
and _live_n(persist) == g2 + 4, [_cables(persist).size() - c2n, _live_n(persist) - g2])
persist.undo_last()
persist.refold()
_ok("M2 ... one Ctrl+Z takes both", _cables(persist).size() == c2n, _cables(persist).size() - c2n)
# ---- M2b: the API direct (no operator stack wrapping a gesture round it) ----
var c2b := _cables(persist).size()
var direct: Array = map.cables_from_edges(_resolved(map, [top[0], top[2]]),
false, 0.0, false, {"radius": 0.05})
persist.undo_last()
persist.refold()
_ok("M2b cables_from_edges called directly: two cables, ONE Ctrl+Z takes both", direct.size() == 2
and _cables(persist).size() == c2b, [direct.size(), _cables(persist).size() - c2b])
# ---- M5 ----
var rows: Array = []
for sec: Array in map.add_sections():
for r: Array in sec[1]:
rows.append(r[0])
_set_edges(map, [top[0]])
map.set("_sel_kind", "edge")
map.set("_sel_brush", w)
var qrows: Array = []
for sec2: Array in map.q_sections():
for r2: Array in sec2[1]:
qrows.append(r2[0])
var spec: Dictionary = Keymap.DEFAULTS.get("builder", {}).get("builder.cable_from_edges", {})
_ok("M5 the Add menu and the edge Q menu offer Cable from edges",
rows.has("builder.cable_from_edges") and qrows.has("builder.cable_from_edges"), [rows, qrows])
_ok("M5 Alt+C is bound", int(spec.get("key", 0)) == KEY_C and bool(spec.get("alt", false))
and not bool(spec.get("shift", false)) and not bool(spec.get("ctrl", false)), spec)