OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / 8558fea

#120 L3 §14.5: cables from edges (Alt+C)
EdgeCables (pure): the edge set's chains (a 1- or 3+-way vertex ends one, a loop closes), curve points at vertices or edge centres, offset along the outward mean normal (stored as n), corners round = auto / sharp = vector, an open chain's ends always vector. CableEdgesOperator: P / C / offset / radius, Adjust Last. map.cables_from_edges: a curve + cable per chain in one gesture, on the new _record_cable (add_cable now wraps it). gate_cable_edges 21/21 (in ci.yaml), 4 mutants killed; 29 related gates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8558feae75799adebef7d34ca78970eb89f20198
by plankatron <fleeky@gmail.com> · 2026-10-19T15:00:53.000Z

Changed files (9) +642 -13

.pear-git/ci.yaml +4 -0
……@@ -1122,6 +1122,10 @@ steps:
11221122 # The edge set (#120 L3 §14.4): Shift-click edges across brushes, held as brush key +
11231123 # two plane keys, resolved from the planes, live brushes only, highlighted.
11241124 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_edge_set.tscn"
1125 - name: gate-cable-edges
1126 # Cables from edges (#120 L3 §14.5): chains (stop at a 3-way vertex, close a loop),
1127 # vertices / centres, offset along the mean normal, corners; Alt+C, one gesture.
1128 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"
11251129 - name: gate-curve-overlay
11261130 # Curves (wire, points, held handles) are an editor overlay: hidden in play mode, the
11271131 # Curves switch (Shift+Alt+K) and the master switch hide them in build (2026-10-18).
autoloads/keymap.gd +2 -0
……@@ -397,6 +397,8 @@ const DEFAULTS: Dictionary = {
397397 # #120 L3 §14.3 (Cablerator's Drop the Cable). Ctrl+Shift+D: with Shift+D / Alt+D
398398 # beside it, and free in Blender's 3D view.
399399 "builder.drop_cable": {"key": KEY_D, "ctrl": true, "shift": true, "label": "Drop the selected cables to the ground (ends stay)"},
400 # #120 L3 §14.5. Alt+C: Blender 2.79's Convert (to Curve from Mesh), the nearest act.
401 "builder.cable_from_edges": {"key": KEY_C, "alt": true, "label": "Cable from edges (edge mode: a cable per chain of selected edges)"},
400402 "builder.assign_generator": {"label": "Generator… (stairs, tunnel … on this shape; it stays the passage)"},
401403 "builder.add_modifier": {"key": KEY_M, "ctrl": true, "shift": true, "label": "Add modifier… (mirror, array, grid, radial, curve, chamfer, dice on this shape)"},
402404 # Menu-only, as in Blender (the outliner's / X menu's Delete Hierarchy): X keeps children.
docs/node-tree-and-stacks.md +9 -0
……@@ -980,6 +980,14 @@ Cablerator's Drop the Cable, adapted:
980980- Enter = ONE gesture: a curve + cable per chain (n stored = the outward normal). The new
981981 cables are selected. Adjust Last re-runs it.
982982- Refused, with a status line: an empty edge set.
983- **Built 2026-10-19** (`gate_cable_edges` 21/21; 4 mutants killed): `EdgeCables` (pure:
984 chains + curve_points), `CableEdgesOperator`, `map.cables_from_edges` on a new
985 `_record_cable` (records only; `add_cable` now wraps it). Changed from the plan: corners
986 are **C**, not H — `ModalControls` gives H to every tool's help. Centres on an OPEN chain
987 keep its two end vertices (end, midpoints, end), so the cable still spans the chain.
988 `builder.cable_from_edges` = **Alt+C** (Blender 2.79's Convert to Curve from Mesh), in
989 Add ▸ Other and the Edge section of the Q menu. The radius is the tool's; the cable's
990 other params are the Shape panel's afterwards.
983991
984992### 14.6 Bundle between two face sets (`builder.cable_bundle`)
985993
……@@ -1013,6 +1021,7 @@ Every command in a menu and on a key (standing rule):
10131021- Keys picked at build time against `keymap_gate` (no collisions, Blender meaning first)
10141022 and recorded here:
10151023 - Drop to ground: **Ctrl+Shift+D** (beside Shift+D / Alt+D; free in Blender's 3D view).
1024 - Cable from edges: **Alt+C** (Blender 2.79's Convert, to Curve from Mesh).
10161025
10171026### 14.8 Order and gates (each red first)
10181027
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.carve"]],
29 "builder.create_box", "builder.add_curve", "builder.add_cable", "builder.cable_from_edges", "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/edge_cables.gd +140 -0
……@@ -0,0 +1,140 @@
1extends RefCounted
2class_name EdgeCables
3## CABLES FROM EDGES (#120 L3, docs/node-tree-and-stacks.md §14.5): Cablerator's Create a
4## Cable from Edges, on the editor's edge set. Pure and static: the edges come in as
5## map.selected_edges() gives them ({a, e, normals: [n1, n2]}), the cable's curve points
6## go out, and the map writes them.
7##
8## CHAINS: edges whose ends coincide (within ON) join. A vertex where 1 or 3+ set edges
9## meet ENDS a chain (one cable per path, as Cablerator makes one spline per path); a chain
10## that comes back to its start is CLOSED.
11##
12## POINTS: at the chain's vertices, or at its edge CENTRES (which rounds the corners off;
13## an open chain keeps its two end vertices so the cable still spans it). Each point moves
14## `offset` along its OUTWARD normal — at a vertex the mean of every incident set edge's
15## two face normals, at a centre that edge's two — so the cable sits on the edge rather
16## than half inside it; that normal is stored as the point's n. Corners: round = auto
17## handles, sharp = vector. An open chain's two ends are always vector, so the cable runs
18## straight off the end instead of turning out along its normal (auto's rule for a cable
19## placed on a wall).
20
21## Two ends closer than this (metres) are one vertex.
22const ON := 1.0e-3
23
24
25## The chains of `edges`: [{verts: [Vector3], vnormals: [Vector3] (per vertex),
26## enormals: [Vector3] (per edge: edge i joins vertex i and i + 1, the last of a closed
27## chain back to 0), closed: bool}]. Open chains first (from each end vertex in order of
28## first appearance), then loops (from their first edge in input order).
29static func chains(edges: Array) -> Array:
30 var nodes: Array = [] # Vector3 per vertex
31 var inc: Array = [] # per vertex: incident edge ids
32 var en: Array = [] # per edge: [vertex, vertex], or [] (degenerate)
33 var nsum: Array = [] # per vertex: the sum of incident edges' face normals
34 for k in edges.size():
35 var e: Dictionary = edges[k]
36 var ia := _node(nodes, inc, nsum, e["a"])
37 var ie := _node(nodes, inc, nsum, e["e"])
38 if ia == ie:
39 en.append([])
40 continue
41 en.append([ia, ie])
42 var ns: Array = e.get("normals", [])
43 var fsum := Vector3.ZERO
44 for n: Vector3 in ns:
45 fsum += n
46 for v: int in [ia, ie]:
47 (inc[v] as Array).append(k)
48 nsum[v] = (nsum[v] as Vector3) + fsum
49 var used := {}
50 var out: Array = []
51 for s in nodes.size():
52 if (inc[s] as Array).size() == 2:
53 continue
54 for k2: int in (inc[s] as Array):
55 if not used.has(k2):
56 out.append(_walk(s, k2, nodes, inc, en, nsum, edges, used))
57 for k3 in en.size():
58 if not used.has(k3) and not (en[k3] as Array).is_empty():
59 out.append(_walk(int(en[k3][0]), k3, nodes, inc, en, nsum, edges, used))
60 return out
61
62
63static func _node(nodes: Array, inc: Array, nsum: Array, p: Vector3) -> int:
64 for i in nodes.size():
65 if (nodes[i] as Vector3).distance_to(p) < ON:
66 return i
67 nodes.append(p)
68 inc.append([])
69 nsum.append(Vector3.ZERO)
70 return nodes.size() - 1
71
72
73static func _walk(start: int, first: int, nodes: Array, inc: Array, en: Array, nsum: Array,
74 edges: Array, used: Dictionary) -> Dictionary:
75 var vids: Array = [start]
76 var eids: Array = []
77 var cur := start
78 var e := first
79 var closed := false
80 while true:
81 used[e] = true
82 eids.append(e)
83 var nxt: int = en[e][1] if int(en[e][0]) == cur else en[e][0]
84 if nxt == start:
85 closed = true
86 break
87 vids.append(nxt)
88 if (inc[nxt] as Array).size() != 2:
89 break
90 var other: int = inc[nxt][0] if int(inc[nxt][0]) != e else inc[nxt][1]
91 if used.has(other):
92 break
93 cur = nxt
94 e = other
95 var verts: Array = []
96 var vn: Array = []
97 for v: int in vids:
98 verts.append(nodes[v])
99 var s: Vector3 = nsum[v]
100 vn.append(s.normalized() if s.length() > 1.0e-9 else Vector3.ZERO)
101 var enr: Array = []
102 for k: int in eids:
103 var fs := Vector3.ZERO
104 for n: Vector3 in ((edges[k] as Dictionary).get("normals", []) as Array):
105 fs += n
106 enr.append(fs.normalized() if fs.length() > 1.0e-9 else Vector3.ZERO)
107 return {"verts": verts, "vnormals": vn, "enormals": enr, "closed": closed}
108
109
110## A chain's curve points (CurveMath's, handles derived): at its vertices or its edge
111## centres, each `offset` along its outward normal; `sharp` corners are vector handles.
112static func curve_points(chain: Dictionary, centres: bool, offset: float, sharp: bool) -> Array:
113 var verts: Array = chain["verts"]
114 var vn: Array = chain["vnormals"]
115 var enr: Array = chain["enormals"]
116 var closed := bool(chain["closed"])
117 var corner := "vector" if sharp else "auto"
118 var pts: Array = []
119 if not centres:
120 for i in verts.size():
121 pts.append(_pt((verts[i] as Vector3) + (vn[i] as Vector3) * offset, vn[i], corner))
122 else:
123 var n := verts.size()
124 if not closed:
125 pts.append(_pt((verts[0] as Vector3) + (vn[0] as Vector3) * offset, vn[0], corner))
126 for i in enr.size():
127 var mid: Vector3 = ((verts[i] as Vector3) + (verts[(i + 1) % n] as Vector3)) * 0.5
128 pts.append(_pt(mid + (enr[i] as Vector3) * offset, enr[i], corner))
129 if not closed:
130 pts.append(_pt((verts[n - 1] as Vector3) + (vn[n - 1] as Vector3) * offset, vn[n - 1], corner))
131 if not closed and pts.size() >= 2:
132 pts[0]["handle"] = "vector"
133 pts[pts.size() - 1]["handle"] = "vector"
134 return CurveMath.auto_handles(pts, closed)
135
136
137static func _pt(p: Vector3, n: Vector3, handle: String) -> Dictionary:
138 var q := CurveMath.point(p, n)
139 q["handle"] = handle
140 return q
scenes/maps/map_dc_level.gd +59 -12
……@@ -557,6 +557,8 @@ func context_sections() -> Array:
557557 ["build_mode.faces_extrude"]])
558558 if _sel_faces.size() >= 2:
559559 el.append(["build_mode.faces_hull"])
560 if _sel_kind == "edge":
561 el.append(["builder.cable_from_edges"]) # #120 L3 §14.5
560562 el.append(["builder.deselect"])
561563 secs.append([_sel_kind.capitalize(), el])
562564 if not _sel_brushes.is_empty():
……@@ -1312,7 +1314,8 @@ func add_sections() -> Array:
13121314 var rows: Array = []
13131315 for s: String in ADD_SHAPES:
13141316 rows.append(["builder.add_%s" % s])
1315 return [["Add shape", rows], ["Other", [["builder.add_curve"], ["builder.add_cable"], ["builder.carve"]]]]
1317 return [["Add shape", rows], ["Other", [["builder.add_curve"], ["builder.add_cable"], ["builder.cable_from_edges"],
1318 ["builder.carve"]]]]
13161319
13171320
13181321## Start the create tool already set to `shape_id`.
……@@ -3041,6 +3044,7 @@ func _ensure_ops() -> OperatorStack:
30413044 _cable_op = _make_op(_cable_op, "Cable")
30423045 _curve_edit = _make_op(CurveEditOperator.new(), "CurveEdit")
30433046 _drop_op = _make_op(DropCableOperatorScript.new(), "DropCable") # #120 L3 §14.3
3047 _edges_op = _make_op(CableEdgesOperatorScript.new(), "CableEdges") # #120 L3 §14.5
30443048 _grab_op = _make_op(ElementOperator.new(), "Grab")
30453049 _extrude = _make_op(ExtrudeOperator.new(), "Extrude")
30463050 _box = _make_op(CreateBoxOperator.new(), "CreateBox")
……@@ -4367,6 +4371,10 @@ func _builder_key(event: InputEvent) -> void:
43674371 if _ensure_ops().activate(_curve_edit):
43684372 print("[MapDCLevel] curve edit: the point follows your aim, Tab next, X delete, I insert, Enter applies")
43694373 return
4374 if Keymap.tapped(event, "builder.cable_from_edges"):
4375 if _ensure_ops().activate(_edges_op):
4376 print("[MapDCLevel] cable from edges: P vertices / centres, C corners, [ ] offset, Enter adds")
4377 return
43704378 if Keymap.tapped(event, "builder.drop_cable"):
43714379 if _ensure_ops().activate(_drop_op):
43724380 print("[MapDCLevel] drop: the selected cables fall to the ground, [ ] point step, Enter drops")
……@@ -5639,18 +5647,8 @@ func add_cable(points: Array, closed: bool = false, params: Dictionary = {}, aut
56395647 _ensure_persistence()
56405648 if points.size() < 2:
56415649 return ""
5642 var pts: Array = CurveMath.auto_handles(points, closed) if auto else points
56435650 _persist.begin_gesture("Add cable")
5644 var cdot: String = _persist.record("curve.create", {"points": LevelPersistence.CurveFold.to_wire(pts),
5645 "closed": closed, "name": "Cable"})
5646 var p := params.duplicate(true)
5647 p["curve"] = cdot
5648 var t: Object = ParamShapes.get_type("cable")
5649 var dot := ""
5650 if cdot != "":
5651 dot = _persist.record("shape.create", {"type": "cable", "gv": t.GV, "params": p,
5652 "frame": SceneObjects.xform_to(Transform3D.IDENTITY), "size": [1.0, 1.0, 1.0],
5653 "material_id": 0, "level_id": 0, "tex": current_face_tex()})
5651 var dot := _record_cable(points, closed, params, auto)
56545652 _persist.end_gesture()
56555653 _persist.refold()
56565654 if dot != "":
……@@ -5772,6 +5770,55 @@ func _drop_ray(from: Vector3, own: Array) -> Dictionary:
57725770 return {}
57735771
57745772
5773## CABLES FROM EDGES (#120 L3 §14.5). Preloaded for the same class-cache reason as Drop.
5774const CableEdgesOperatorScript := preload("res://scenes/maps/operators/cable_edges_operator.gd")
5775const EdgeCablesScript := preload("res://scenes/maps/curves/edge_cables.gd")
5776var _edges_op: LevelOperator = null
5777
5778
5779## A curve through `points` and a cable shape on it — RECORDS ONLY, inside the caller's
5780## gesture (add_cable and cables_from_edges write many in one). The shape's dot, or "".
5781func _record_cable(points: Array, closed: bool, params: Dictionary, auto: bool = true) -> String:
5782 var pts: Array = CurveMath.auto_handles(points, closed) if auto else points
5783 var cdot: String = _persist.record("curve.create", {"points": LevelPersistence.CurveFold.to_wire(pts),
5784 "closed": closed, "name": "Cable"})
5785 if cdot == "":
5786 return ""
5787 var p := params.duplicate(true)
5788 p["curve"] = cdot
5789 var t: Object = ParamShapes.get_type("cable")
5790 return _persist.record("shape.create", {"type": "cable", "gv": t.GV, "params": p,
5791 "frame": SceneObjects.xform_to(Transform3D.IDENTITY), "size": [1.0, 1.0, 1.0],
5792 "material_id": 0, "level_id": 0, "tex": current_face_tex()})
5793
5794
5795## A cable per chain of `edges` (selected_edges()' form), ONE gesture; the new cables become
5796## the selection. Their dots ([] when there was nothing to make).
5797func cables_from_edges(edges: Array, centres: bool, offset: float, sharp: bool, params: Dictionary = {}) -> Array:
5798 _ensure_persistence()
5799 var out: Array = []
5800 var chains: Array = EdgeCablesScript.chains(edges)
5801 if chains.is_empty():
5802 return out
5803 _persist.begin_gesture("Cable from edges")
5804 for ch: Dictionary in chains:
5805 var pts: Array = EdgeCablesScript.curve_points(ch, centres, offset, sharp)
5806 if pts.size() < 2:
5807 continue
5808 var d := _record_cable(pts, bool(ch["closed"]), params, false)
5809 if d != "":
5810 out.append(d)
5811 _persist.end_gesture()
5812 if out.is_empty():
5813 return out
5814 _persist.refold()
5815 var ids := PackedInt32Array()
5816 for d2: String in out:
5817 ids.append_array(level.get_group_brush_ids(d2) as PackedInt32Array)
5818 select_brushes(ids)
5819 return out
5820
5821
57755822## Is `p` inside (or on, within FaceKit.ON_PLANE) any of the convex plane sets?
57765823static func _inside_any(p: Vector3, sets: Array) -> bool:
57775824 for pl: PackedFloat32Array in sets:
scenes/maps/operators/cable_edges_operator.gd +159 -0
……@@ -0,0 +1,159 @@
1extends LevelOperator
2## CABLES FROM EDGES (#120 L3, docs/node-tree-and-stacks.md §14.5): the edge set (§14.4) ->
3## one cable per chain (EdgeCables). The preview draws the cables' curves; Enter writes ONE
4## gesture (a curve + a cable per chain, map.cables_from_edges). Adjust Last re-runs it.
5## Keys (Cablerator's from-edges where the convention allows): P points at vertices /
6## centres, C corners round / sharp (Cablerator's H is every tool's help here), [ ] / Tab
7## the offset and the radius.
8
9const EdgeCablesScript := preload("res://scenes/maps/curves/edge_cables.gd")
10const POINTS_AT := ["Vertices", "Centres"]
11const CORNERS := ["Round", "Sharp"]
12
13var centres := false
14var sharp := false
15var offset := 0.05 # metres off the edge, along its outward normal
16var radius := 0.05
17var _edges: Array = [] # selected_edges() at activation (Adjust Last: stored)
18var _frozen := false
19
20
21func op_name() -> String:
22 return "cable from edges"
23
24
25func gesture_label() -> String:
26 return "Cable from edges"
27
28
29func preview_color() -> Color:
30 return Color(1.0, 0.85, 0.3, 0.9) # the curve view's amber
31
32
33func setup_modal(m: ModalControls) -> void:
34 m.confirm_label = "add the cables"
35 m.table([["P", "points at", func() -> String: return POINTS_AT[1 if centres else 0]],
36 ["C", "corners", func() -> String: return CORNERS[1 if sharp else 0]],
37 ["", "chains", func() -> String: return "%d" % EdgeCablesScript.chains(_edges).size()]])
38 m.bind("offset", "off the edge", func() -> float: return offset,
39 func(v: float) -> void:
40 offset = maxf(0.0, v)
41 update_preview(),
42 0.01, " m", 0.002)
43 m.bind("radius", "radius", func() -> float: return radius,
44 func(v: float) -> void:
45 radius = clampf(v, 0.005, 2.0)
46 update_preview(),
47 0.01, " m", 0.002)
48
49
50func status() -> String:
51 return modal().status_text()
52
53
54func on_activate() -> bool:
55 if not _frozen:
56 _edges = map.selected_edges() if map != null and map.has_method("selected_edges") else []
57 if _edges.is_empty():
58 print("[cable from edges] select edges first (edge mode, Shift-click to add)")
59 return false
60 update_preview()
61 return true
62
63
64func on_deactivate() -> void:
65 clear_preview()
66
67
68func handle_key(event: InputEvent) -> bool:
69 if not (event is InputEventKey) or not (event as InputEventKey).pressed:
70 return false
71 match KeyRules.key_of(event as InputEventKey):
72 KEY_P:
73 centres = not centres
74 update_preview()
75 return true
76 KEY_C:
77 sharp = not sharp
78 update_preview()
79 return true
80 return modal().handle_key(event)
81
82
83func update_preview() -> void:
84 clear_preview()
85 var lines := PackedVector3Array()
86 for ch: Dictionary in EdgeCablesScript.chains(_edges):
87 var pts: Array = EdgeCablesScript.curve_points(ch, centres, offset, sharp)
88 if pts.size() < 2:
89 continue
90 var closed := bool(ch["closed"])
91 var fs := CurveMath.sample(pts, closed, 64)
92 for i in range(1, fs.size()):
93 lines.append(fs[i - 1]["p"])
94 lines.append(fs[i]["p"])
95 if closed and fs.size() > 2:
96 lines.append(fs[fs.size() - 1]["p"])
97 lines.append(fs[0]["p"])
98 draw_lines(lines)
99
100
101func confirm() -> bool:
102 var dots: Array = map.cables_from_edges(_edges, centres, offset, sharp, {"radius": radius})
103 if dots.is_empty():
104 return false
105 ModalControls.summary("Added %d cable%s from %d edge%s" % [dots.size(), "" if dots.size() == 1 else "s",
106 _edges.size(), "" if _edges.size() == 1 else "s"])
107 return true
108
109
110func tool_settings() -> Array:
111 return [
112 {"id": "points_at", "label": "Points at", "type": "enum", "value": 1 if centres else 0,
113 "choices": POINTS_AT, "tip": "P: the chain's vertices, or its edge centres (rounds corners off)"},
114 {"id": "corners", "label": "Corners", "type": "enum", "value": 1 if sharp else 0,
115 "choices": CORNERS, "tip": "C: round (auto handles) or sharp (vector)"},
116 {"id": "offset", "label": "Offset", "type": "float", "value": offset, "min": 0.0, "max": 2.0,
117 "step": 0.01, "tip": "Off the edge along its outward normal (the radius sits the cable on the edge)"},
118 {"id": "radius", "label": "Radius", "type": "float", "value": radius, "min": 0.005, "max": 2.0,
119 "step": 0.01},
120 ]
121
122
123func set_tool_setting(sid: String, value: Variant) -> bool:
124 match sid:
125 "points_at":
126 centres = int(value) == 1
127 "corners":
128 sharp = int(value) == 1
129 "offset":
130 offset = maxf(0.0, float(value))
131 "radius":
132 radius = clampf(float(value), 0.005, 2.0)
133 _:
134 return false
135 if not _frozen:
136 update_preview()
137 return true
138
139
140func adjust_ids() -> Array:
141 return ["points_at", "corners", "offset", "radius"]
142
143
144func adjust_snapshot() -> Dictionary:
145 return {"edges": _edges.duplicate(true), "centres": centres, "sharp": sharp, "offset": offset, "radius": radius}
146
147
148func adjust_restore(snap: Dictionary) -> void:
149 _edges = (snap.get("edges", []) as Array).duplicate(true)
150 centres = bool(snap.get("centres", centres))
151 sharp = bool(snap.get("sharp", sharp))
152 offset = float(snap.get("offset", offset))
153 radius = float(snap.get("radius", radius))
154 _frozen = true
155
156
157func adjust_end() -> void:
158 _frozen = false
159 clear_preview()
scenes/test/gate_cable_edges.gd +262 -0
……@@ -0,0 +1,262 @@
1extends Node
2## GATE — CABLES FROM EDGES (#120 L3, docs/node-tree-and-stacks.md §14.5).
3## pure (EdgeCables, edges as selected_edges() gives them: {a, e, normals}):
4## C1 a box top's 4 edges, in any order and direction: ONE closed chain of 4
5## C2 two disjoint edges: two open chains
6## C3 three edges meeting at one corner: the chain stops there (three chains)
7## C4 an L of two edges, listed backwards: one open chain of 3, ends at the free ends
8## C5 vertices, offset 0.1: a top corner moves 0.1 along its mean normal, n = that normal
9## C6 centres: a closed chain -> the 4 edge midpoints; an open one -> end, mids, end
10## C7 corners round = auto, sharp = vector; an open chain's ends are always vector
11## in the map:
12## M1 4 top edges selected, Alt+C, Enter: one closed cable of 4 points, ONE gesture
13## M2 two disjoint edges: two cables in one gesture; one Ctrl+Z takes both (M2b: the
14## map API called directly, not through the operator stack)
15## M3 Adjust Last with another offset: the curve moves, still one live gesture
16## M4 no edges selected: the tool refuses
17## M5 the Add menu and the edge Q menu offer it; Alt+C is bound
18##
19## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_edges.tscn
20
21var fails := 0
22var checks := 0
23var EC: GDScript = null
24
25
26func _ok(what: String, cond: bool, note: Variant = "") -> void:
27 checks += 1
28 if not cond:
29 fails += 1
30 print(" %-66s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
31
32
33func _ready() -> void:
34 if ResourceLoader.exists("res://scenes/maps/curves/edge_cables.gd"):
35 EC = load("res://scenes/maps/curves/edge_cables.gd")
36 _ok("setup: EdgeCables exists", EC != null)
37 if EC != null:
38 _pure()
39 var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
40 gs.route("world:level:*", 1, "peer")
41 var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
42 if keet != null:
43 keet.current_member_id = "gate-author"
44 get_tree().root.get_node("/root/GameMode").set_mode("build")
45 var map: Node3D = (load("res://scenes/maps/map_test.tscn") as PackedScene).instantiate()
46 add_child(map)
47 for i in 6:
48 await get_tree().process_frame
49 await _run(map)
50 get_tree().root.get_node("/root/GameMode").set_mode("play")
51 if checks < 18:
52 print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
53 fails += 1
54 print("\nGATE cable_edges: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
55 get_tree().quit(0 if fails == 0 else 1)
56
57
58## An edge as selected_edges() gives it.
59func _e(a: Vector3, e: Vector3, n1: Vector3, n2: Vector3) -> Dictionary:
60 return {"a": a, "e": e, "normals": [n1, n2]}
61
62
63## The top face of the unit box [0,1]^3: four edges, mixed order and direction.
64func _top() -> Array:
65 var U := Vector3.UP
66 return [_e(Vector3(0, 1, 0), Vector3(1, 1, 0), U, Vector3.FORWARD),
67 _e(Vector3(1, 1, 1), Vector3(0, 1, 1), U, Vector3.BACK),
68 _e(Vector3(1, 1, 0), Vector3(1, 1, 1), U, Vector3.RIGHT),
69 _e(Vector3(0, 1, 0), Vector3(0, 1, 1), U, Vector3.LEFT)]
70
71
72func _pure() -> void:
73 # ---- C1 ----
74 var c1: Array = EC.chains(_top())
75 _ok("C1 a box top: one closed chain of 4", c1.size() == 1 and bool(c1[0]["closed"])
76 and (c1[0]["verts"] as Array).size() == 4, c1)
77 # ---- C2 ----
78 var c2: Array = EC.chains([_e(Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3.UP, Vector3.FORWARD),
79 _e(Vector3(0, 0, 5), Vector3(1, 0, 5), Vector3.UP, Vector3.BACK)])
80 _ok("C2 two disjoint edges: two open chains", c2.size() == 2 and not bool(c2[0]["closed"])
81 and not bool(c2[1]["closed"]), c2.size())
82 # ---- C3 ----
83 # A FREE end first, so the walk starts there and must STOP when it reaches the corner
84 # (listed from the corner, every walk would start at it and never test the stop).
85 var c3: Array = EC.chains([_e(Vector3(0, 1, 0), Vector3(1, 1, 0), Vector3.UP, Vector3.FORWARD),
86 _e(Vector3(1, 1, 0), Vector3(1, 1, 1), Vector3.UP, Vector3.RIGHT),
87 _e(Vector3(1, 0, 0), Vector3(1, 1, 0), Vector3.RIGHT, Vector3.FORWARD)])
88 var ones := c3.size() == 3
89 for ch: Dictionary in c3:
90 ones = ones and (ch["verts"] as Array).size() == 2
91 _ok("C3 three edges at one corner: three chains of one edge", ones, c3.size())
92 # ---- C4 ----
93 var c4: Array = EC.chains([_e(Vector3(2, 0, 0), Vector3(2, 0, 2), Vector3.UP, Vector3.RIGHT),
94 _e(Vector3(0, 0, 0), Vector3(2, 0, 0), Vector3.UP, Vector3.FORWARD)])
95 var v4: Array = c4[0]["verts"] if c4.size() == 1 else []
96 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))
97 or (v4[2] as Vector3).is_equal_approx(Vector3(0, 0, 0)) and (v4[0] as Vector3).is_equal_approx(Vector3(2, 0, 2)))
98 _ok("C4 an L listed backwards: one open chain, ends at the free ends", ends_ok, v4)
99 # ---- C5 ----
100 var p5: Array = EC.curve_points(c1[0], false, 0.1, false) if c1.size() == 1 else []
101 var corner_ok := false
102 var want_n := (Vector3.UP * 2.0 + Vector3.RIGHT + Vector3.FORWARD).normalized()
103 for q: Dictionary in p5:
104 if (q["p"] as Vector3).distance_to(Vector3(1, 1, 0) + want_n * 0.1) < 1.0e-4 \
105 and (q["n"] as Vector3).distance_to(want_n) < 1.0e-4:
106 corner_ok = true
107 _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())
108 # ---- C6 ----
109 var p6: Array = EC.curve_points(c1[0], true, 0.0, false) if c1.size() == 1 else []
110 var mids := 0
111 for q2: Dictionary in p6:
112 for m: Vector3 in [Vector3(0.5, 1, 0), Vector3(1, 1, 0.5), Vector3(0.5, 1, 1), Vector3(0, 1, 0.5)]:
113 if (q2["p"] as Vector3).distance_to(m) < 1.0e-4:
114 mids += 1
115 _ok("C6 centres, closed: the 4 edge midpoints", p6.size() == 4 and mids == 4, p6.size())
116 var p6o: Array = EC.curve_points(c4[0], true, 0.0, false) if c4.size() == 1 else []
117 _ok("C6 centres, open: end, 2 midpoints, end", p6o.size() == 4, p6o.size())
118 # ---- C7 ----
119 var round_pts: Array = EC.curve_points(c4[0], false, 0.0, false) if c4.size() == 1 else []
120 var sharp_pts: Array = EC.curve_points(c4[0], false, 0.0, true) if c4.size() == 1 else []
121 var types_r: Array = round_pts.map(func(q: Dictionary) -> String: return CurveMath.handle_type(q))
122 var types_s: Array = sharp_pts.map(func(q: Dictionary) -> String: return CurveMath.handle_type(q))
123 _ok("C7 round: ends vector, the corner auto", types_r == ["vector", "auto", "vector"], types_r)
124 _ok("C7 sharp: all vector", types_s == ["vector", "vector", "vector"], types_s)
125
126
127func _box_planes(lo: Vector3, hi: Vector3) -> Array:
128 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]
129
130
131func _cables(persist: Node) -> Array:
132 var out: Array = []
133 for d: String in (persist.live_shapes() as Dictionary):
134 if String(persist.shape_state(d)["type"]) == "cable":
135 out.append(d)
136 return out
137
138
139func _curve_of(persist: Node, dot: String) -> Dictionary:
140 var cd := String((persist.shape_state(dot)["params"] as Dictionary).get("curve", ""))
141 return (persist.curve_states() as Dictionary).get(cd, {})
142
143
144func _live_n(persist: Node) -> int:
145 var n := 0
146 for r: Dictionary in (persist.records(false) as Array):
147 if not bool(r.get("undone", false)):
148 n += 1
149 return n
150
151
152## The map's edge set, replaced IN PLACE: it is an Array[Dictionary], and set() with an
153## untyped Array would not convert.
154func _set_edges(map: Node, list: Array) -> void:
155 var a: Array = map.get("_sel_edges")
156 a.clear()
157 for e: Dictionary in list:
158 a.append(e)
159
160
161## Edge keys resolved as selected_edges() would.
162func _resolved(map: Node, keys: Array) -> Array:
163 _set_edges(map, keys)
164 return map.selected_edges()
165
166
167func _key(map: Node, ev_action: String) -> void:
168 var ev := InputEventAction.new()
169 ev.action = ev_action
170 ev.pressed = true
171 map._unhandled_input(ev)
172
173
174func _run(map: Node3D) -> void:
175 map._ensure_persistence()
176 var persist: Node = map.get("_persist")
177 var level: Node = map.get("level")
178 var ops: Node = map.call("_ensure_ops")
179 var has := map.has_method("cables_from_edges") and map.get("_edges_op") != null
180 _ok("setup: map.cables_from_edges + the tool", has)
181 if not has:
182 return
183 var y0 := 60.0
184 var w: int = map._create_brush_journalled({"planes": _box_planes(Vector3(200, y0, 0), Vector3(202, y0 + 1, 2)),
185 "proj": [0, 0, 0, 0, 0, 0], "material_id": 7, "level_id": 0})
186 level.rebuild()
187 var T := y0 + 1
188 map.set_select_mode("edge")
189
190 # ---- M4 (empty first) ----
191 _set_edges(map, [])
192 _key(map, "builder.cable_from_edges")
193 _ok("M4 no edges: the tool refuses", ops.get("active") == null, ops.get("active"))
194
195 # ---- M1 ----
196 var top: Array = []
197 for pr: Array in [[Vector3(200, T, 0), Vector3(202, T, 0)], [Vector3(202, T, 0), Vector3(202, T, 2)],
198 [Vector3(202, T, 2), Vector3(200, T, 2)], [Vector3(200, T, 2), Vector3(200, T, 0)]]:
199 top.append(map._edge_key(w, pr[0], pr[1]))
200 _set_edges(map, top)
201 var c0 := _cables(persist).size()
202 var g0: int = _live_n(persist)
203 _key(map, "builder.cable_from_edges")
204 var op: Object = map.get("_edges_op")
205 _ok("M1 Alt+C with 4 edges: the tool is up", ops.get("active") == op, ops.get("active"))
206 if ops.get("active") == op:
207 op.set_tool_setting("offset", 0.0)
208 ops.confirm()
209 var new1: Array = _cables(persist).slice(c0)
210 var cv1: Dictionary = _curve_of(persist, new1[0]) if new1.size() == 1 else {}
211 _ok("M1 one closed cable of 4 points", new1.size() == 1 and bool(cv1.get("closed", false))
212 and (cv1.get("points", []) as Array).size() == 4, [new1.size(), cv1.get("closed"), (cv1.get("points", []) as Array).size()])
213 _ok("M1 ... two records (curve + shape)", _live_n(persist) == g0 + 2, _live_n(persist) - g0)
214
215 # ---- M3 ----
216 var adj: bool = ops.adjust("offset", 0.2)
217 var cv3: Dictionary = _curve_of(persist, _cables(persist)[-1]) if _cables(persist).size() > c0 else {}
218 var lifted := false
219 for q: Dictionary in (cv3.get("points", []) as Array):
220 lifted = lifted or (q["p"] as Vector3).y > T + 0.1
221 _ok("M3 Adjust Last offset 0.2: the curve lifts, still one cable, one gesture", adj and lifted
222 and _cables(persist).size() == c0 + 1 and _live_n(persist) == g0 + 2, [adj, lifted, _cables(persist).size() - c0])
223
224 # ---- M2 ----
225 _set_edges(map, [top[0], top[2]])
226 var c2n := _cables(persist).size()
227 var g2: int = _live_n(persist)
228 _key(map, "builder.cable_from_edges")
229 if ops.get("active") == op:
230 ops.confirm()
231 _ok("M2 two disjoint edges: two cables, four records", _cables(persist).size() == c2n + 2
232 and _live_n(persist) == g2 + 4, [_cables(persist).size() - c2n, _live_n(persist) - g2])
233 persist.undo_last()
234 persist.refold()
235 _ok("M2 ... one Ctrl+Z takes both", _cables(persist).size() == c2n, _cables(persist).size() - c2n)
236
237 # ---- M2b: the API direct (no operator stack wrapping a gesture round it) ----
238 var c2b := _cables(persist).size()
239 var direct: Array = map.cables_from_edges(_resolved(map, [top[0], top[2]]),
240 false, 0.0, false, {"radius": 0.05})
241 persist.undo_last()
242 persist.refold()
243 _ok("M2b cables_from_edges called directly: two cables, ONE Ctrl+Z takes both", direct.size() == 2
244 and _cables(persist).size() == c2b, [direct.size(), _cables(persist).size() - c2b])
245
246 # ---- M5 ----
247 var rows: Array = []
248 for sec: Array in map.add_sections():
249 for r: Array in sec[1]:
250 rows.append(r[0])
251 _set_edges(map, [top[0]])
252 map.set("_sel_kind", "edge")
253 map.set("_sel_brush", w)
254 var qrows: Array = []
255 for sec2: Array in map.q_sections():
256 for r2: Array in sec2[1]:
257 qrows.append(r2[0])
258 var spec: Dictionary = Keymap.DEFAULTS.get("builder", {}).get("builder.cable_from_edges", {})
259 _ok("M5 the Add menu and the edge Q menu offer Cable from edges",
260 rows.has("builder.cable_from_edges") and qrows.has("builder.cable_from_edges"), [rows, qrows])
261 _ok("M5 Alt+C is bound", int(spec.get("key", 0)) == KEY_C and bool(spec.get("alt", false))
262 and not bool(spec.get("shift", false)) and not bool(spec.get("ctrl", false)), spec)
scenes/test/gate_cable_edges.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_edges.gd" id="1"]
4
5[node name="CableEdgesGate" type="Node"]
6script = ExtResource("1")