OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / 69c5d1a

#120 L3 §14.2: custom cable profiles — a closed curve, live
CurveMath.profile_outline: a closed curve's convex 2D outline (Newell plane, hull, <= 24 vertices, normalised). param_cable profile "custom" + hidden profile_curve; ShapeFold passes the profile curve and takes its <ver>; a missing profile curve folds round under "r" names (else BrushCreate restored the custom planes — caught by mutation). Index follows path + profile curves; flatten remaps both; delete keeps a path curve another cable profiles on. Shape panel: Profile curve dropdown of closed curves (one shape.set). gate_cable_profile 16/16; 17 related gates green. ci.yaml entry pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
69c5d1a0521f17b56d151a6f2457a6b86026a3ab
by plankatron <fleeky@gmail.com> · 2026-10-19T14:12:44.000Z

Changed files (8) +439 -18

docs/node-tree-and-stacks.md +17 -1
……@@ -894,7 +894,10 @@ Cablerator's multi-profile repeats the profile around the cable's axis. Ours, on
894894### 14.2 Custom profile (a closed curve, live)
895895
896896- `profile` gains `custom`, with a hidden `profile_curve` (the curve's dot), set by the
897 Shape panel's **Set profile** picker (pick a curve in the viewport; Cablerator's A).
897 Shape panel's **Profile curve** dropdown: the room's CLOSED curves by name, sorted by
898 dot, "—" first (= back to round). Picking one writes ONE `shape.set {profile: custom,
899 profile_curve}`. (Built 2026-10-19 as a dropdown, the follow-curve pattern: the editor
900 has no viewport pick for curves yet, so Cablerator's "pick it" is deferred with it.)
898901- **The 2D outline**, a pure function `CurveMath.profile_outline(curve)`:
899902 - sample the closed curve (4 samples per segment, so a `vector`-handle corner is exact);
900903 - its plane: Newell normal of the samples, origin = their centroid, u = the direction
……@@ -911,6 +914,11 @@ Cablerator's multi-profile repeats the profile around the cable's axis. Ours, on
911914 and no other curve does. Flatten remaps `profile_curve` as it does `curve`.
912915- Profile curve removed, open, or under 3 distinct points: the cable folds as `round`
913916 (it does not vanish); undo brings the curve back and the profile with it.
917- **Names:** `<ver>` also takes the profile curve's, so a profile edit renames the pieces.
918 With the profile curve GONE, `<ver>` falls back to an earlier version whose pieces were
919 custom, and BrushCreate would restore those planes by name. So that state adds `r` to the
920 piece names (`…@<ver>r`), as a cutter adds `c`. Without it P10 restored the custom pieces
921 (checked by mutation).
914922- X on a cable never removes its profile curve (only its own path curve, as today).
915923
916924### 14.3 Drop to the ground (`builder.drop_cable`)
……@@ -1023,3 +1031,11 @@ and dc_hull's dedup (SAME_NORMAL 0.9999, about 0.8°) keeps one of each pair of
10231031moving the shared corners: up to 0.17 mm on an untwisted bend (this was true before
10241032strands), up to 0.73 mm with twist 30°/m. Shorter segments do not shrink it. S8 therefore
10251033checks closure to 1 mm, against 0.17 m for a wrong closure.
1034
1035**Status 2026-10-19:** step 2 done (`gate_cable_profile` P1–P12, 16/16).
1036`CurveMath.profile_outline`; `param_cable` `custom` + hidden `profile_curve`;
1037`world_pieces(curve, p, profile)`; the index's `_ix_followed_curves` (path + profile);
1038flatten remaps both; deleting a cable keeps its path curve while another cable profiles on
1039it. Seen while gating: `set_curve_point` writing only `p` leaves a point's stored handles
1040where they were, so a `vector` corner moved that way bends its spans (existing curve
1041behaviour; the curve tool is not affected only if it re-derives — not checked here).
scenes/game/shape_panel.gd +29 -0
……@@ -34,11 +34,32 @@ class ShapeTarget extends LevelOperator:
3434 return []
3535 var st: Dictionary = map._persist.shape_state(dot)
3636 var out: Array = ParamShapes.rows(t, t.resolve(st.get("params", {})))
37 if String(st.get("type", "")) == "cable":
38 # PROFILE CURVE (§14.2): a dropdown of the room's CLOSED curves, as follow-curve
39 # picks its curve in the Stack panel. Index 0 = none.
40 var dots := profile_curves()
41 var names: Array = ["—"]
42 var curves: Dictionary = map._persist.curve_states()
43 for d: String in dots:
44 names.append(String(curves[d].get("name", d)))
45 out.append({"id": "profile_curve", "label": "Profile curve", "type": "enum", "choices": names,
46 "value": dots.find(String((st["params"] as Dictionary).get("profile_curve", ""))) + 1,
47 "tip": "A closed curve whose outline is the cable's cross-section (concave outlines use their hull)"})
3748 out.append({"id": "make_real", "label": "Make Real", "type": "action",
3849 "tip": "Turn the pieces into plain brushes you can edit one by one (Ctrl+Z undoes)."})
3950 out.append({"id": "delete", "label": "Delete", "type": "action", "tip": "Delete the shape (Ctrl+Z undoes)."})
4051 return out
4152
53 ## The room's closed curves, by dot (sorted: the same order on every peer and every frame).
54 func profile_curves() -> Array:
55 var curves: Dictionary = map._persist.curve_states()
56 var out: Array = []
57 for d: String in curves:
58 if bool(curves[d].get("closed", false)):
59 out.append(d)
60 out.sort()
61 return out
62
4263 func adjust_ids() -> Array:
4364 var ids: Array = []
4465 for d: Dictionary in tool_settings():
……@@ -102,6 +123,14 @@ func apply(sid: String, v: Variant) -> void:
102123 map.make_real(_dot)
103124 "delete":
104125 map.delete_brushes(Array(map.level.get_group_brush_ids(_dot)))
126 "profile_curve":
127 # One shape.set: the curve AND profile custom (none = back to round).
128 var dots: Array = _target.profile_curves()
129 var i := int(v) - 1
130 if i >= 0 and i < dots.size():
131 map.set_shape_params(_dot, {"profile": "custom", "profile_curve": dots[i]}, "Profile curve")
132 else:
133 map.set_shape_params(_dot, {"profile": "round", "profile_curve": ""}, "Profile curve")
105134 _:
106135 if not sid.begins_with("p."):
107136 return
scenes/maps/curves/curve_math.gd +74 -0
……@@ -209,6 +209,80 @@ static func sample(pts: Array, closed: bool, count: int) -> Array:
209209 return out
210210
211211
212## Samples per segment for a profile outline: a `vector` corner lands on a sample exactly.
213const PROFILE_STEPS := 4
214## A profile outline has at most this many vertices: a cable piece is the hull of two
215## rings, and Jolt refused a hull of 63 planes (§11.4).
216const PROFILE_MAX := 24
217
218
219## A CUSTOM CABLE PROFILE (§14.2): a CLOSED curve `c` ({points, closed}) as a convex 2D
220## outline, normalised so its farthest vertex is 1 (a cable's `radius` stays half its width).
221## Its plane: the samples' Newell normal through their centroid, u = towards the first
222## sample, v = normal x u — derived from the points alone, so every peer agrees. A concave
223## outline becomes its convex hull. Empty when the curve is open, flat to a line, or under
224## 3 distinct points.
225static func profile_outline(c: Dictionary) -> PackedVector2Array:
226 var pts: Array = c.get("points", [])
227 if not bool(c.get("closed", false)) or pts.size() < 3:
228 return PackedVector2Array()
229 var smp := PackedVector3Array()
230 for i in segment_count(pts, true):
231 var ctl := controls(pts, true, i)
232 for k in PROFILE_STEPS:
233 smp.append(bezier(ctl, float(k) / PROFILE_STEPS))
234 var o := Vector3.ZERO
235 for q: Vector3 in smp:
236 o += q
237 o /= float(smp.size())
238 var nrm := Vector3.ZERO
239 for i in smp.size():
240 var a := smp[i] - o
241 var b := smp[(i + 1) % smp.size()] - o
242 nrm += a.cross(b)
243 if nrm.length() < 1.0e-9:
244 return PackedVector2Array()
245 nrm = nrm.normalized()
246 var u := Vector3.ZERO
247 for q: Vector3 in smp:
248 var w := (q - o) - nrm * (q - o).dot(nrm)
249 if w.length() > 1.0e-6:
250 u = w.normalized()
251 break
252 if u == Vector3.ZERO:
253 return PackedVector2Array()
254 var v := nrm.cross(u)
255 var flat := PackedVector2Array()
256 for q: Vector3 in smp:
257 flat.append(Vector2((q - o).dot(u), (q - o).dot(v)))
258 var hull := Geometry2D.convex_hull(flat)
259 if hull.size() > 1 and hull[0].is_equal_approx(hull[hull.size() - 1]):
260 hull.remove_at(hull.size() - 1) # convex_hull closes the loop
261 # Collinear vertices add planes and no shape.
262 var out := PackedVector2Array()
263 for i in hull.size():
264 var p0 := hull[(i - 1 + hull.size()) % hull.size()]
265 var p1 := hull[i]
266 var p2 := hull[(i + 1) % hull.size()]
267 if absf((p1 - p0).cross(p2 - p1)) > 1.0e-9:
268 out.append(p1)
269 while out.size() > PROFILE_MAX:
270 var half := PackedVector2Array()
271 for i in range(0, out.size(), 2):
272 half.append(out[i])
273 out = half
274 if out.size() < 3:
275 return PackedVector2Array()
276 var far := 0.0
277 for q2: Vector2 in out:
278 far = maxf(far, q2.length())
279 if far < 1.0e-9:
280 return PackedVector2Array()
281 for i in out.size():
282 out[i] = out[i] / far
283 return out
284
285
212286## A per-point value (tilt, radius) interpolated by arc length between the two points
213287## of the segment `d` lies on.
214288static func _along(pts: Array, closed: bool, tb: Dictionary, d: float, key: String) -> float:
scenes/maps/level_persistence.gd +20 -12
……@@ -974,8 +974,9 @@ func flatten_map() -> Dictionary:
974974 var sz: Vector3 = s["size"]
975975 shape_remap[d] = reserve_dot()
976976 var sp: Dictionary = (s["params"] as Dictionary).duplicate(true)
977 if sp.has("curve"):
978 sp["curve"] = String(curve_remap.get(String(sp["curve"]), sp["curve"]))
977 for ck: String in ["curve", "profile_curve"]: # a cable's path and its profile
978 if sp.has(ck):
979 sp[ck] = String(curve_remap.get(String(sp[ck]), sp[ck]))
979980 shape_writes.append([shape_remap[d], {"type": s["type"], "gv": s["gv"], "params": sp,
980981 "frame": SceneObjects.xform_to(s["frame"]), "size": [sz.x, sz.y, sz.z],
981982 "material_id": s["material_id"], "level_id": s["level_id"], "tex": s.get("tex", {}),
……@@ -1236,8 +1237,9 @@ func remove_shape(dot: String) -> String:
12361237 return out
12371238 var recs: Array = records(false)
12381239 for d: String in (live_shapes() as Dictionary):
1239 if d != dot and String((shape_state(d)["params"] as Dictionary).get("curve", "")) == cdot:
1240 return out
1240 var dp: Dictionary = shape_state(d)["params"]
1241 if d != dot and (String(dp.get("curve", "")) == cdot or String(dp.get("profile_curve", "")) == cdot):
1242 return out # its path, or its custom profile (§14.2)
12411243 for m: Dictionary in ModFold.resolve_states(recs).values():
12421244 if not bool(m["removed"]) and String((m["params"] as Dictionary).get("curve", "")) == cdot:
12431245 return out
……@@ -1451,8 +1453,7 @@ func _ix_add(key: String, value: Variant) -> void:
14511453 _ix_curve_keys[key] = true # no brushes: the level does not re-fold ...
14521454 _ix_curve_touched(rec, dot) # ... unless a follow-curve reads it
14531455 else:
1454 var fc := _ix_followed_curve(rec)
1455 if fc != "":
1456 for fc: String in _ix_followed_curves(rec):
14561457 _ix_followed[fc] = int(_ix_followed.get(fc, 0)) + 1
14571458 _ix_brush_ver += 1
14581459
……@@ -1488,21 +1489,28 @@ func _ix_drop(key: String) -> void:
14881489 _ix_curve_keys.erase(key)
14891490 _ix_curve_touched(m["rec"], "%s:%d" % [String(m["author"]), int(m["seq"])])
14901491 else:
1491 var fc := _ix_followed_curve(m["rec"])
1492 if fc != "":
1492 for fc: String in _ix_followed_curves(m["rec"]):
14931493 _ix_followed[fc] = int(_ix_followed.get(fc, 0)) - 1
14941494 if int(_ix_followed[fc]) <= 0:
14951495 _ix_followed.erase(fc)
14961496 _ix_brush_ver += 1
14971497
14981498
1499## The curve a mod.* or shape.* record names (params.curve: a follow-curve, a cable), or "".
1500static func _ix_followed_curve(rec: Dictionary) -> String:
1499## The curves a mod.* or shape.* record names: params.curve (a follow-curve, a cable's
1500## path) and params.profile_curve (a cable's custom profile, §14.2). Empty dots dropped.
1501static func _ix_followed_curves(rec: Dictionary) -> Array:
1502 var out: Array = []
15011503 var k := String(rec.get("kind", ""))
15021504 if not ModFold.handles(k) and not ShapeFold.handles(k):
1503 return ""
1505 return out
15041506 var ps: Variant = rec.get("params", {})
1505 return String((ps as Dictionary).get("curve", "")) if ps is Dictionary else ""
1507 if not (ps is Dictionary):
1508 return out
1509 for key: String in ["curve", "profile_curve"]:
1510 var c := String((ps as Dictionary).get(key, ""))
1511 if c != "" and not out.has(c):
1512 out.append(c)
1513 return out
15061514
15071515
15081516## A curve record changed (`dot` = its own key's dot). When a modifier record names that
scenes/maps/shapes/param/param_cable.gd +19 -2
……@@ -55,7 +55,11 @@ func id() -> String:
5555func params() -> Array:
5656 return [
5757 {"id": "curve", "label": "Curve", "type": "string", "default": "", "hidden": true},
58 {"id": "profile", "label": "Profile", "type": "enum", "options": ["round", "square", "strip"], "default": "round"},
58 {"id": "profile", "label": "Profile", "type": "enum", "options": ["round", "square", "strip", "custom"], "default": "round",
59 "tip": "Custom: the closed curve picked as Profile curve"},
60 # Custom (§14.2): the closed curve whose outline is the cross-section. Set by the
61 # Shape panel's Profile curve row, not typed.
62 {"id": "profile_curve", "label": "Profile curve", "type": "string", "default": "", "hidden": true},
5963 {"id": "sides", "label": "Sides", "type": "int", "default": 8, "min": 3, "max": 32, "step": 1,
6064 "tip": "Round profile only"},
6165 {"id": "radius", "label": "Radius", "type": "float", "default": 0.05, "min": 0.005, "max": 2.0,
……@@ -89,10 +93,19 @@ func params() -> Array:
8993
9094## Pieces [{planes, role}] in WORLD space for `curve` (a CurveFold state: {points, closed})
9195## and the resolved params `p`. Empty with no curve or a curve of fewer than 2 points.
92func world_pieces(curve: Dictionary, p: Dictionary) -> Array:
96## `profile` = the profile curve's state when `p.profile` is custom; with no usable
97## outline (no curve, open, too few points) the cable folds round.
98func world_pieces(curve: Dictionary, p: Dictionary, profile: Dictionary = {}) -> Array:
9399 var out: Array = []
94100 if curve.is_empty() or (curve.get("points", []) as Array).size() < 2:
95101 return out
102 if String(p["profile"]) == "custom":
103 var ol := CurveMath.profile_outline(profile)
104 p = p.duplicate()
105 if ol.size() >= 3:
106 p["_outline"] = ol
107 else:
108 p["profile"] = "round"
96109 var pts: Array = curve["points"]
97110 var closed := bool(curve.get("closed", false))
98111 var length := CurveMath.length(pts, closed)
……@@ -204,6 +217,10 @@ func _ring(c: Vector3, u: Vector3, v: Vector3, r: float, p: Dictionary) -> Packe
204217 var h := r * float(p["flat"])
205218 for q2: Vector2 in [Vector2(1, 1), Vector2(-1, 1), Vector2(-1, -1), Vector2(1, -1)]:
206219 out.append(c + u * (q2.x * h) + v * (q2.y * r))
220 "custom":
221 # The outline world_pieces put in (normalised: its farthest vertex is 1).
222 for q3: Vector2 in (p["_outline"] as PackedVector2Array):
223 out.append(c + u * (q3.x * r) + v * (q3.y * r))
207224 _:
208225 var n := int(p["sides"])
209226 for k in n:
scenes/maps/shapes/param/shape_fold.gd +17 -3
……@@ -133,8 +133,20 @@ static func _link_data(states: Dictionary, s: Dictionary) -> void:
133133
134134## A cable's frame / size from its curve (axis-aligned, padded by the widest the profile
135135## gets: a connector's 1.6 x the radius x the largest point radius), and <ver> the curve's.
136##
137## A CUSTOM PROFILE (§14.2): <ver> takes the profile curve's too, so editing it renames the
138## pieces. With the profile curve GONE the cable folds round, and `profile_gone` adds "r"
139## to the names: <ver> falls back to an earlier version whose pieces were custom, and
140## BrushCreate would restore those planes by name.
136141static func _cable_bounds(s: Dictionary, curves: Dictionary) -> void:
137 var cv: Dictionary = curves.get(String((s["params"] as Dictionary).get("curve", "")), {})
142 var ps: Dictionary = s["params"]
143 if String(ps.get("profile", "")) == "custom":
144 var pcv: Dictionary = curves.get(String(ps.get("profile_curve", "")), {})
145 if pcv.is_empty():
146 s["profile_gone"] = true
147 else:
148 s["ver"] = maxi(int(s["ver"]), int(pcv.get("ver", 0)))
149 var cv: Dictionary = curves.get(String(ps.get("curve", "")), {})
138150 if cv.is_empty() or (cv["points"] as Array).size() < 2:
139151 return
140152 s["ver"] = maxi(int(s["ver"]), int(cv.get("ver", 0)))
……@@ -192,7 +204,8 @@ static func expand(records: Array, cutters: Dictionary = {}, curves: Dictionary
192204 # A cable's pieces are already in the WORLD (it reads a curve, not a box).
193205 var world: bool = t.has_method("world_pieces")
194206 if world:
195 for pcw: Dictionary in t.world_pieces(curves.get(String(p.get("curve", "")), {}), p):
207 for pcw: Dictionary in t.world_pieces(curves.get(String(p.get("curve", "")), {}), p,
208 curves.get(String(p.get("profile_curve", "")), {})):
196209 pieces.append([pcw, false])
197210 for pc: Dictionary in t.local_pieces(size, p):
198211 pieces.append([pc, false])
……@@ -219,7 +232,8 @@ static func expand(records: Array, cutters: Dictionary = {}, curves: Dictionary
219232 for _k in (pc3["planes"] as PackedFloat32Array).size() / 4:
220233 face_tex.append(tex.duplicate())
221234 out.append({
222 "author": "%s/%d@%d%s" % [s["author"], i, s["ver"], "c" if is_cutter else ""],
235 "author": "%s/%d@%d%s%s" % [s["author"], i, s["ver"], "c" if is_cutter else "",
236 "r" if bool(s.get("profile_gone", false)) else ""],
223237 "seq": s["seq"], "kind": "brush.create", "cutter": is_cutter,
224238 "undone": false, "planes": pc3["planes"] if world else transform_planes(pc3["planes"], frame),
225239 "proj": PackedInt32Array(), "material_id": s["material_id"],
scenes/test/gate_cable_profile.gd +257 -0
……@@ -0,0 +1,257 @@
1extends Node
2## GATE — CUSTOM CABLE PROFILES (#120 L3, docs/node-tree-and-stacks.md §14.2): a cable
3## whose cross-section is a closed CURVE, live.
4## P1 profile_outline of a closed square: 4 vertices, each 1 from the centre (normalised)
5## P2 a concave L outline becomes its convex hull: 5 vertices
6## P3 a 40-point circle: at most 24 vertices
7## P4 an open curve, or fewer than 3 distinct points: no outline (empty)
8## P5 a cable on the square outline: square-prism pieces (8 corners, 6 planes), corners
9## r from the axis
10## P6 the L outline: each piece the hull of two 5-gons (10 corners)
11## P7 in the fold: Profile curve picked in the Shape panel = ONE shape.set (custom + the
12## curve); the open path curve is not offered
13## P8 editing the profile curve re-folds the cable (new piece names, new planes)
14## P9 editing an UNRELATED curve does not re-fold the level
15## P10 the profile curve removed: the cable folds ROUND (16 corners); Ctrl+Z: custom again
16## P11 deleting a cable keeps its path curve when another cable uses it as a profile
17## P12 a flatten keeps the custom profile on the re-minted profile curve
18##
19## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_profile.tscn
20
21const Cable := preload("res://scenes/maps/shapes/param/param_cable.gd")
22
23var fails := 0
24var checks := 0
25var map: Node3D
26var persist: Node
27var level: Node
28var _cm := CurveMath.new() # profile_outline is static; called by name so the gate parses before it exists
29
30
31func _ok(what: String, cond: bool, note: Variant = "") -> void:
32 checks += 1
33 if not cond:
34 fails += 1
35 print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
36
37
38func _ready() -> void:
39 _pure()
40 var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
41 gs.route("world:level:*", 1, "peer")
42 var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
43 if keet != null:
44 keet.current_member_id = "gate-author"
45 get_tree().root.get_node("/root/GameMode").set_mode("build")
46 map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
47 add_child(map)
48 for i in 6:
49 await get_tree().process_frame
50 await _run()
51 get_tree().root.get_node("/root/GameMode").set_mode("play")
52 if checks < 14:
53 print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
54 fails += 1
55 print("\nGATE cable_profile: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
56 get_tree().quit(0 if fails == 0 else 1)
57
58
59## Points with VECTOR handles (straight spans: the outline's corners are exact).
60func _vec_pts(ps: Array) -> Array:
61 var out: Array = []
62 for p: Vector3 in ps:
63 var q := CurveMath.point(p, Vector3.ZERO)
64 q["handle"] = "vector"
65 out.append(q)
66 return out
67
68
69func _body(pieces: Array) -> Array:
70 var out: Array = []
71 for x: Dictionary in pieces:
72 if String(x["role"]) == "cable":
73 out.append(x)
74 return out
75
76
77func _state(ps: Array, closed := true) -> Dictionary:
78 return {"points": CurveMath.auto_handles(_vec_pts(ps), closed), "closed": closed}
79
80
81## A square 2 x 2 in the YZ plane at x = c.x, and an L of the same span.
82func _square(c: Vector3) -> Array:
83 return [c + Vector3(0, -1, -1), c + Vector3(0, -1, 1), c + Vector3(0, 1, 1), c + Vector3(0, 1, -1)]
84
85
86func _ell(c: Vector3) -> Array:
87 return [c + Vector3(0, 0, 0), c + Vector3(0, 0, 2), c + Vector3(0, 1, 2), c + Vector3(0, 1, 1),
88 c + Vector3(0, 2, 1), c + Vector3(0, 2, 0)]
89
90
91func _pure() -> void:
92 var has_fn := CurveMath.new().has_method("profile_outline")
93 _ok("setup: CurveMath.profile_outline exists", has_fn)
94 if not has_fn:
95 return
96 var sq: PackedVector2Array = _cm.call("profile_outline", _state(_square(Vector3.ZERO)))
97 var unit := sq.size() == 4
98 for v: Vector2 in sq:
99 unit = unit and absf(v.length() - 1.0) < 1.0e-4
100 _ok("P1 a closed square: 4 vertices, each 1 from the centre", unit, sq)
101 var el: PackedVector2Array = _cm.call("profile_outline", _state(_ell(Vector3.ZERO)))
102 _ok("P2 a concave L becomes its hull: 5 vertices", el.size() == 5, el)
103 var cpts: Array = []
104 for k in 40:
105 var a := TAU * k / 40.0
106 cpts.append(CurveMath.point(Vector3(0, cos(a), sin(a)), Vector3.ZERO))
107 var co: PackedVector2Array = _cm.call("profile_outline", {"points": CurveMath.auto_handles(cpts, true), "closed": true})
108 _ok("P3 a 40-point circle: 3..24 vertices", co.size() >= 3 and co.size() <= 24, co.size())
109 var open_o: PackedVector2Array = _cm.call("profile_outline", _state(_square(Vector3.ZERO), false))
110 var two: PackedVector2Array = _cm.call("profile_outline", _state([Vector3.ZERO, Vector3(0, 1, 0)]))
111 _ok("P4 open, or under 3 distinct points: empty", open_o.is_empty() and two.is_empty(), [open_o.size(), two.size()])
112 # ---- P5 / P6: the generator with a profile curve state ----
113 var gen: Object = Cable.new()
114 var path := {"points": CurveMath.auto_handles([CurveMath.point(Vector3(0, 0.5, 0), Vector3.ZERO),
115 CurveMath.point(Vector3(10, 0.5, 0), Vector3.ZERO)], false), "closed": false}
116 var p := gen.resolve({"radius": 0.1, "profile": "custom", "profile_curve": "x"}) as Dictionary
117 var body: Array = _body(gen.world_pieces(path, p, _state(_square(Vector3(50, 0, 0)))))
118 var sq_ok := body.size() == 10
119 var far := 0.0
120 for pc: Dictionary in body:
121 var cs := FaceKit.corners(pc["planes"])
122 sq_ok = sq_ok and cs.size() == 8 and (pc["planes"] as PackedFloat32Array).size() / 4 == 6
123 for v: Vector3 in cs:
124 far = maxf(far, absf(Vector2(v.y - 0.5, v.z).length() - 0.1))
125 _ok("P5 a square outline: square prisms (8 corners, 6 planes), corners r from the axis",
126 sq_ok and far < 1.0e-4, [body.size(), far])
127 var lb: Array = _body(gen.world_pieces(path, p, _state(_ell(Vector3(50, 0, 0)))))
128 var ten := lb.size() == 10
129 for pc2: Dictionary in lb:
130 ten = ten and FaceKit.corners(pc2["planes"]).size() == 10
131 _ok("P6 the L outline: each piece the hull of two 5-gons (10 corners)", ten, lb.size())
132
133
134func _solids(dot: String) -> Array:
135 var out: Array = []
136 for b: int in (level.get_group_brush_ids(dot) as PackedInt32Array):
137 var d: Dictionary = level.get_brush(b)
138 if not bool(d.get("clearance", false)) and not bool(d.get("cutter", false)):
139 out.append(b)
140 return out
141
142
143func _corner_counts(dot: String) -> Array:
144 var out: Array = []
145 for b: int in _solids(dot):
146 out.append(FaceKit.corners(level.get_brush(b)["planes"]).size())
147 return out
148
149
150## Every piece of `dot` has `n` corners (and it has pieces).
151func _all_corners(dot: String, n: int) -> bool:
152 var cc := _corner_counts(dot)
153 return not cc.is_empty() and cc.count(n) == cc.size()
154
155
156func _names(dot: String) -> Array:
157 var out: Array = []
158 for b: int in _solids(dot):
159 out.append(String(level.get_brush_key(b)))
160 out.sort()
161 return out
162
163
164func _params(dot: String) -> Dictionary:
165 return persist.shape_state(dot)["params"]
166
167
168func _run() -> void:
169 persist = map.get("_persist")
170 level = map.get("level")
171 persist.refold()
172 var cable := map.add_cable([CurveMath.point(Vector3(300, 0.5, 300), Vector3.ZERO),
173 CurveMath.point(Vector3(304, 0.5, 300), Vector3.ZERO)], false, {"radius": 0.1}) as String
174 var path_curve := String(_params(cable)["curve"])
175 var prof := map.create_curve(_vec_pts(_square(Vector3(320, 0.5, 300))), true, "Square") as String
176 var other := map.create_curve(_vec_pts([Vector3(330, 0.5, 300), Vector3(334, 0.5, 300)]), false, "Other") as String
177
178 # ---- P7: the Shape panel row ----
179 var panel: VBoxContainer = VBoxContainer.new()
180 panel.set_script(load("res://scenes/game/shape_panel.gd"))
181 add_child(panel)
182 panel.setup(map)
183 map.select_shape(cable)
184 await get_tree().process_frame
185 var row: Dictionary = {}
186 for r: Dictionary in (panel.get("_target").tool_settings() as Array):
187 if String(r["id"]) == "profile_curve":
188 row = r
189 var choices: Array = row.get("choices", [])
190 _ok("P7 the Shape panel offers Profile curve: the closed curve, not the open path",
191 choices.has("Square") and not choices.has("Cable") and not choices.has("Other"), choices)
192 var n0: int = (persist.records(false) as Array).size()
193 if choices.has("Square"):
194 panel.apply("profile_curve", choices.find("Square"))
195 var n1: int = (persist.records(false) as Array).size()
196 _ok("P7 ... picking it writes ONE shape.set: profile custom on that curve", n1 == n0 + 1
197 and String(_params(cable).get("profile", "")) == "custom" and String(_params(cable).get("profile_curve", "")) == prof,
198 [n1 - n0, _params(cable)])
199 _ok("P7 ... the cable is square prisms now (8 corners each)", _all_corners(cable, 8), _corner_counts(cable))
200
201 # ---- P8 ----
202 var names_before := _names(cable)
203 var planes_before: PackedFloat32Array = level.get_brush(_solids(cable)[0])["planes"] if not _solids(cable).is_empty() else PackedFloat32Array()
204 map.set_curve_point(prof, 2, {"p": Vector3(320, 3.5, 303)}) # pull one corner out: a kite
205 var planes_after: PackedFloat32Array = level.get_brush(_solids(cable)[0])["planes"] if not _solids(cable).is_empty() else PackedFloat32Array()
206 _ok("P8 a profile-curve edit re-folds the cable: new names, new planes",
207 _names(cable) != names_before and planes_after != planes_before, [_names(cable).size(), _corner_counts(cable)])
208
209 # ---- P9 ----
210 var v0: int = int(persist.get("_ix_brush_ver"))
211 map.set_curve_point(other, 1, {"p": Vector3(335, 0.5, 300)})
212 _ok("P9 an unrelated curve edit does not re-fold the level", int(persist.get("_ix_brush_ver")) == v0,
213 [v0, persist.get("_ix_brush_ver")])
214
215 # ---- P10 ----
216 # (P8's edit moved a point but not its stored handles, so the outline is no longer a
217 # 4-gon: compare with the state before the removal, not with a square.)
218 var before_rm := _corner_counts(cable)
219 map.remove_curve(prof)
220 _ok("P10 the profile curve removed: the cable folds ROUND (16 corners)", _all_corners(cable, 16), _corner_counts(cable))
221 persist.undo_last()
222 persist.refold()
223 _ok("P10 ... Ctrl+Z: custom again, as before the removal", not before_rm.is_empty()
224 and before_rm.count(16) == 0 and _corner_counts(cable) == before_rm, [before_rm, _corner_counts(cable)])
225
226 # ---- P11 ----
227 var c2 := map.add_cable([CurveMath.point(Vector3(300, 0.5, 310), Vector3.ZERO),
228 CurveMath.point(Vector3(304, 0.5, 310), Vector3.ZERO)], false, {"radius": 0.1}) as String
229 var loop := map.add_cable(_vec_pts(_square(Vector3(340, 2, 300))), true, {"radius": 0.05}) as String
230 var loop_curve := String(_params(loop)["curve"])
231 map.set_shape_params(c2, {"profile": "custom", "profile_curve": loop_curve})
232 persist.begin_gesture("Delete")
233 persist.remove_shape(loop)
234 persist.end_gesture()
235 persist.refold()
236 _ok("P11 deleting a cable keeps its path curve while another cable profiles on it",
237 (persist.curve_states() as Dictionary).has(loop_curve)
238 and _all_corners(c2, 8),
239 _corner_counts(c2))
240
241 # ---- P12 ----
242 var want12: Array = [before_rm, _corner_counts(c2)]
243 want12.sort()
244 var r12: Dictionary = persist.flatten_map()
245 var got12: Array = []
246 var ok12 := 0
247 for d: String in (persist.live_shapes() as Dictionary):
248 var st: Dictionary = persist.shape_state(d)
249 var pc := String((st["params"] as Dictionary).get("profile_curve", ""))
250 if String(st["type"]) == "cable" and pc != "" and pc != prof and pc != loop_curve \
251 and (persist.curve_states() as Dictionary).has(pc):
252 ok12 += 1
253 got12.append(_corner_counts(d))
254 got12.sort()
255 _ok("P12 a flatten keeps both custom profiles on re-minted curves, the same pieces",
256 not r12.has("error") and ok12 == 2 and got12 == want12, [ok12, got12, want12, r12.get("error", "")])
257 panel.queue_free()
scenes/test/gate_cable_profile.tscn +6 -0
……@@ -0,0 +1,6 @@
1[gd_scene load_steps=2 format=3]
2
3[ext_resource type="Script" path="res://scenes/test/gate_cable_profile.gd" id="1"]
4
5[node name="CableProfileGate" type="Node"]
6script = ExtResource("1")