OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / 4bc5240

#120 L3 §14.6: cable bundle between two face sets (Shift+Ctrl+C)
BundleCables (pure): the face set's islands (union-find over shared corners; exactly two or refused with the count), seeded ends (a random point by area, or face centres cycled), free end handles along the face normal (tension x chord / 3, jitter), slack. CableBundleOperator: Z / D / N / S focus count / tension / jitter / radius (new ModalControls.focus), R ends, Q seed, Adjust Last. map.cable_bundle: N curves + cables and a "Bundle" collection in one gesture. gate_cable_bundle 20/20 (in ci.yaml), 3 mutants killed; 33 related gates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4bc52405b61f6bcb31e16a4f3a092a2587594b69
by plankatron <fleeky@gmail.com> · 2026-10-19T15:30:00.000Z

Changed files (10) +718 -2

.pear-git/ci.yaml +4 -0
……@@ -1126,6 +1126,10 @@ steps:
11261126 # Cables from edges (#120 L3 §14.5): chains (stop at a 3-way vertex, close a loop),
11271127 # vertices / centres, offset along the mean normal, corners; Alt+C, one gesture.
11281128 run: "HYPERTPS_DAEMON_TCP=127.0.0.1:1 /storage/projects/code/godot/nvidia-rtx-godot-v3/bin/godot.linuxbsd.editor.x86_64 --headless --path . res://scenes/test/gate_cable_edges.tscn"
1129 - name: gate-cable-bundle
1130 # A bundle between two face sets (#120 L3 §14.6): islands, seeded ends on the faces,
1131 # free end handles, slack; N cables + a Bundle collection in one gesture; Shift+Ctrl+C.
1132 run: "HYPERTPS_DAEMON_TCP=127.0.0.1:1 /storage/projects/code/godot/nvidia-rtx-godot-v3/bin/godot.linuxbsd.editor.x86_64 --headless --path . res://scenes/test/gate_cable_bundle.tscn"
11291133 - name: gate-curve-overlay
11301134 # Curves (wire, points, held handles) are an editor overlay: hidden in play mode, the
11311135 # Curves switch (Shift+Alt+K) and the master switch hide them in build (2026-10-18).
autoloads/keymap.gd +3 -0
……@@ -399,6 +399,9 @@ const DEFAULTS: Dictionary = {
399399 "builder.drop_cable": {"key": KEY_D, "ctrl": true, "shift": true, "label": "Drop the selected cables to the ground (ends stay)"},
400400 # #120 L3 §14.5. Alt+C: Blender 2.79's Convert (to Curve from Mesh), the nearest act.
401401 "builder.cable_from_edges": {"key": KEY_C, "alt": true, "label": "Cable from edges (edge mode: a cable per chain of selected edges)"},
402 # #120 L3 §14.6. Shift+Ctrl+C, beside Alt+C (Blender 2.79's is Add Constraint with
403 # targets; there are no constraints here).
404 "builder.cable_bundle": {"key": KEY_C, "ctrl": true, "shift": true, "label": "Cable bundle between two face sets (face mode: two islands)"},
402405 "builder.assign_generator": {"label": "Generator… (stairs, tunnel … on this shape; it stays the passage)"},
403406 "builder.add_modifier": {"key": KEY_M, "ctrl": true, "shift": true, "label": "Add modifier… (mirror, array, grid, radial, curve, chamfer, dice on this shape)"},
404407 # Menu-only, as in Blender (the outliner's / X menu's Delete Hierarchy): X keeps children.
docs/node-tree-and-stacks.md +9 -0
……@@ -1012,6 +1012,13 @@ Cablerator's Create a Cable Mass:
10121012- The map's `add_cable` splits into `_record_cable(points, closed, params)` (records only)
10131013 and the gesture around it, so from-edges and the bundle write many cables in one
10141014 gesture.
1015- **Built 2026-10-19** (`gate_cable_bundle` 20/20; 3 mutants killed): `BundleCables` (pure:
1016 islands by union-find over shared corners, ends, cables), `CableBundleOperator`,
1017 `map.cable_bundle`, and `ModalControls.focus(id)` so Z / D / N / S jump to their value
1018 ([ ] then steps it). Slack is a bound value (Tab), not [ ] directly: [ ] steps whatever
1019 is focused, in every tool. `builder.cable_bundle` = **Shift+Ctrl+C**, in Add ▸ Other and
1020 the face Q menu when 2+ faces are in the set. It reads `_selected_face_polys()`, which
1021 still has §14.4's unchecked tombstone lookup.
10151022
10161023### 14.7 Menus and keys
10171024
……@@ -1022,6 +1029,8 @@ Every command in a menu and on a key (standing rule):
10221029 and recorded here:
10231030 - Drop to ground: **Ctrl+Shift+D** (beside Shift+D / Alt+D; free in Blender's 3D view).
10241031 - Cable from edges: **Alt+C** (Blender 2.79's Convert, to Curve from Mesh).
1032 - Cable bundle: **Shift+Ctrl+C** (beside Alt+C; Blender 2.79's Add Constraint with
1033 targets — there are no constraints here).
10251034
10261035### 14.8 Order and gates (each red first)
10271036
scenes/game/editor_top_bar.gd +1 -1
……@@ -26,7 +26,7 @@ const MENUS: Array = [
2626 "build_mode.sel_brush", "builder.element_toggle", "builder.select_mode_menu"]],
2727 ["Add", ["builder.add_menu", "-", "builder.add_box", "builder.add_stairs", "builder.add_spiral_stairs",
2828 "builder.add_tunnel", "builder.add_cylinder", "builder.add_cone", "builder.add_sphere", "-",
29 "builder.create_box", "builder.add_curve", "builder.add_cable", "builder.cable_from_edges", "builder.carve"]],
29 "builder.create_box", "builder.add_curve", "builder.add_cable", "builder.cable_from_edges", "builder.cable_bundle", "builder.carve"]],
3030 # Blender 2.79's OBJECT menu (was "Brush"): transforms, CSG, pivot, and since #120 F1
3131 # parenting, join / separate and collections. One menu, not a new one: a seventh title
3232 # pushed the top band past a 1280-px window (gate_editor_e1).
scenes/maps/curves/bundle_cables.gd +137 -0
……@@ -0,0 +1,137 @@
1extends RefCounted
2class_name BundleCables
3## A BUNDLE BETWEEN TWO FACE SETS (#120 L3, docs/node-tree-and-stacks.md §14.6):
4## Cablerator's Create a Cable Mass, on the editor's face set. Pure and static: the faces
5## come in as map._selected_face_polys() gives them ({pts, n}), the cables' curve points go
6## out, and the map writes them.
7##
8## ISLANDS: the faces' connected components by shared corners (within ON). Exactly two, or
9## nothing is made and `islands` says how many there were (Cablerator refuses the same way).
10## Island A = the one holding the first face.
11##
12## ENDS, from one Pcg32 stream (`seed`), cable i from A_i to B_i:
13## random a face picked by area, a triangle of its fan by area, a uniform point in it;
14## centres the island's face centres, cycled (cable i uses centre i mod faces).
15## Each end's normal is its face's. Both handles are FREE: an end leaves along its normal,
16## tension x chord / 3 long, its tension x (1 + jitter x u), u in [-1, 1] from the same
17## stream (drawn whatever the jitter, so changing it never moves the ends). `slack` hangs
18## each cable as the curve tool does (CurveOperator.hung: interior points).
19
20const Pcg32Script := preload("res://scenes/maps/shapes/pcg32.gd")
21const CurveOperatorScript := preload("res://scenes/maps/operators/curve_operator.gd")
22## Two corners closer than this (metres) are one.
23const ON := 1.0e-3
24
25
26## The faces' islands: [[face index, …], …], each ascending, ordered by their first face.
27static func islands(faces: Array) -> Array:
28 var parent: Array = []
29 for i in faces.size():
30 parent.append(i)
31 for i in faces.size():
32 for j in range(i + 1, faces.size()):
33 if _touch(faces[i], faces[j]):
34 var ri := _root(parent, i)
35 var rj := _root(parent, j)
36 if ri != rj:
37 parent[maxi(ri, rj)] = mini(ri, rj)
38 var by_root := {}
39 var order: Array = []
40 for i in faces.size():
41 var r := _root(parent, i)
42 if not by_root.has(r):
43 by_root[r] = []
44 order.append(r)
45 (by_root[r] as Array).append(i)
46 var out: Array = []
47 for r: int in order:
48 out.append(by_root[r])
49 return out
50
51
52static func _root(parent: Array, i: int) -> int:
53 while int(parent[i]) != i:
54 i = int(parent[i])
55 return i
56
57
58static func _touch(a: Dictionary, b: Dictionary) -> bool:
59 for p: Vector3 in (a["pts"] as PackedVector3Array):
60 for q: Vector3 in (b["pts"] as PackedVector3Array):
61 if p.distance_to(q) < ON:
62 return true
63 return false
64
65
66## The bundle: {islands: how many, cables: [[CurveMath points], …]} — no cables unless
67## there are exactly two islands. `o` = {count, random, seed, tension, jitter, slack}.
68static func bundle(faces: Array, o: Dictionary) -> Dictionary:
69 var isl := islands(faces)
70 if isl.size() != 2:
71 return {"islands": isl.size(), "cables": []}
72 var A: Array = (isl[0] as Array).map(func(i: int) -> Dictionary: return faces[i])
73 var B: Array = (isl[1] as Array).map(func(i: int) -> Dictionary: return faces[i])
74 var ta := _tris(A)
75 var tb := _tris(B)
76 var rng: Object = Pcg32Script.seeded(maxi(0, int(o.get("seed", 0))), 1)
77 var random := bool(o.get("random", true))
78 var tension := float(o.get("tension", 1.0))
79 var jitter := float(o.get("jitter", 0.0))
80 var slack := float(o.get("slack", 0.0))
81 var cables: Array = []
82 for i in maxi(0, int(o.get("count", 1))):
83 var ea := _end(A, ta, i, random, rng)
84 var eb := _end(B, tb, i, random, rng)
85 var u1: float = rng.range_f(-1.0, 1.0)
86 var u2: float = rng.range_f(-1.0, 1.0)
87 var chord := (ea["p"] as Vector3).distance_to(eb["p"])
88 var pa := CurveMath.point(ea["p"], ea["n"])
89 pa["handle"] = "free"
90 pa["h_out"] = (ea["n"] as Vector3) * tension * (1.0 + jitter * u1) * chord / 3.0
91 pa["h_in"] = -(pa["h_out"] as Vector3)
92 var pb := CurveMath.point(eb["p"], eb["n"])
93 pb["handle"] = "free"
94 pb["h_in"] = (eb["n"] as Vector3) * tension * (1.0 + jitter * u2) * chord / 3.0
95 pb["h_out"] = -(pb["h_in"] as Vector3)
96 var pts: Array = [pa, pb]
97 if slack > 1.0e-6:
98 pts = CurveOperatorScript.hung(pts, false, slack)
99 cables.append(CurveMath.auto_handles(pts, false))
100 return {"islands": 2, "cables": cables}
101
102
103## An island's fan triangles: {tris: [[a, b, c, n, area]], total}.
104static func _tris(island: Array) -> Dictionary:
105 var tris: Array = []
106 var total := 0.0
107 for f: Dictionary in island:
108 var pts: PackedVector3Array = f["pts"]
109 for k in range(1, pts.size() - 1):
110 var area := (pts[k] - pts[0]).cross(pts[k + 1] - pts[0]).length() * 0.5
111 if area > 1.0e-12:
112 tris.append([pts[0], pts[k], pts[k + 1], f["n"], area])
113 total += area
114 return {"tris": tris, "total": total}
115
116
117## End i on an island: {p, n}.
118static func _end(island: Array, t: Dictionary, i: int, random: bool, rng: Object) -> Dictionary:
119 if not random or float(t["total"]) <= 0.0:
120 var f: Dictionary = island[i % island.size()]
121 var c := Vector3.ZERO
122 for p: Vector3 in (f["pts"] as PackedVector3Array):
123 c += p
124 return {"p": c / float((f["pts"] as PackedVector3Array).size()), "n": f["n"]}
125 var pick: float = rng.next_float() * float(t["total"])
126 var tri: Array = t["tris"][(t["tris"] as Array).size() - 1]
127 for tr: Array in (t["tris"] as Array):
128 if pick < float(tr[4]):
129 tri = tr
130 break
131 pick -= float(tr[4])
132 var s := sqrt(float(rng.next_float()))
133 var r2: float = rng.next_float()
134 var a: Vector3 = tri[0]
135 var b: Vector3 = tri[1]
136 var c2: Vector3 = tri[2]
137 return {"p": a * (1.0 - s) + b * (s * (1.0 - r2)) + c2 * (s * r2), "n": tri[3]}
scenes/maps/map_dc_level.gd +45 -1
……@@ -557,6 +557,7 @@ func context_sections() -> Array:
557557 ["build_mode.faces_extrude"]])
558558 if _sel_faces.size() >= 2:
559559 el.append(["build_mode.faces_hull"])
560 el.append(["builder.cable_bundle"]) # #120 L3 §14.6
560561 if _sel_kind == "edge":
561562 el.append(["builder.cable_from_edges"]) # #120 L3 §14.5
562563 el.append(["builder.deselect"])
……@@ -1315,7 +1316,7 @@ func add_sections() -> Array:
13151316 for s: String in ADD_SHAPES:
13161317 rows.append(["builder.add_%s" % s])
13171318 return [["Add shape", rows], ["Other", [["builder.add_curve"], ["builder.add_cable"], ["builder.cable_from_edges"],
1318 ["builder.carve"]]]]
1319 ["builder.cable_bundle"], ["builder.carve"]]]]
13191320
13201321
13211322## Start the create tool already set to `shape_id`.
……@@ -3045,6 +3046,7 @@ func _ensure_ops() -> OperatorStack:
30453046 _curve_edit = _make_op(CurveEditOperator.new(), "CurveEdit")
30463047 _drop_op = _make_op(DropCableOperatorScript.new(), "DropCable") # #120 L3 §14.3
30473048 _edges_op = _make_op(CableEdgesOperatorScript.new(), "CableEdges") # #120 L3 §14.5
3049 _bundle_op = _make_op(CableBundleOperatorScript.new(), "CableBundle") # #120 L3 §14.6
30483050 _grab_op = _make_op(ElementOperator.new(), "Grab")
30493051 _extrude = _make_op(ExtrudeOperator.new(), "Extrude")
30503052 _box = _make_op(CreateBoxOperator.new(), "CreateBox")
……@@ -4371,6 +4373,10 @@ func _builder_key(event: InputEvent) -> void:
43714373 if _ensure_ops().activate(_curve_edit):
43724374 print("[MapDCLevel] curve edit: the point follows your aim, Tab next, X delete, I insert, Enter applies")
43734375 return
4376 if Keymap.tapped(event, "builder.cable_bundle"):
4377 if _ensure_ops().activate(_bundle_op):
4378 print("[MapDCLevel] cable bundle: Z count, D tension, N jitter, S radius, R ends, Q seed, Enter adds")
4379 return
43744380 if Keymap.tapped(event, "builder.cable_from_edges"):
43754381 if _ensure_ops().activate(_edges_op):
43764382 print("[MapDCLevel] cable from edges: P vertices / centres, C corners, [ ] offset, Enter adds")
……@@ -5819,6 +5825,44 @@ func cables_from_edges(edges: Array, centres: bool, offset: float, sharp: bool,
58195825 return out
58205826
58215827
5828## A BUNDLE BETWEEN TWO FACE SETS (#120 L3 §14.6). Preloaded for the class-cache reason.
5829const CableBundleOperatorScript := preload("res://scenes/maps/operators/cable_bundle_operator.gd")
5830const BundleCablesScript := preload("res://scenes/maps/curves/bundle_cables.gd")
5831var _bundle_op: LevelOperator = null
5832
5833
5834## The bundle on `faces` (_selected_face_polys()' form; exactly two islands) with options `o`
5835## (BundleCables.bundle): N curves + cables and a "Bundle" collection holding them (under
5836## Scene), ONE gesture; the cables become the selection. Their dots ([] when refused).
5837func cable_bundle(faces: Array, o: Dictionary, params: Dictionary = {}) -> Array:
5838 _ensure_persistence()
5839 var out: Array = []
5840 var cables: Array = BundleCablesScript.bundle(faces, o)["cables"]
5841 if cables.is_empty():
5842 return out
5843 _persist.begin_gesture("Cable bundle")
5844 for pts: Array in cables:
5845 var d := _record_cable(pts, false, params, false)
5846 if d != "":
5847 out.append(d)
5848 if not out.is_empty():
5849 var col: String = _persist.record("col.create", {"name": "Bundle", "parent": ""})
5850 var members: Array = []
5851 for d2: String in out:
5852 members.append("g:" + d2)
5853 if col != "":
5854 _persist.record("col.link", {"target": col, "members": members})
5855 _persist.end_gesture()
5856 if out.is_empty():
5857 return out
5858 _persist.refold()
5859 var ids := PackedInt32Array()
5860 for d3: String in out:
5861 ids.append_array(level.get_group_brush_ids(d3) as PackedInt32Array)
5862 select_brushes(ids)
5863 return out
5864
5865
58225866## Is `p` inside (or on, within FaceKit.ON_PLANE) any of the convex plane sets?
58235867static func _inside_any(p: Vector3, sets: Array) -> bool:
58245868 for pl: PackedFloat32Array in sets:
scenes/maps/operators/cable_bundle_operator.gd +194 -0
……@@ -0,0 +1,194 @@
1extends LevelOperator
2## A BUNDLE BETWEEN TWO FACE SETS (#120 L3, docs/node-tree-and-stacks.md §14.6):
3## Cablerator's Create a Cable Mass on the face set — exactly two islands. The preview draws
4## the cables; Enter writes ONE gesture (N curves + cables and a "Bundle" collection holding
5## them, map.cable_bundle). Adjust Last re-runs it with the same seed.
6## Keys (Cablerator's, tool-local): Z count, D tension, N tension jitter, S radius — each
7## focuses its value, then [ ] steps it (Tab cycles, as everywhere); R random points / face
8## centres; Q a new seed.
9
10const BundleCablesScript := preload("res://scenes/maps/curves/bundle_cables.gd")
11
12var count := 8
13var tension := 1.0
14var jitter := 0.3
15var radius := 0.03
16var slack := 0.0
17var random := true
18var rng_seed := 1
19var _faces: Array = [] # _selected_face_polys() at activation (Adjust Last: stored)
20var _frozen := false
21
22
23func op_name() -> String:
24 return "cable bundle"
25
26
27func gesture_label() -> String:
28 return "Cable bundle"
29
30
31func preview_color() -> Color:
32 return Color(1.0, 0.85, 0.3, 0.9) # the curve view's amber
33
34
35func setup_modal(m: ModalControls) -> void:
36 m.confirm_label = "add the bundle"
37 m.table([["R", "ends", func() -> String: return "random points" if random else "face centres"],
38 ["Q", "new seed", func() -> String: return "%d" % rng_seed]])
39 m.bind("count", "cables (Z)", func() -> float: return float(count),
40 func(v: float) -> void:
41 count = clampi(roundi(v), 1, 64)
42 update_preview(),
43 1.0, "")
44 m.bind("tension", "tension (D)", func() -> float: return tension,
45 func(v: float) -> void:
46 tension = clampf(v, 0.0, 5.0)
47 update_preview(),
48 0.1, "", 0.02)
49 m.bind("jitter", "tension jitter (N)", func() -> float: return jitter,
50 func(v: float) -> void:
51 jitter = clampf(v, 0.0, 1.0)
52 update_preview(),
53 0.05, "", 0.01)
54 m.bind("radius", "radius (S)", func() -> float: return radius,
55 func(v: float) -> void:
56 radius = clampf(v, 0.005, 2.0)
57 update_preview(),
58 0.01, " m", 0.002)
59 m.bind("slack", "slack", func() -> float: return slack,
60 func(v: float) -> void:
61 slack = maxf(0.0, v)
62 update_preview(),
63 0.1, " m", 0.02)
64
65
66func status() -> String:
67 return modal().status_text()
68
69
70func _opts() -> Dictionary:
71 return {"count": count, "random": random, "seed": rng_seed, "tension": tension, "jitter": jitter, "slack": slack}
72
73
74func on_activate() -> bool:
75 if not _frozen:
76 _faces = map._selected_face_polys() if map != null and map.has_method("_selected_face_polys") else []
77 var n: int = BundleCablesScript.islands(_faces).size()
78 if n != 2:
79 print("[bundle] select exactly 2 face islands (face mode, Shift-click), found %d" % n)
80 return false
81 update_preview()
82 return true
83
84
85func on_deactivate() -> void:
86 clear_preview()
87
88
89func handle_key(event: InputEvent) -> bool:
90 if not (event is InputEventKey) or not (event as InputEventKey).pressed:
91 return false
92 match KeyRules.key_of(event as InputEventKey):
93 KEY_Z:
94 return modal().focus("count")
95 KEY_D:
96 return modal().focus("tension")
97 KEY_N:
98 return modal().focus("jitter")
99 KEY_S:
100 return modal().focus("radius")
101 KEY_R:
102 random = not random
103 update_preview()
104 return true
105 KEY_Q:
106 rng_seed += 1
107 update_preview()
108 return true
109 return modal().handle_key(event)
110
111
112func update_preview() -> void:
113 clear_preview()
114 var lines := PackedVector3Array()
115 for pts: Array in (BundleCablesScript.bundle(_faces, _opts())["cables"] as Array):
116 var fs := CurveMath.sample(pts, false, 48)
117 for i in range(1, fs.size()):
118 lines.append(fs[i - 1]["p"])
119 lines.append(fs[i]["p"])
120 draw_lines(lines)
121
122
123func confirm() -> bool:
124 var dots: Array = map.cable_bundle(_faces, _opts(), {"radius": radius})
125 if dots.is_empty():
126 return false
127 ModalControls.summary("Added a bundle of %d cable%s" % [dots.size(), "" if dots.size() == 1 else "s"])
128 return true
129
130
131func tool_settings() -> Array:
132 return [
133 {"id": "count", "label": "Cables", "type": "int", "value": count, "min": 1, "max": 64, "step": 1},
134 {"id": "tension", "label": "Tension", "type": "float", "value": tension, "min": 0.0, "max": 5.0, "step": 0.1,
135 "tip": "How far each end leaves its face along the normal, x chord / 3"},
136 {"id": "jitter", "label": "Tension jitter", "type": "float", "value": jitter, "min": 0.0, "max": 1.0, "step": 0.05},
137 {"id": "radius", "label": "Radius", "type": "float", "value": radius, "min": 0.005, "max": 2.0, "step": 0.01},
138 {"id": "slack", "label": "Slack", "type": "float", "value": slack, "min": 0.0, "max": 20.0, "step": 0.1,
139 "tip": "Extra length: each cable hangs"},
140 {"id": "random", "label": "Ends", "type": "enum", "value": 0 if random else 1,
141 "choices": ["Random points", "Face centres"], "tip": "R"},
142 {"id": "seed", "label": "Seed", "type": "int", "value": rng_seed, "min": 0, "max": 1000000, "step": 1, "tip": "Q: a new one"},
143 ]
144
145
146func set_tool_setting(sid: String, value: Variant) -> bool:
147 match sid:
148 "count":
149 count = clampi(int(value), 1, 64)
150 "tension":
151 tension = clampf(float(value), 0.0, 5.0)
152 "jitter":
153 jitter = clampf(float(value), 0.0, 1.0)
154 "radius":
155 radius = clampf(float(value), 0.005, 2.0)
156 "slack":
157 slack = maxf(0.0, float(value))
158 "random":
159 random = int(value) == 0
160 "seed":
161 rng_seed = maxi(0, int(value))
162 _:
163 return false
164 if not _frozen:
165 update_preview()
166 return true
167
168
169func adjust_ids() -> Array:
170 return ["count", "tension", "jitter", "radius", "slack", "random", "seed"]
171
172
173func adjust_snapshot() -> Dictionary:
174 var s := _opts()
175 s["faces"] = _faces.duplicate(true)
176 s["radius"] = radius
177 return s
178
179
180func adjust_restore(snap: Dictionary) -> void:
181 _faces = (snap.get("faces", []) as Array).duplicate(true)
182 count = int(snap.get("count", count))
183 random = bool(snap.get("random", random))
184 rng_seed = int(snap.get("seed", rng_seed))
185 tension = float(snap.get("tension", tension))
186 jitter = float(snap.get("jitter", jitter))
187 slack = float(snap.get("slack", slack))
188 radius = float(snap.get("radius", radius))
189 _frozen = true
190
191
192func adjust_end() -> void:
193 _frozen = false
194 clear_preview()
scenes/maps/operators/modal_controls.gd +10 -0
……@@ -73,6 +73,16 @@ func focused() -> String:
7373 return String(_values[_focus]["id"]) if not _values.is_empty() else ""
7474
7575
76## Focus the bound value `id` (a tool's own letter for it, as Cablerator's Z / D / N / S
77## jump to a value: [ ] then step that one). False if no value has that id.
78func focus(id: String) -> bool:
79 for i in _values.size():
80 if String(_values[i]["id"]) == id:
81 _focus = i
82 return true
83 return false
84
85
7686## H (help), Tab (focus) and [ ] (the focused value). True if the key was one of these.
7787func handle_key(event: InputEvent) -> bool:
7888 if not (event is InputEventKey) or not (event as InputEventKey).pressed:
scenes/test/gate_cable_bundle.gd +309 -0
……@@ -0,0 +1,309 @@
1extends Node
2## GATE — A BUNDLE BETWEEN TWO FACE SETS (#120 L3, docs/node-tree-and-stacks.md §14.6):
3## Cablerator's Create a Cable Mass.
4## pure (BundleCables, faces as _selected_face_polys() gives them: {pts, n}):
5## B1 two touching faces + one apart: two islands, [2 faces] and [1 face]
6## B2 one island, or three: refused, saying how many
7## B3 the same seed, the same ends; another seed, other ends
8## B4 random ends lie ON their island's faces (on the plane, inside the polygon)
9## B5 jitter 0: each end's handle runs along its face normal, tension x chord / 3 long
10## B6 random off: ends are the face centres, cycled
11## B7 count 8: 8 cables, each from island A to island B
12## B8 slack: each cable hangs (2 ends + 3 interior points)
13## in the map:
14## M1 two islands selected, the key, Enter: 5 cables + a "Bundle" collection holding
15## them, ONE gesture; one Ctrl+Z takes it all
16## M2 one island: the tool refuses
17## M3 Adjust Last with count 3: 3 cables, still one Bundle collection
18## M4 Z / D / N / S focus count / tension / jitter / radius; R and Q work
19## M5 the Add menu and the face Q menu offer it; the key is bound
20## M6 map.cable_bundle called directly (not through the operator stack): one Ctrl+Z
21##
22## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_bundle.tscn
23
24const CollectionFold := preload("res://scenes/maps/collection_fold.gd")
25
26var fails := 0
27var checks := 0
28var BC: GDScript = null
29
30
31func _ok(what: String, cond: bool, note: Variant = "") -> void:
32 checks += 1
33 if not cond:
34 fails += 1
35 print(" %-66s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
36
37
38func _ready() -> void:
39 if ResourceLoader.exists("res://scenes/maps/curves/bundle_cables.gd"):
40 BC = load("res://scenes/maps/curves/bundle_cables.gd")
41 _ok("setup: BundleCables exists", BC != null)
42 if BC != null:
43 _pure()
44 var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
45 gs.route("world:level:*", 1, "peer")
46 var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
47 if keet != null:
48 keet.current_member_id = "gate-author"
49 get_tree().root.get_node("/root/GameMode").set_mode("build")
50 var map: Node3D = (load("res://scenes/maps/map_test.tscn") as PackedScene).instantiate()
51 add_child(map)
52 for i in 6:
53 await get_tree().process_frame
54 await _run(map)
55 get_tree().root.get_node("/root/GameMode").set_mode("play")
56 if checks < 20:
57 print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
58 fails += 1
59 print("\nGATE cable_bundle: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
60 get_tree().quit(0 if fails == 0 else 1)
61
62
63func _face(pts: Array, n: Vector3) -> Dictionary:
64 return {"pts": PackedVector3Array(pts), "n": n}
65
66
67## Box [0,2]x[0,1]x[0,2]: its top and its +X side (they share an edge); and a face of a
68## box 10 m away.
69func _faces() -> Array:
70 var top := _face([Vector3(0, 1, 0), Vector3(0, 1, 2), Vector3(2, 1, 2), Vector3(2, 1, 0)], Vector3.UP)
71 var side := _face([Vector3(2, 0, 0), Vector3(2, 1, 0), Vector3(2, 1, 2), Vector3(2, 0, 2)], Vector3.RIGHT)
72 var far := _face([Vector3(10, 0, 0), Vector3(10, 0, 2), Vector3(10, 1, 2), Vector3(10, 1, 0)], Vector3.LEFT)
73 return [top, far, side]
74
75
76func _opts(over: Dictionary = {}) -> Dictionary:
77 var o := {"count": 4, "random": true, "seed": 7, "tension": 1.0, "jitter": 0.0, "slack": 0.0}
78 for k: String in over:
79 o[k] = over[k]
80 return o
81
82
83## Is `p` on the plane of face `f` and inside its (convex) polygon?
84func _on_face(p: Vector3, f: Dictionary) -> bool:
85 var pts: PackedVector3Array = f["pts"]
86 var n: Vector3 = f["n"]
87 if absf(n.dot(p - pts[0])) > 1.0e-4:
88 return false
89 var sgn := 0.0
90 for i in pts.size():
91 var s := n.dot((pts[(i + 1) % pts.size()] - pts[i]).cross(p - pts[i]))
92 if absf(s) < 1.0e-9:
93 continue
94 if sgn == 0.0:
95 sgn = signf(s)
96 elif signf(s) != sgn:
97 return false
98 return true
99
100
101func _pure() -> void:
102 var fs := _faces()
103 # ---- B1 ----
104 var isl: Array = BC.islands(fs)
105 _ok("B1 two islands: [top, side] and [far]", isl.size() == 2 and isl[0] == [0, 2] and isl[1] == [1], isl)
106 # ---- B2 ----
107 var one: Dictionary = BC.bundle([fs[0], fs[2]], _opts())
108 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())
109 _ok("B2 one island / three islands: refused, with the count", int(one.get("islands", -1)) == 1
110 and int(three.get("islands", -1)) == 3 and (one.get("cables", []) as Array).is_empty()
111 and (three.get("cables", []) as Array).is_empty(), [one.get("islands"), three.get("islands")])
112 # ---- B3 ----
113 var r1: Array = BC.bundle(fs, _opts())["cables"]
114 var r2: Array = BC.bundle(fs, _opts())["cables"]
115 var r3: Array = BC.bundle(fs, _opts({"seed": 8}))["cables"]
116 var same := r1.size() == 4 and r2.size() == 4
117 for i in mini(r1.size(), r2.size()):
118 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)
119 _ok("B3 the same seed: the same ends", same)
120 _ok("B3 ... another seed: other ends", r3.size() == 4 and (r3[0][0]["p"] as Vector3) != (r1[0][0]["p"] as Vector3))
121 # ---- B4 + B7 ----
122 var r8: Array = BC.bundle(fs, _opts({"count": 8}))["cables"]
123 var on := r8.size() == 8
124 for c: Array in r8:
125 var a: Vector3 = c[0]["p"]
126 var b: Vector3 = c[c.size() - 1]["p"]
127 on = on and (_on_face(a, fs[0]) or _on_face(a, fs[2])) and _on_face(b, fs[1])
128 _ok("B4 + B7 count 8: 8 cables, each from a face of A to the face of B (on it)", on, r8.size())
129 # ---- B5 ----
130 var c0: Array = r1[0] if r1.size() > 0 else []
131 var b5 := false
132 if c0.size() == 2:
133 var pa: Dictionary = c0[0]
134 var pb: Dictionary = c0[1]
135 var chord := (pa["p"] as Vector3).distance_to(pb["p"])
136 var ho: Vector3 = pa["h_out"]
137 var hi: Vector3 = pb["h_in"]
138 b5 = ho.normalized().distance_to(pa["n"]) < 1.0e-4 and absf(ho.length() - chord / 3.0) < 1.0e-4 \
139 and hi.normalized().distance_to(pb["n"]) < 1.0e-4 and absf(hi.length() - chord / 3.0) < 1.0e-4 \
140 and CurveMath.handle_type(pa) == "free" and CurveMath.handle_type(pb) == "free"
141 _ok("B5 jitter 0: each end leaves along its normal, chord / 3 long, free", b5, c0)
142 # ---- B6 ----
143 var rc: Array = BC.bundle(fs, _opts({"random": false, "count": 3}))["cables"]
144 var cen := [Vector3(1, 1, 1), Vector3(2, 0.5, 1)] # top, side: island A in face order
145 var b6 := rc.size() == 3
146 for i in rc.size():
147 b6 = b6 and (rc[i][0]["p"] as Vector3).distance_to(cen[i % 2]) < 1.0e-4 \
148 and (rc[i][rc[i].size() - 1]["p"] as Vector3).distance_to(Vector3(10, 0.5, 1)) < 1.0e-4
149 _ok("B6 random off: A's face centres cycled, B's centre", b6, rc.map(func(c: Array) -> Vector3: return c[0]["p"]))
150 # ---- B8 ----
151 var rs: Array = BC.bundle(fs, _opts({"slack": 0.5}))["cables"]
152 _ok("B8 slack 0.5: each cable 2 ends + 3 interior points", rs.size() == 4
153 and rs.all(func(c: Array) -> bool: return c.size() == 5), rs.map(func(c: Array) -> int: return c.size()))
154
155
156func _box_planes(lo: Vector3, hi: Vector3) -> Array:
157 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]
158
159
160func _key(level: Node, brush: int, n: Vector3) -> String:
161 var d: Dictionary = level.get_brush(brush)
162 var pl: PackedFloat32Array = d["planes"]
163 var keys: PackedStringArray = d["plane_keys"]
164 for i in pl.size() / 4:
165 if Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2]).normalized().distance_to(n) < 1.0e-4:
166 return keys[i]
167 return ""
168
169
170## The map's face set, replaced IN PLACE (an Array[Dictionary]: set() would not convert).
171func _set_faces(map: Node, list: Array) -> void:
172 var a: Array = map.get("_sel_faces")
173 a.clear()
174 for f: Dictionary in list:
175 a.append(f)
176
177
178func _press(map: Node, action: String) -> void:
179 var ev := InputEventAction.new()
180 ev.action = action
181 ev.pressed = true
182 map._unhandled_input(ev)
183
184
185func _key_ev(code: Key) -> InputEventKey:
186 var k := InputEventKey.new()
187 k.keycode = code
188 k.physical_keycode = code
189 k.pressed = true
190 return k
191
192
193func _cables(persist: Node) -> Array:
194 var out: Array = []
195 for d: String in (persist.live_shapes() as Dictionary):
196 if String(persist.shape_state(d)["type"]) == "cable":
197 out.append(d)
198 return out
199
200
201func _bundles(persist: Node) -> Array:
202 var out: Array = []
203 var t: Dictionary = persist.collection_tree()
204 for c: String in (t.get("cols", {}) as Dictionary):
205 if String(t["cols"][c].get("name", "")) == "Bundle":
206 out.append(c)
207 return out
208
209
210func _run(map: Node3D) -> void:
211 map._ensure_persistence()
212 var persist: Node = map.get("_persist")
213 var level: Node = map.get("level")
214 var ops: Node = map.call("_ensure_ops")
215 var has := map.has_method("cable_bundle") and map.get("_bundle_op") != null
216 _ok("setup: map.cable_bundle + the tool", has)
217 if not has:
218 return
219 var a: int = map._create_brush_journalled({"planes": _box_planes(Vector3(200, 60, 0), Vector3(202, 61, 2)),
220 "proj": [0, 0, 0, 0, 0, 0], "material_id": 7, "level_id": 0})
221 var b: int = map._create_brush_journalled({"planes": _box_planes(Vector3(210, 60, 0), Vector3(212, 61, 2)),
222 "proj": [0, 0, 0, 0, 0, 0], "material_id": 7, "level_id": 0})
223 level.rebuild()
224 var ka := String(level.get_brush_key(a))
225 var kb := String(level.get_brush_key(b))
226 var two := [{"b": ka, "p": _key(level, a, Vector3.UP)}, {"b": ka, "p": _key(level, a, Vector3.RIGHT)},
227 {"b": kb, "p": _key(level, b, Vector3.LEFT)}]
228 map.set_select_mode("face")
229 var op: Object = map.get("_bundle_op")
230
231 # ---- M2 ----
232 _set_faces(map, [two[0], two[1]])
233 _press(map, "builder.cable_bundle")
234 _ok("M2 one island: the tool refuses", ops.get("active") == null, ops.get("active"))
235
236 # ---- M1 + M4 ----
237 _set_faces(map, two)
238 var c0 := _cables(persist).size()
239 var b0 := _bundles(persist).size()
240 var n0: int = (persist.records(false) as Array).size()
241 _press(map, "builder.cable_bundle")
242 _ok("M1 two islands: the tool is up", ops.get("active") == op, ops.get("active"))
243 if ops.get("active") != op:
244 return
245 op.set_tool_setting("count", 5)
246 var focus_ok := true
247 for pair: Array in [[KEY_Z, "count"], [KEY_D, "tension"], [KEY_N, "jitter"], [KEY_S, "radius"]]:
248 op.handle_key(_key_ev(pair[0]))
249 focus_ok = focus_ok and op.modal().focused() == String(pair[1])
250 var seed0 := int(op.get("rng_seed"))
251 op.handle_key(_key_ev(KEY_Q))
252 var rnd0 := bool(op.get("random"))
253 op.handle_key(_key_ev(KEY_R))
254 var r_ok := bool(op.get("random")) != rnd0
255 op.handle_key(_key_ev(KEY_R))
256 _ok("M4 Z / D / N / S focus count / tension / jitter / radius; Q reseeds; R toggles",
257 focus_ok and int(op.get("rng_seed")) != seed0 and r_ok, [op.modal().focused(), seed0, op.get("rng_seed")])
258 ops.confirm()
259 var new_c := _cables(persist).size() - c0
260 var bun := _bundles(persist)
261 var members := 0
262 if bun.size() == b0 + 1:
263 var t: Dictionary = persist.collection_tree()
264 for d: String in _cables(persist):
265 if CollectionFold.collections_of(t, "g:" + d).has(bun[-1]):
266 members += 1
267 _ok("M1 5 cables + one Bundle collection holding all 5", new_c == 5 and bun.size() == b0 + 1 and members == 5,
268 [new_c, bun.size() - b0, members])
269 _ok("M1 ... 12 records (5 x curve + shape, col.create, col.link)",
270 (persist.records(false) as Array).size() == n0 + 12, (persist.records(false) as Array).size() - n0)
271
272 # ---- M3 ----
273 var adj: bool = ops.adjust("count", 3)
274 _ok("M3 Adjust Last count 3: 3 cables, one Bundle", adj and _cables(persist).size() - c0 == 3
275 and _bundles(persist).size() == b0 + 1, [adj, _cables(persist).size() - c0, _bundles(persist).size() - b0])
276 persist.undo_last()
277 persist.refold()
278 _ok("M1 one Ctrl+Z takes the whole bundle", _cables(persist).size() == c0 and _bundles(persist).size() == b0,
279 [_cables(persist).size() - c0, _bundles(persist).size() - b0])
280
281 # ---- M6: the API direct (no operator stack wrapping a gesture round it) ----
282 _set_faces(map, two)
283 var c6 := _cables(persist).size()
284 var b6 := _bundles(persist).size()
285 var direct: Array = map.cable_bundle(map._selected_face_polys(), _opts({"count": 3}), {"radius": 0.03})
286 var made := _cables(persist).size() - c6
287 persist.undo_last()
288 persist.refold()
289 _ok("M6 cable_bundle called directly: 3 cables + a Bundle, ONE Ctrl+Z takes it all", direct.size() == 3
290 and made == 3 and _cables(persist).size() == c6 and _bundles(persist).size() == b6,
291 [direct.size(), made, _cables(persist).size() - c6, _bundles(persist).size() - b6])
292
293 # ---- M5 ----
294 var rows: Array = []
295 for sec: Array in map.add_sections():
296 for r: Array in sec[1]:
297 rows.append(r[0])
298 _set_faces(map, two)
299 map.set("_sel_kind", "face")
300 map.set("_sel_brush", a)
301 var qrows: Array = []
302 for sec2: Array in map.q_sections():
303 for r2: Array in sec2[1]:
304 qrows.append(r2[0])
305 var spec: Dictionary = Keymap.DEFAULTS.get("builder", {}).get("builder.cable_bundle", {})
306 _ok("M5 the Add menu and the face Q menu offer Bundle", rows.has("builder.cable_bundle")
307 and qrows.has("builder.cable_bundle"), [rows, qrows])
308 _ok("M5 Shift+Ctrl+C is bound", int(spec.get("key", 0)) == KEY_C and bool(spec.get("ctrl", false))
309 and bool(spec.get("shift", false)) and not bool(spec.get("alt", false)), spec)
scenes/test/gate_cable_bundle.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_bundle.gd" id="1"]
4
5[node name="CableBundleGate" type="Node"]
6script = ExtResource("1")