OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / ea6d609

#120 L3 §14.4: the edge set — Shift-click edges across brushes
_sel_edges holds {b: brush key, p: [two plane keys]}, the planes through both ends of the picked edge; toggled like the face set, cleared where it is, carried over Make Real. selected_edges() resolves each from its planes (ends + normals), live brushes only (a removed brush is a tombstone that brush_by_key still returns). The highlight draws every edge in the set. gate_edge_set 13/13 (in ci.yaml); 63 selection/menu/cable gates green. outliner_panel_gate (not in CI) already failed at 9889de1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ea6d6092b89d80cdd6cd46b6dc7503883ddd065b
by plankatron <fleeky@gmail.com> · 2026-10-19T14:47:18.000Z

Changed files (5) +317 -4

.pear-git/ci.yaml +4 -0
……@@ -1118,6 +1118,10 @@ steps:
11181118 # Drop to the ground (#120 L3 §14.3): CurveDrop on a fake floor; real physics with the
11191119 # cable's own pieces skipped; Ctrl+Shift+D tool, one gesture, Adjust Last.
11201120 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_drop.tscn"
1121 - name: gate-edge-set
1122 # The edge set (#120 L3 §14.4): Shift-click edges across brushes, held as brush key +
1123 # two plane keys, resolved from the planes, live brushes only, highlighted.
1124 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"
11211125 - name: gate-curve-overlay
11221126 # Curves (wire, points, held handles) are an editor overlay: hidden in play mode, the
11231127 # Curves switch (Shift+Alt+K) and the master switch hide them in build (2026-10-18).
docs/node-tree-and-stacks.md +7 -0
……@@ -955,6 +955,13 @@ Cablerator's Drop the Cable, adapted:
955955 on BOTH planes (FaceKit.corners, ON_PLANE). An edge whose planes no longer meet in a
956956 segment drops out of the set.
957957- The highlight draws every edge in the set.
958- **Built 2026-10-19** (`gate_edge_set` 13/13): `_sel_edges`, `_edge_key` (the two planes
959 through both ends of the picked edge), `selected_edges()` → `{b, p, a, e, normals}`,
960 `edge_set_lines()`; Shift on a second edge of a selected brush keeps the brush, as for
961 faces; Make Real carries the set over like the face set. A removed brush is a TOMBSTONE
962 that `brush_by_key` / `get_brush` still return, so resolving checks `get_live_brush_ids`
963 (E8 caught it). `_selected_face_polys` (the face set, Alt+J / Alt+E) has the same
964 unchecked lookup — not changed here.
958965
959966### 14.5 Cables from edges (`builder.cable_from_edges`)
960967
scenes/maps/map_dc_level.gd +130 -4
……@@ -1636,6 +1636,12 @@ func _q_make_real_then() -> void:
16361636 for fk: Dictionary in _sel_faces:
16371637 var ob: int = level.brush_by_key(String(fk["b"]))
16381638 faces.append([String(fk["b"]), ob, level.face_by_key(ob, String(fk["p"])) if ob >= 0 else -1])
1639 var edges: Array = [] # the edge set (§14.4): [key, old id, plane index, plane index]
1640 for ek: Dictionary in _sel_edges:
1641 var oe: int = level.brush_by_key(String(ek["b"]))
1642 var pk: Array = ek["p"]
1643 edges.append([String(ek["b"]), oe, level.face_by_key(oe, String(pk[0])) if oe >= 0 else -1,
1644 level.face_by_key(oe, String(pk[1])) if oe >= 0 else -1])
16391645 if make_real(dot) <= 0:
16401646 return
16411647 var nb: int = level.brush_by_key(String(last_real.get(old, ""))) if last_real.has(old) else -1
……@@ -1661,6 +1667,15 @@ func _q_make_real_then() -> void:
16611667 var k2: PackedStringArray = level.get_brush(b2).get("plane_keys", PackedStringArray()) if b2 >= 0 else PackedStringArray()
16621668 if b2 >= 0 and int(f[2]) >= 0 and int(f[2]) < k2.size():
16631669 _sel_faces.append({"b": key, "p": k2[int(f[2])]})
1670 _sel_edges.clear()
1671 for e: Array in edges:
1672 var ekey := String(last_real[int(e[1])]) if last_real.has(int(e[1])) else String(e[0])
1673 var b3: int = level.brush_by_key(ekey)
1674 var k3: PackedStringArray = level.get_brush(b3).get("plane_keys", PackedStringArray()) if b3 >= 0 else PackedStringArray()
1675 if b3 >= 0 and int(e[2]) >= 0 and int(e[3]) >= 0 and int(e[2]) < k3.size() and int(e[3]) < k3.size():
1676 var pr: Array = [String(k3[int(e[2])]), String(k3[int(e[3])])]
1677 pr.sort()
1678 _sel_edges.append({"b": ekey, "p": pr})
16641679 _update_face_highlight()
16651680 _update_element_highlight()
16661681 if resume.is_valid():
……@@ -5049,6 +5064,96 @@ var _sel_keys: PackedStringArray = PackedStringArray()
50495064## _sel_brush/_sel_keys (nudge, extrude act on it); this set is what Alt+J / Alt+E
50505065## build from. Empty outside face mode.
50515066var _sel_faces: Array[Dictionary] = []
5067## THE EDGE SET (#120 L3 §14.4): edge mode, Shift-click adds an edge of ANY brush, as
5068## _sel_faces does for faces. {b: brush key, p: [plane key, plane key], sorted} — the edge
5069## IS where those two planes meet on that brush, so a fold between clicks cannot leave it
5070## pointing elsewhere, and no picked point is kept. The ACTIVE edge is still
5071## _sel_edge_a/_b. Cables from edges (§14.5) read it. Empty outside edge mode.
5072var _sel_edges: Array[Dictionary] = []
5073
5074
5075## The edge a..b of `brush` as a durable key: the two planes through both ends. {} unless
5076## exactly two.
5077func _edge_key(brush: int, a: Vector3, b: Vector3) -> Dictionary:
5078 var d: Dictionary = level.get_brush(brush)
5079 var pl: PackedFloat32Array = d.get("planes", PackedFloat32Array())
5080 var keys: PackedStringArray = d.get("plane_keys", PackedStringArray())
5081 var found: Array = []
5082 for i in mini(pl.size() / 4, keys.size()):
5083 var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
5084 var l := n.length()
5085 if l < 1.0e-9:
5086 continue
5087 var dd := pl[i * 4 + 3] / l
5088 n /= l
5089 if absf(n.dot(a) - dd) < FaceKit.ON_PLANE and absf(n.dot(b) - dd) < FaceKit.ON_PLANE:
5090 found.append(String(keys[i]))
5091 if found.size() != 2:
5092 return {}
5093 found.sort()
5094 return {"b": String(level.get_brush_key(brush)), "p": found}
5095
5096
5097## One edge key resolved on the level now: {b, p, a, e (the two ends, in (x, y, z) order),
5098## normals: [n1, n2] (the planes', in p's order)}, or {} when the brush is gone or its two
5099## planes no longer meet in a segment of it. `live` = the live brush ids as keys: a removed
5100## brush is a TOMBSTONE, which brush_by_key and get_brush still return.
5101func _resolve_edge(ek: Dictionary, live: Dictionary) -> Dictionary:
5102 var b: int = level.brush_by_key(String(ek.get("b", "")))
5103 if b <= 0 or not live.has(b):
5104 return {}
5105 var d: Dictionary = level.get_brush(b)
5106 var pl: PackedFloat32Array = d.get("planes", PackedFloat32Array())
5107 var keys: PackedStringArray = d.get("plane_keys", PackedStringArray())
5108 var p: Array = ek.get("p", [])
5109 if p.size() != 2:
5110 return {}
5111 var ns: Array = []
5112 var ds: Array = []
5113 for k: String in p:
5114 var i := keys.find(k)
5115 if i < 0 or i * 4 + 3 >= pl.size():
5116 return {}
5117 var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
5118 var l := n.length()
5119 if l < 1.0e-9:
5120 return {}
5121 ns.append(n / l)
5122 ds.append(pl[i * 4 + 3] / l)
5123 var on: Array = []
5124 for v: Vector3 in FaceKit.corners(pl):
5125 if absf((ns[0] as Vector3).dot(v) - float(ds[0])) < FaceKit.ON_PLANE \
5126 and absf((ns[1] as Vector3).dot(v) - float(ds[1])) < FaceKit.ON_PLANE:
5127 on.append(v)
5128 if on.size() != 2:
5129 return {}
5130 if FaceKit._before(on[1], on[0]):
5131 on.reverse()
5132 return {"b": ek["b"], "p": p, "a": on[0], "e": on[1], "normals": ns}
5133
5134
5135## The edge set, resolved (see _resolve_edge); edges that no longer resolve are left out.
5136func selected_edges() -> Array:
5137 var out: Array = []
5138 if _sel_edges.is_empty():
5139 return out
5140 var live := {}
5141 for id: int in (level.get_live_brush_ids() as PackedInt32Array):
5142 live[id] = true
5143 for ek: Dictionary in _sel_edges:
5144 var r := _resolve_edge(ek, live)
5145 if not r.is_empty():
5146 out.append(r)
5147 return out
5148
5149
5150## The edge set as line pairs, for the highlight.
5151func edge_set_lines() -> PackedVector3Array:
5152 var out := PackedVector3Array()
5153 for r: Dictionary in selected_edges():
5154 out.append(r["a"])
5155 out.append(r["e"])
5156 return out
50525157## The `face_sel` keymap context follows "is anything selected" (_process).
50535158var _face_sel_on := false
50545159## Home / End pressed again on the SAME selection step to the next fit / edge.
……@@ -5909,6 +6014,7 @@ func select_brushes(ids: PackedInt32Array) -> void:
59096014 # highlight is cleared rather than left pointing at whatever was picked last.
59106015 _sel_keys = PackedStringArray()
59116016 _sel_faces.clear()
6017 _sel_edges.clear()
59126018 # "brush", NOT "". MoveOperator gates its selection branch on
59136019 # `_sel_kind == "brush"` (move_operator.gd on_activate), so an empty kind made
59146020 # G ignore the outliner entirely and fall through to the crosshair — which
……@@ -6214,6 +6320,7 @@ func _clear_face_selection() -> void:
62146320 _sel_brush = -1
62156321 _sel_keys = PackedStringArray()
62166322 _sel_faces.clear()
6323 _sel_edges.clear()
62176324 _sel_kind = ""
62186325 _update_face_highlight()
62196326 _update_element_highlight()
……@@ -6332,8 +6439,11 @@ func _update_element_highlight() -> void:
63326439 im.surface_add_vertex(_sel_point)
63336440 im.surface_end()
63346441 "edge":
6335 ol.set_layer("element", PackedVector3Array([_sel_edge_a, _sel_edge_b]),
6336 Color(0.2, 1.0, 0.6), 3.0, 0.4, 1.5)
6442 # Every edge in the set (§14.4); the active one alone when the set is empty.
6443 var segs := edge_set_lines()
6444 if segs.is_empty():
6445 segs = PackedVector3Array([_sel_edge_a, _sel_edge_b])
6446 ol.set_layer("element", segs, Color(0.2, 1.0, 0.6), 3.0, 0.4, 1.5)
63376447 "brush":
63386448 # OUTLINES ARE THE WHOLE GRAMMAR (docs/editor-texturing-selection-move.md §3):
63396449 # every selected brush outlined orange; the ACTIVE one — the operand, the
……@@ -6560,6 +6670,7 @@ func _pick_element(add: bool = false, at: Variant = null) -> void:
65606670 if _sel_mode == "brush":
65616671 _note_selected(brush, add)
65626672 _sel_faces.clear()
6673 _sel_edges.clear()
65636674 _sel_keys = level.get_brush(brush).get("plane_keys", PackedStringArray())
65646675 _sel_point = hit["position"]
65656676 _sel_kind = "brush"
……@@ -6632,6 +6743,20 @@ func _pick_element(add: bool = false, at: Variant = null) -> void:
66326743 # The cross-brush FACE SET: Shift toggles this face in it, a plain click restarts
66336744 # it. Captured before _note_selected, whose `add` toggle may drop the brush from
66346745 # _sel_brushes when a second face of an already-selected brush is clicked.
6746 # The cross-brush EDGE SET (§14.4), the same way: Shift toggles, a plain click restarts.
6747 if kind == "edge":
6748 var ek := _edge_key(brush, _sel_edge_a, _sel_edge_b)
6749 var ei := _sel_edges.find(ek)
6750 if ek.is_empty():
6751 pass
6752 elif not add:
6753 _sel_edges = [ek]
6754 elif ei >= 0:
6755 _sel_edges.remove_at(ei)
6756 else:
6757 _sel_edges.append(ek)
6758 else:
6759 _sel_edges.clear()
66356760 if kind == "face" and face < keys.size():
66366761 var fk := {"b": String(level.get_brush_key(brush)), "p": keys[face]}
66376762 var fi := _sel_faces.find(fk)
……@@ -6643,8 +6768,8 @@ func _pick_element(add: bool = false, at: Variant = null) -> void:
66436768 _sel_faces.append(fk)
66446769 else:
66456770 _sel_faces.clear()
6646 if kind == "face" and add and _sel_brushes.has(brush):
6647 _sel_brush = brush # another face of a selected brush: keep it, make it active
6771 if (kind == "face" or kind == "edge") and add and _sel_brushes.has(brush):
6772 _sel_brush = brush # another face / edge of a selected brush: keep it, make it active
66486773 else:
66496774 _note_selected(brush, add)
66506775 _sel_point = target
……@@ -7609,6 +7734,7 @@ func _after_new_brushes(ids: PackedInt32Array) -> void:
76097734 level.rebuild()
76107735 _refresh_brush_view()
76117736 _sel_faces.clear()
7737 _sel_edges.clear()
76127738 _sel_mode = "brush"
76137739 select_brushes(ids)
76147740 _update_face_highlight()
scenes/test/gate_edge_set.gd +170 -0
……@@ -0,0 +1,170 @@
1extends Node
2## GATE — THE EDGE SET (#120 L3, docs/node-tree-and-stacks.md §14.4): edge mode, Shift-click
3## builds a cross-brush SET of edges, as face mode does for faces. An edge is held by
4## durable names — the brush key and its two plane keys — never by a picked point.
5## E1 a plain click on an edge: a set of one, {b, p: the two plane keys, sorted}
6## E2 Shift-click another edge of the SAME brush: two, and the brush stays selected
7## E3 Shift-click an edge of another brush: three
8## E4 Shift-click the first edge again: removed (two)
9## E5 selected_edges() resolves each from its planes: the right endpoints and normals
10## E6 the set survives a re-fold
11## E7 the highlight draws every edge in the set
12## E8 a brush gone: its edge drops out of selected_edges()
13## E9 a plain click restarts the set
14## E10 face mode clears it
15##
16## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_edge_set.tscn
17
18var fails := 0
19var checks := 0
20var _cam: Camera3D = null
21
22
23func _ok(what: String, cond: bool, note: Variant = "") -> void:
24 checks += 1
25 if not cond:
26 fails += 1
27 print(" %-66s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
28
29
30func _ready() -> void:
31 var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
32 gs.route("world:level:*", 1, "peer")
33 var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
34 if keet != null:
35 keet.current_member_id = "gate-author"
36 get_tree().root.get_node("/root/GameMode").set_mode("build")
37 var map: Node3D = (load("res://scenes/maps/map_test.tscn") as PackedScene).instantiate()
38 add_child(map)
39 _cam = Camera3D.new()
40 add_child(_cam)
41 _cam.current = true
42 for i in 6:
43 await get_tree().process_frame
44 await _run(map)
45 get_tree().root.get_node("/root/GameMode").set_mode("play")
46 if checks < 13:
47 print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
48 fails += 1
49 print("\nGATE edge_set: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
50 get_tree().quit(0 if fails == 0 else 1)
51
52
53func _box_planes(lo: Vector3, hi: Vector3) -> Array:
54 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]
55
56
57func _wall(map: Node, lo: Vector3, hi: Vector3) -> int:
58 return map._create_brush_journalled({"planes": _box_planes(lo, hi),
59 "proj": [0, 0, 0, 0, 0, 0], "material_id": 7, "level_id": 0})
60
61
62## Aim along -n at `at` (a point on a face, near the edge wanted) and click.
63func _pick(map: Node, at: Vector3, n: Vector3, add: bool) -> void:
64 _cam.global_position = at + n * 3.0
65 _cam.look_at(at, Vector3.UP if absf(n.y) < 0.9 else Vector3.FORWARD)
66 await get_tree().physics_frame
67 map._pick_element(add, get_viewport().get_visible_rect().size * 0.5)
68
69
70## The plane key of `brush` whose outward normal is `n`.
71func _key(level: Node, brush: int, n: Vector3) -> String:
72 var d: Dictionary = level.get_brush(brush)
73 var pl: PackedFloat32Array = d["planes"]
74 var keys: PackedStringArray = d["plane_keys"]
75 for i in pl.size() / 4:
76 if Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2]).normalized().distance_to(n) < 1.0e-4:
77 return keys[i]
78 return ""
79
80
81func _pair(level: Node, brush: int, n1: Vector3, n2: Vector3) -> Array:
82 var p := [_key(level, brush, n1), _key(level, brush, n2)]
83 p.sort()
84 return p
85
86
87func _run(map: Node3D) -> void:
88 map._ensure_persistence()
89 var persist: Node = map.get("_persist")
90 var level: Node = map.get("level")
91 var y0 := 60.0
92 var wa := _wall(map, Vector3(200, y0, 0), Vector3(201, y0 + 2, 2))
93 var wb := _wall(map, Vector3(203, y0, 0), Vector3(204, y0 + 2, 2))
94 level.rebuild()
95 for i in 3:
96 await get_tree().physics_frame
97 var ka := String(level.get_brush_key(wa))
98 var kb := String(level.get_brush_key(wb))
99 var has := map.get("_sel_edges") != null and map.has_method("selected_edges")
100 _ok("setup: the map has _sel_edges and selected_edges()", has)
101 if not has:
102 return
103 map.set_select_mode("edge")
104 map.clear_selection()
105
106 # ---- E1: the top edge of wa's +X face ----
107 await _pick(map, Vector3(201, y0 + 1.9, 1), Vector3.RIGHT, false)
108 var se: Array = map.get("_sel_edges")
109 var e_top := {"b": ka, "p": _pair(level, wa, Vector3.RIGHT, Vector3.UP)}
110 _ok("E1 a plain click: one edge, wa's +X / +Y planes", se.size() == 1 and se[0] == e_top, se)
111
112 # ---- E2: wa's +X face, its front (+Z) edge ----
113 await _pick(map, Vector3(201, y0 + 1, 1.9), Vector3.RIGHT, true)
114 se = map.get("_sel_edges")
115 var e_front := {"b": ka, "p": _pair(level, wa, Vector3.RIGHT, Vector3.BACK)}
116 _ok("E2 Shift-click another edge of the same brush: two", se.size() == 2 and se.has(e_front), se)
117 _ok("E2 ... and the brush stays selected", (map.get("_sel_brushes") as Array).has(wa), map.get("_sel_brushes"))
118
119 # ---- E3: wb's -X face, its top edge ----
120 await _pick(map, Vector3(203, y0 + 1.9, 1), Vector3.LEFT, true)
121 se = map.get("_sel_edges")
122 var e_b := {"b": kb, "p": _pair(level, wb, Vector3.LEFT, Vector3.UP)}
123 _ok("E3 Shift-click an edge of another brush: three", se.size() == 3 and se.has(e_b), se)
124
125 # ---- E4 ----
126 await _pick(map, Vector3(201, y0 + 1.9, 1), Vector3.RIGHT, true)
127 se = map.get("_sel_edges")
128 _ok("E4 Shift-click the first edge again: removed", se.size() == 2 and not se.has(e_top), se)
129
130 # ---- E5 ----
131 var res: Array = map.selected_edges()
132 var front: Dictionary = {}
133 for r: Dictionary in res:
134 if String(r["b"]) == ka:
135 front = r
136 var ends: Array = [front.get("a", Vector3.INF), front.get("e", Vector3.INF)] if not front.is_empty() else []
137 var want := [Vector3(201, y0, 2), Vector3(201, y0 + 2, 2)]
138 var ends_ok := ends.size() == 2 and ((ends[0] as Vector3).distance_to(want[0]) < 1.0e-3 and (ends[1] as Vector3).distance_to(want[1]) < 1.0e-3
139 or (ends[0] as Vector3).distance_to(want[1]) < 1.0e-3 and (ends[1] as Vector3).distance_to(want[0]) < 1.0e-3)
140 _ok("E5 the front edge resolves to (201, y0, 2) .. (201, y0+2, 2)", res.size() == 2 and ends_ok, ends)
141 var ns: Array = front.get("normals", [])
142 _ok("E5 ... with its two face normals (+X, +Z)", ns.size() == 2 and (ns.has(Vector3.RIGHT) or (ns[0] as Vector3).distance_to(Vector3.RIGHT) < 1.0e-4
143 or (ns[1] as Vector3).distance_to(Vector3.RIGHT) < 1.0e-4), ns)
144
145 # ---- E6 ----
146 persist.refold()
147 _ok("E6 a re-fold keeps the set (both still resolve)", (map.get("_sel_edges") as Array).size() == 2
148 and (map.selected_edges() as Array).size() == 2, map.selected_edges().size())
149
150 # ---- E7 ----
151 var lines: PackedVector3Array = map.edge_set_lines() if map.has_method("edge_set_lines") else PackedVector3Array()
152 _ok("E7 the highlight's lines: every edge in the set (2 x 2 points)", lines.size() == 4, lines.size())
153
154 # ---- E8 ----
155 persist.begin_gesture("Delete wb")
156 persist.record("brush.remove", {"brush_key": kb})
157 persist.end_gesture()
158 persist.refold()
159 _ok("E8 wb gone: its edge drops out of selected_edges()", (map.selected_edges() as Array).size() == 1,
160 (map.selected_edges() as Array).size())
161 persist.undo_last()
162 persist.refold()
163
164 # ---- E9 ----
165 await _pick(map, Vector3(201, y0 + 1.9, 1), Vector3.RIGHT, false)
166 _ok("E9 a plain click restarts the set", (map.get("_sel_edges") as Array).size() == 1, map.get("_sel_edges"))
167
168 # ---- E10 ----
169 map.set_select_mode("face")
170 _ok("E10 face mode clears the edge set", (map.get("_sel_edges") as Array).is_empty(), map.get("_sel_edges"))
scenes/test/gate_edge_set.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_edge_set.gd" id="1"]
4
5[node name="EdgeSetGate" type="Node"]
6script = ExtResource("1")