extends Node
## GATE — A BUNDLE BETWEEN TWO FACE SETS (#120 L3, docs/node-tree-and-stacks.md §14.6):
## Cablerator's Create a Cable Mass.
## pure (BundleCables, faces as _selected_face_polys() gives them: {pts, n}):
## B1 two touching faces + one apart: two islands, [2 faces] and [1 face]
## B2 one island, or three: refused, saying how many
## B3 the same seed, the same ends; another seed, other ends
## B4 random ends lie ON their island's faces (on the plane, inside the polygon)
## B5 jitter 0: each end's handle runs along its face normal, tension x chord / 3 long
## B6 random off: ends are the face centres, cycled
## B7 count 8: 8 cables, each from island A to island B
## B8 slack: each cable hangs (2 ends + 3 interior points)
## in the map:
## M1 two islands selected, the key, Enter: 5 cables + a "Bundle" collection holding
## them, ONE gesture; one Ctrl+Z takes it all
## M2 one island: the tool refuses
## M3 Adjust Last with count 3: 3 cables, still one Bundle collection
## M4 Z / D / N / S focus count / tension / jitter / radius; R and Q work
## M5 the Add menu and the face Q menu offer it; the key is bound
## M6 map.cable_bundle called directly (not through the operator stack): one Ctrl+Z
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_bundle.tscn
const CollectionFold := preload("res://scenes/maps/collection_fold.gd")
var fails := 0
var checks := 0
var BC: 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/bundle_cables.gd"):
BC = load("res://scenes/maps/curves/bundle_cables.gd")
_ok("setup: BundleCables exists", BC != null)
if BC != 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 < 20:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE cable_bundle: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
func _face(pts: Array, n: Vector3) -> Dictionary:
return {"pts": PackedVector3Array(pts), "n": n}
## Box [0,2]x[0,1]x[0,2]: its top and its +X side (they share an edge); and a face of a
## box 10 m away.
func _faces() -> Array:
var top := _face([Vector3(0, 1, 0), Vector3(0, 1, 2), Vector3(2, 1, 2), Vector3(2, 1, 0)], Vector3.UP)
var side := _face([Vector3(2, 0, 0), Vector3(2, 1, 0), Vector3(2, 1, 2), Vector3(2, 0, 2)], Vector3.RIGHT)
var far := _face([Vector3(10, 0, 0), Vector3(10, 0, 2), Vector3(10, 1, 2), Vector3(10, 1, 0)], Vector3.LEFT)
return [top, far, side]
func _opts(over: Dictionary = {}) -> Dictionary:
var o := {"count": 4, "random": true, "seed": 7, "tension": 1.0, "jitter": 0.0, "slack": 0.0}
for k: String in over:
o[k] = over[k]
return o
## Is `p` on the plane of face `f` and inside its (convex) polygon?
func _on_face(p: Vector3, f: Dictionary) -> bool:
var pts: PackedVector3Array = f["pts"]
var n: Vector3 = f["n"]
if absf(n.dot(p - pts[0])) > 1.0e-4:
return false
var sgn := 0.0
for i in pts.size():
var s := n.dot((pts[(i + 1) % pts.size()] - pts[i]).cross(p - pts[i]))
if absf(s) < 1.0e-9:
continue
if sgn == 0.0:
sgn = signf(s)
elif signf(s) != sgn:
return false
return true
func _pure() -> void:
var fs := _faces()
# ---- B1 ----
var isl: Array = BC.islands(fs)
_ok("B1 two islands: [top, side] and [far]", isl.size() == 2 and isl[0] == [0, 2] and isl[1] == [1], isl)
# ---- B2 ----
var one: Dictionary = BC.bundle([fs[0], fs[2]], _opts())
var three: Dictionary = BC.bundle([fs[0], fs[1], _face([Vector3(20, 0, 0), Vector3(20, 0, 1), Vector3(20, 1, 1)], Vector3.LEFT)], _opts())
_ok("B2 one island / three islands: refused, with the count", int(one.get("islands", -1)) == 1
and int(three.get("islands", -1)) == 3 and (one.get("cables", []) as Array).is_empty()
and (three.get("cables", []) as Array).is_empty(), [one.get("islands"), three.get("islands")])
# ---- B3 ----
var r1: Array = BC.bundle(fs, _opts())["cables"]
var r2: Array = BC.bundle(fs, _opts())["cables"]
var r3: Array = BC.bundle(fs, _opts({"seed": 8}))["cables"]
var same := r1.size() == 4 and r2.size() == 4
for i in mini(r1.size(), r2.size()):
same = same and (r1[i][0]["p"] as Vector3) == (r2[i][0]["p"] as Vector3) and (r1[i][1]["p"] as Vector3) == (r2[i][1]["p"] as Vector3)
_ok("B3 the same seed: the same ends", same)
_ok("B3 ... another seed: other ends", r3.size() == 4 and (r3[0][0]["p"] as Vector3) != (r1[0][0]["p"] as Vector3))
# ---- B4 + B7 ----
var r8: Array = BC.bundle(fs, _opts({"count": 8}))["cables"]
var on := r8.size() == 8
for c: Array in r8:
var a: Vector3 = c[0]["p"]
var b: Vector3 = c[c.size() - 1]["p"]
on = on and (_on_face(a, fs[0]) or _on_face(a, fs[2])) and _on_face(b, fs[1])
_ok("B4 + B7 count 8: 8 cables, each from a face of A to the face of B (on it)", on, r8.size())
# ---- B5 ----
var c0: Array = r1[0] if r1.size() > 0 else []
var b5 := false
if c0.size() == 2:
var pa: Dictionary = c0[0]
var pb: Dictionary = c0[1]
var chord := (pa["p"] as Vector3).distance_to(pb["p"])
var ho: Vector3 = pa["h_out"]
var hi: Vector3 = pb["h_in"]
b5 = ho.normalized().distance_to(pa["n"]) < 1.0e-4 and absf(ho.length() - chord / 3.0) < 1.0e-4 \
and hi.normalized().distance_to(pb["n"]) < 1.0e-4 and absf(hi.length() - chord / 3.0) < 1.0e-4 \
and CurveMath.handle_type(pa) == "free" and CurveMath.handle_type(pb) == "free"
_ok("B5 jitter 0: each end leaves along its normal, chord / 3 long, free", b5, c0)
# ---- B6 ----
var rc: Array = BC.bundle(fs, _opts({"random": false, "count": 3}))["cables"]
var cen := [Vector3(1, 1, 1), Vector3(2, 0.5, 1)] # top, side: island A in face order
var b6 := rc.size() == 3
for i in rc.size():
b6 = b6 and (rc[i][0]["p"] as Vector3).distance_to(cen[i % 2]) < 1.0e-4 \
and (rc[i][rc[i].size() - 1]["p"] as Vector3).distance_to(Vector3(10, 0.5, 1)) < 1.0e-4
_ok("B6 random off: A's face centres cycled, B's centre", b6, rc.map(func(c: Array) -> Vector3: return c[0]["p"]))
# ---- B8 ----
var rs: Array = BC.bundle(fs, _opts({"slack": 0.5}))["cables"]
_ok("B8 slack 0.5: each cable 2 ends + 3 interior points", rs.size() == 4
and rs.all(func(c: Array) -> bool: return c.size() == 5), rs.map(func(c: Array) -> int: return c.size()))
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 _key(level: Node, brush: int, n: Vector3) -> String:
var d: Dictionary = level.get_brush(brush)
var pl: PackedFloat32Array = d["planes"]
var keys: PackedStringArray = d["plane_keys"]
for i in pl.size() / 4:
if Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2]).normalized().distance_to(n) < 1.0e-4:
return keys[i]
return ""
## The map's face set, replaced IN PLACE (an Array[Dictionary]: set() would not convert).
func _set_faces(map: Node, list: Array) -> void:
var a: Array = map.get("_sel_faces")
a.clear()
for f: Dictionary in list:
a.append(f)
func _press(map: Node, action: String) -> void:
var ev := InputEventAction.new()
ev.action = action
ev.pressed = true
map._unhandled_input(ev)
func _key_ev(code: Key) -> InputEventKey:
var k := InputEventKey.new()
k.keycode = code
k.physical_keycode = code
k.pressed = true
return k
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 _bundles(persist: Node) -> Array:
var out: Array = []
var t: Dictionary = persist.collection_tree()
for c: String in (t.get("cols", {}) as Dictionary):
if String(t["cols"][c].get("name", "")) == "Bundle":
out.append(c)
return out
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("cable_bundle") and map.get("_bundle_op") != null
_ok("setup: map.cable_bundle + the tool", has)
if not has:
return
var a: int = map._create_brush_journalled({"planes": _box_planes(Vector3(200, 60, 0), Vector3(202, 61, 2)),
"proj": [0, 0, 0, 0, 0, 0], "material_id": 7, "level_id": 0})
var b: int = map._create_brush_journalled({"planes": _box_planes(Vector3(210, 60, 0), Vector3(212, 61, 2)),
"proj": [0, 0, 0, 0, 0, 0], "material_id": 7, "level_id": 0})
level.rebuild()
var ka := String(level.get_brush_key(a))
var kb := String(level.get_brush_key(b))
var two := [{"b": ka, "p": _key(level, a, Vector3.UP)}, {"b": ka, "p": _key(level, a, Vector3.RIGHT)},
{"b": kb, "p": _key(level, b, Vector3.LEFT)}]
map.set_select_mode("face")
var op: Object = map.get("_bundle_op")
# ---- M2 ----
_set_faces(map, [two[0], two[1]])
_press(map, "builder.cable_bundle")
_ok("M2 one island: the tool refuses", ops.get("active") == null, ops.get("active"))
# ---- M1 + M4 ----
_set_faces(map, two)
var c0 := _cables(persist).size()
var b0 := _bundles(persist).size()
var n0: int = (persist.records(false) as Array).size()
_press(map, "builder.cable_bundle")
_ok("M1 two islands: the tool is up", ops.get("active") == op, ops.get("active"))
if ops.get("active") != op:
return
op.set_tool_setting("count", 5)
var focus_ok := true
for pair: Array in [[KEY_Z, "count"], [KEY_D, "tension"], [KEY_N, "jitter"], [KEY_S, "radius"]]:
op.handle_key(_key_ev(pair[0]))
focus_ok = focus_ok and op.modal().focused() == String(pair[1])
var seed0 := int(op.get("rng_seed"))
op.handle_key(_key_ev(KEY_Q))
var rnd0 := bool(op.get("random"))
op.handle_key(_key_ev(KEY_R))
var r_ok := bool(op.get("random")) != rnd0
op.handle_key(_key_ev(KEY_R))
_ok("M4 Z / D / N / S focus count / tension / jitter / radius; Q reseeds; R toggles",
focus_ok and int(op.get("rng_seed")) != seed0 and r_ok, [op.modal().focused(), seed0, op.get("rng_seed")])
ops.confirm()
var new_c := _cables(persist).size() - c0
var bun := _bundles(persist)
var members := 0
if bun.size() == b0 + 1:
var t: Dictionary = persist.collection_tree()
for d: String in _cables(persist):
if CollectionFold.collections_of(t, "g:" + d).has(bun[-1]):
members += 1
_ok("M1 5 cables + one Bundle collection holding all 5", new_c == 5 and bun.size() == b0 + 1 and members == 5,
[new_c, bun.size() - b0, members])
_ok("M1 ... 12 records (5 x curve + shape, col.create, col.link)",
(persist.records(false) as Array).size() == n0 + 12, (persist.records(false) as Array).size() - n0)
# ---- M3 ----
var adj: bool = ops.adjust("count", 3)
_ok("M3 Adjust Last count 3: 3 cables, one Bundle", adj and _cables(persist).size() - c0 == 3
and _bundles(persist).size() == b0 + 1, [adj, _cables(persist).size() - c0, _bundles(persist).size() - b0])
persist.undo_last()
persist.refold()
_ok("M1 one Ctrl+Z takes the whole bundle", _cables(persist).size() == c0 and _bundles(persist).size() == b0,
[_cables(persist).size() - c0, _bundles(persist).size() - b0])
# ---- M6: the API direct (no operator stack wrapping a gesture round it) ----
_set_faces(map, two)
var c6 := _cables(persist).size()
var b6 := _bundles(persist).size()
var direct: Array = map.cable_bundle(map._selected_face_polys(), _opts({"count": 3}), {"radius": 0.03})
var made := _cables(persist).size() - c6
persist.undo_last()
persist.refold()
_ok("M6 cable_bundle called directly: 3 cables + a Bundle, ONE Ctrl+Z takes it all", direct.size() == 3
and made == 3 and _cables(persist).size() == c6 and _bundles(persist).size() == b6,
[direct.size(), made, _cables(persist).size() - c6, _bundles(persist).size() - b6])
# ---- M5 ----
var rows: Array = []
for sec: Array in map.add_sections():
for r: Array in sec[1]:
rows.append(r[0])
_set_faces(map, two)
map.set("_sel_kind", "face")
map.set("_sel_brush", a)
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_bundle", {})
_ok("M5 the Add menu and the face Q menu offer Bundle", rows.has("builder.cable_bundle")
and qrows.has("builder.cable_bundle"), [rows, qrows])
_ok("M5 Shift+Ctrl+C is bound", int(spec.get("key", 0)) == KEY_C and bool(spec.get("ctrl", false))
and bool(spec.get("shift", false)) and not bool(spec.get("alt", false)), spec)