extends Node
## GATE — CUSTOM CABLE PROFILES (#120 L3, docs/node-tree-and-stacks.md §14.2): a cable
## whose cross-section is a closed CURVE, live.
## P1 profile_outline of a closed square: 4 vertices, each 1 from the centre (normalised)
## P2 a concave L outline becomes its convex hull: 5 vertices
## P3 a 40-point circle: at most 24 vertices
## P4 an open curve, or fewer than 3 distinct points: no outline (empty)
## P5 a cable on the square outline: square-prism pieces (8 corners, 6 planes), corners
## r from the axis
## P6 the L outline: each piece the hull of two 5-gons (10 corners)
## P7 in the fold: Profile curve picked in the Shape panel = ONE shape.set (custom + the
## curve); the open path curve is not offered
## P8 editing the profile curve re-folds the cable (new piece names, new planes)
## P9 editing an UNRELATED curve does not re-fold the level
## P10 the profile curve removed: the cable folds ROUND (16 corners); Ctrl+Z: custom again
## P11 deleting a cable keeps its path curve when another cable uses it as a profile
## P12 a flatten keeps the custom profile on the re-minted profile curve
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_profile.tscn
const Cable := preload("res://scenes/maps/shapes/param/param_cable.gd")
var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var level: Node
var _cm := CurveMath.new() # profile_outline is static; called by name so the gate parses before it exists
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:
_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")
map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
add_child(map)
for i in 6:
await get_tree().process_frame
await _run()
get_tree().root.get_node("/root/GameMode").set_mode("play")
if checks < 14:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE cable_profile: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
## Points with VECTOR handles (straight spans: the outline's corners are exact).
func _vec_pts(ps: Array) -> Array:
var out: Array = []
for p: Vector3 in ps:
var q := CurveMath.point(p, Vector3.ZERO)
q["handle"] = "vector"
out.append(q)
return out
func _body(pieces: Array) -> Array:
var out: Array = []
for x: Dictionary in pieces:
if String(x["role"]) == "cable":
out.append(x)
return out
func _state(ps: Array, closed := true) -> Dictionary:
return {"points": CurveMath.auto_handles(_vec_pts(ps), closed), "closed": closed}
## A square 2 x 2 in the YZ plane at x = c.x, and an L of the same span.
func _square(c: Vector3) -> Array:
return [c + Vector3(0, -1, -1), c + Vector3(0, -1, 1), c + Vector3(0, 1, 1), c + Vector3(0, 1, -1)]
func _ell(c: Vector3) -> Array:
return [c + Vector3(0, 0, 0), c + Vector3(0, 0, 2), c + Vector3(0, 1, 2), c + Vector3(0, 1, 1),
c + Vector3(0, 2, 1), c + Vector3(0, 2, 0)]
func _pure() -> void:
var has_fn := CurveMath.new().has_method("profile_outline")
_ok("setup: CurveMath.profile_outline exists", has_fn)
if not has_fn:
return
var sq: PackedVector2Array = _cm.call("profile_outline", _state(_square(Vector3.ZERO)))
var unit := sq.size() == 4
for v: Vector2 in sq:
unit = unit and absf(v.length() - 1.0) < 1.0e-4
_ok("P1 a closed square: 4 vertices, each 1 from the centre", unit, sq)
var el: PackedVector2Array = _cm.call("profile_outline", _state(_ell(Vector3.ZERO)))
_ok("P2 a concave L becomes its hull: 5 vertices", el.size() == 5, el)
var cpts: Array = []
for k in 40:
var a := TAU * k / 40.0
cpts.append(CurveMath.point(Vector3(0, cos(a), sin(a)), Vector3.ZERO))
var co: PackedVector2Array = _cm.call("profile_outline", {"points": CurveMath.auto_handles(cpts, true), "closed": true})
_ok("P3 a 40-point circle: 3..24 vertices", co.size() >= 3 and co.size() <= 24, co.size())
var open_o: PackedVector2Array = _cm.call("profile_outline", _state(_square(Vector3.ZERO), false))
var two: PackedVector2Array = _cm.call("profile_outline", _state([Vector3.ZERO, Vector3(0, 1, 0)]))
_ok("P4 open, or under 3 distinct points: empty", open_o.is_empty() and two.is_empty(), [open_o.size(), two.size()])
# ---- P5 / P6: the generator with a profile curve state ----
var gen: Object = Cable.new()
var path := {"points": CurveMath.auto_handles([CurveMath.point(Vector3(0, 0.5, 0), Vector3.ZERO),
CurveMath.point(Vector3(10, 0.5, 0), Vector3.ZERO)], false), "closed": false}
var p := gen.resolve({"radius": 0.1, "profile": "custom", "profile_curve": "x"}) as Dictionary
var body: Array = _body(gen.world_pieces(path, p, _state(_square(Vector3(50, 0, 0)))))
var sq_ok := body.size() == 10
var far := 0.0
for pc: Dictionary in body:
var cs := FaceKit.corners(pc["planes"])
sq_ok = sq_ok and cs.size() == 8 and (pc["planes"] as PackedFloat32Array).size() / 4 == 6
for v: Vector3 in cs:
far = maxf(far, absf(Vector2(v.y - 0.5, v.z).length() - 0.1))
_ok("P5 a square outline: square prisms (8 corners, 6 planes), corners r from the axis",
sq_ok and far < 1.0e-4, [body.size(), far])
var lb: Array = _body(gen.world_pieces(path, p, _state(_ell(Vector3(50, 0, 0)))))
var ten := lb.size() == 10
for pc2: Dictionary in lb:
ten = ten and FaceKit.corners(pc2["planes"]).size() == 10
_ok("P6 the L outline: each piece the hull of two 5-gons (10 corners)", ten, lb.size())
func _solids(dot: String) -> Array:
var out: Array = []
for b: int in (level.get_group_brush_ids(dot) as PackedInt32Array):
var d: Dictionary = level.get_brush(b)
if not bool(d.get("clearance", false)) and not bool(d.get("cutter", false)):
out.append(b)
return out
func _corner_counts(dot: String) -> Array:
var out: Array = []
for b: int in _solids(dot):
out.append(FaceKit.corners(level.get_brush(b)["planes"]).size())
return out
## Every piece of `dot` has `n` corners (and it has pieces).
func _all_corners(dot: String, n: int) -> bool:
var cc := _corner_counts(dot)
return not cc.is_empty() and cc.count(n) == cc.size()
func _names(dot: String) -> Array:
var out: Array = []
for b: int in _solids(dot):
out.append(String(level.get_brush_key(b)))
out.sort()
return out
func _params(dot: String) -> Dictionary:
return persist.shape_state(dot)["params"]
func _run() -> void:
persist = map.get("_persist")
level = map.get("level")
persist.refold()
var cable := map.add_cable([CurveMath.point(Vector3(300, 0.5, 300), Vector3.ZERO),
CurveMath.point(Vector3(304, 0.5, 300), Vector3.ZERO)], false, {"radius": 0.1}) as String
var path_curve := String(_params(cable)["curve"])
var prof := map.create_curve(_vec_pts(_square(Vector3(320, 0.5, 300))), true, "Square") as String
var other := map.create_curve(_vec_pts([Vector3(330, 0.5, 300), Vector3(334, 0.5, 300)]), false, "Other") as String
# ---- P7: the Shape panel row ----
var panel: VBoxContainer = VBoxContainer.new()
panel.set_script(load("res://scenes/game/shape_panel.gd"))
add_child(panel)
panel.setup(map)
map.select_shape(cable)
await get_tree().process_frame
var row: Dictionary = {}
for r: Dictionary in (panel.get("_target").tool_settings() as Array):
if String(r["id"]) == "profile_curve":
row = r
var choices: Array = row.get("choices", [])
_ok("P7 the Shape panel offers Profile curve: the closed curve, not the open path",
choices.has("Square") and not choices.has("Cable") and not choices.has("Other"), choices)
var n0: int = (persist.records(false) as Array).size()
if choices.has("Square"):
panel.apply("profile_curve", choices.find("Square"))
var n1: int = (persist.records(false) as Array).size()
_ok("P7 ... picking it writes ONE shape.set: profile custom on that curve", n1 == n0 + 1
and String(_params(cable).get("profile", "")) == "custom" and String(_params(cable).get("profile_curve", "")) == prof,
[n1 - n0, _params(cable)])
_ok("P7 ... the cable is square prisms now (8 corners each)", _all_corners(cable, 8), _corner_counts(cable))
# ---- P8 ----
var names_before := _names(cable)
var planes_before: PackedFloat32Array = level.get_brush(_solids(cable)[0])["planes"] if not _solids(cable).is_empty() else PackedFloat32Array()
map.set_curve_point(prof, 2, {"p": Vector3(320, 3.5, 303)}) # pull one corner out: a kite
var planes_after: PackedFloat32Array = level.get_brush(_solids(cable)[0])["planes"] if not _solids(cable).is_empty() else PackedFloat32Array()
_ok("P8 a profile-curve edit re-folds the cable: new names, new planes",
_names(cable) != names_before and planes_after != planes_before, [_names(cable).size(), _corner_counts(cable)])
# ---- P9 ----
var v0: int = int(persist.get("_ix_brush_ver"))
map.set_curve_point(other, 1, {"p": Vector3(335, 0.5, 300)})
_ok("P9 an unrelated curve edit does not re-fold the level", int(persist.get("_ix_brush_ver")) == v0,
[v0, persist.get("_ix_brush_ver")])
# ---- P10 ----
# (P8's edit moved a point but not its stored handles, so the outline is no longer a
# 4-gon: compare with the state before the removal, not with a square.)
var before_rm := _corner_counts(cable)
map.remove_curve(prof)
_ok("P10 the profile curve removed: the cable folds ROUND (16 corners)", _all_corners(cable, 16), _corner_counts(cable))
persist.undo_last()
persist.refold()
_ok("P10 ... Ctrl+Z: custom again, as before the removal", not before_rm.is_empty()
and before_rm.count(16) == 0 and _corner_counts(cable) == before_rm, [before_rm, _corner_counts(cable)])
# ---- P11 ----
var c2 := map.add_cable([CurveMath.point(Vector3(300, 0.5, 310), Vector3.ZERO),
CurveMath.point(Vector3(304, 0.5, 310), Vector3.ZERO)], false, {"radius": 0.1}) as String
var loop := map.add_cable(_vec_pts(_square(Vector3(340, 2, 300))), true, {"radius": 0.05}) as String
var loop_curve := String(_params(loop)["curve"])
map.set_shape_params(c2, {"profile": "custom", "profile_curve": loop_curve})
persist.begin_gesture("Delete")
persist.remove_shape(loop)
persist.end_gesture()
persist.refold()
_ok("P11 deleting a cable keeps its path curve while another cable profiles on it",
(persist.curve_states() as Dictionary).has(loop_curve)
and _all_corners(c2, 8),
_corner_counts(c2))
# ---- P12 ----
var want12: Array = [before_rm, _corner_counts(c2)]
want12.sort()
var r12: Dictionary = persist.flatten_map()
var got12: Array = []
var ok12 := 0
for d: String in (persist.live_shapes() as Dictionary):
var st: Dictionary = persist.shape_state(d)
var pc := String((st["params"] as Dictionary).get("profile_curve", ""))
if String(st["type"]) == "cable" and pc != "" and pc != prof and pc != loop_curve \
and (persist.curve_states() as Dictionary).has(pc):
ok12 += 1
got12.append(_corner_counts(d))
got12.sort()
_ok("P12 a flatten keeps both custom profiles on re-minted curves, the same pieces",
not r12.has("error") and ok12 == 2 and got12 == want12, [ok12, got12, want12, r12.get("error", "")])
panel.queue_free()