| … | … | @@ -1122,6 +1122,10 @@ steps: |
| 1122 | 1122 | # The edge set (#120 L3 §14.4): Shift-click edges across brushes, held as brush key + |
| 1123 | 1123 | # two plane keys, resolved from the planes, live brushes only, highlighted. |
| 1124 | 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" |
| 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" | |
| 1125 | 1129 | - name: gate-curve-overlay |
| 1126 | 1130 | # Curves (wire, points, held handles) are an editor overlay: hidden in play mode, the |
| 1127 | 1131 | # Curves switch (Shift+Alt+K) and the master switch hide them in build (2026-10-18). |
| … | … | @@ -397,6 +397,8 @@ const DEFAULTS: Dictionary = { |
| 397 | 397 | # #120 L3 §14.3 (Cablerator's Drop the Cable). Ctrl+Shift+D: with Shift+D / Alt+D |
| 398 | 398 | # beside it, and free in Blender's 3D view. |
| 399 | 399 | "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)"}, | |
| 400 | 402 | "builder.assign_generator": {"label": "Generator… (stairs, tunnel … on this shape; it stays the passage)"}, |
| 401 | 403 | "builder.add_modifier": {"key": KEY_M, "ctrl": true, "shift": true, "label": "Add modifier… (mirror, array, grid, radial, curve, chamfer, dice on this shape)"}, |
| 402 | 404 | # Menu-only, as in Blender (the outliner's / X menu's Delete Hierarchy): X keeps children. |
| … | … | @@ -980,6 +980,14 @@ Cablerator's Drop the Cable, adapted: |
| 980 | 980 | - Enter = ONE gesture: a curve + cable per chain (n stored = the outward normal). The new |
| 981 | 981 | cables are selected. Adjust Last re-runs it. |
| 982 | 982 | - 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. | |
| 983 | 991 | |
| 984 | 992 | ### 14.6 Bundle between two face sets (`builder.cable_bundle`) |
| 985 | 993 | |
| … | … | @@ -1013,6 +1021,7 @@ Every command in a menu and on a key (standing rule): |
| 1013 | 1021 | - Keys picked at build time against `keymap_gate` (no collisions, Blender meaning first) |
| 1014 | 1022 | and recorded here: |
| 1015 | 1023 | - 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). | |
| 1016 | 1025 | |
| 1017 | 1026 | ### 14.8 Order and gates (each red first) |
| 1018 | 1027 |
| … | … | @@ -26,7 +26,7 @@ const MENUS: Array = [ |
| 26 | 26 | "build_mode.sel_brush", "builder.element_toggle", "builder.select_mode_menu"]], |
| 27 | 27 | ["Add", ["builder.add_menu", "-", "builder.add_box", "builder.add_stairs", "builder.add_spiral_stairs", |
| 28 | 28 | "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"]], | |
| 30 | 30 | # Blender 2.79's OBJECT menu (was "Brush"): transforms, CSG, pivot, and since #120 F1 |
| 31 | 31 | # parenting, join / separate and collections. One menu, not a new one: a seventh title |
| 32 | 32 | # pushed the top band past a 1280-px window (gate_editor_e1). |
| … | … | @@ -0,0 +1,140 @@ |
| 1 | extends RefCounted | |
| 2 | class_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. | |
| 22 | const 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). | |
| 29 | static 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 | ||
| 63 | static 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 | ||
| 73 | static 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. | |
| 112 | static 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 | ||
| 137 | static func _pt(p: Vector3, n: Vector3, handle: String) -> Dictionary: | |
| 138 | var q := CurveMath.point(p, n) | |
| 139 | q["handle"] = handle | |
| 140 | return q |
| … | … | @@ -557,6 +557,8 @@ func context_sections() -> Array: |
| 557 | 557 | ["build_mode.faces_extrude"]]) |
| 558 | 558 | if _sel_faces.size() >= 2: |
| 559 | 559 | el.append(["build_mode.faces_hull"]) |
| 560 | if _sel_kind == "edge": | |
| 561 | el.append(["builder.cable_from_edges"]) # #120 L3 §14.5 | |
| 560 | 562 | el.append(["builder.deselect"]) |
| 561 | 563 | secs.append([_sel_kind.capitalize(), el]) |
| 562 | 564 | if not _sel_brushes.is_empty(): |
| … | … | @@ -1312,7 +1314,8 @@ func add_sections() -> Array: |
| 1312 | 1314 | var rows: Array = [] |
| 1313 | 1315 | for s: String in ADD_SHAPES: |
| 1314 | 1316 | 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"]]]] | |
| 1316 | 1319 | |
| 1317 | 1320 | |
| 1318 | 1321 | ## Start the create tool already set to `shape_id`. |
| … | … | @@ -3041,6 +3044,7 @@ func _ensure_ops() -> OperatorStack: |
| 3041 | 3044 | _cable_op = _make_op(_cable_op, "Cable") |
| 3042 | 3045 | _curve_edit = _make_op(CurveEditOperator.new(), "CurveEdit") |
| 3043 | 3046 | _drop_op = _make_op(DropCableOperatorScript.new(), "DropCable") # #120 L3 §14.3 |
| 3047 | _edges_op = _make_op(CableEdgesOperatorScript.new(), "CableEdges") # #120 L3 §14.5 | |
| 3044 | 3048 | _grab_op = _make_op(ElementOperator.new(), "Grab") |
| 3045 | 3049 | _extrude = _make_op(ExtrudeOperator.new(), "Extrude") |
| 3046 | 3050 | _box = _make_op(CreateBoxOperator.new(), "CreateBox") |
| … | … | @@ -4367,6 +4371,10 @@ func _builder_key(event: InputEvent) -> void: |
| 4367 | 4371 | if _ensure_ops().activate(_curve_edit): |
| 4368 | 4372 | print("[MapDCLevel] curve edit: the point follows your aim, Tab next, X delete, I insert, Enter applies") |
| 4369 | 4373 | 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 | |
| 4370 | 4378 | if Keymap.tapped(event, "builder.drop_cable"): |
| 4371 | 4379 | if _ensure_ops().activate(_drop_op): |
| 4372 | 4380 | 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 |
| 5639 | 5647 | _ensure_persistence() |
| 5640 | 5648 | if points.size() < 2: |
| 5641 | 5649 | return "" |
| 5642 | var pts: Array = CurveMath.auto_handles(points, closed) if auto else points | |
| 5643 | 5650 | _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) | |
| 5654 | 5652 | _persist.end_gesture() |
| 5655 | 5653 | _persist.refold() |
| 5656 | 5654 | if dot != "": |
| … | … | @@ -5772,6 +5770,55 @@ func _drop_ray(from: Vector3, own: Array) -> Dictionary: |
| 5772 | 5770 | return {} |
| 5773 | 5771 | |
| 5774 | 5772 | |
| 5773 | ## CABLES FROM EDGES (#120 L3 §14.5). Preloaded for the same class-cache reason as Drop. | |
| 5774 | const CableEdgesOperatorScript := preload("res://scenes/maps/operators/cable_edges_operator.gd") | |
| 5775 | const EdgeCablesScript := preload("res://scenes/maps/curves/edge_cables.gd") | |
| 5776 | var _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 "". | |
| 5781 | func _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). | |
| 5797 | func 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 | ||
| 5775 | 5822 | ## Is `p` inside (or on, within FaceKit.ON_PLANE) any of the convex plane sets? |
| 5776 | 5823 | static func _inside_any(p: Vector3, sets: Array) -> bool: |
| 5777 | 5824 | for pl: PackedFloat32Array in sets: |
| … | … | @@ -0,0 +1,159 @@ |
| 1 | extends 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 | ||
| 9 | const EdgeCablesScript := preload("res://scenes/maps/curves/edge_cables.gd") | |
| 10 | const POINTS_AT := ["Vertices", "Centres"] | |
| 11 | const CORNERS := ["Round", "Sharp"] | |
| 12 | ||
| 13 | var centres := false | |
| 14 | var sharp := false | |
| 15 | var offset := 0.05 # metres off the edge, along its outward normal | |
| 16 | var radius := 0.05 | |
| 17 | var _edges: Array = [] # selected_edges() at activation (Adjust Last: stored) | |
| 18 | var _frozen := false | |
| 19 | ||
| 20 | ||
| 21 | func op_name() -> String: | |
| 22 | return "cable from edges" | |
| 23 | ||
| 24 | ||
| 25 | func gesture_label() -> String: | |
| 26 | return "Cable from edges" | |
| 27 | ||
| 28 | ||
| 29 | func preview_color() -> Color: | |
| 30 | return Color(1.0, 0.85, 0.3, 0.9) # the curve view's amber | |
| 31 | ||
| 32 | ||
| 33 | func 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 | ||
| 50 | func status() -> String: | |
| 51 | return modal().status_text() | |
| 52 | ||
| 53 | ||
| 54 | func 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 | ||
| 64 | func on_deactivate() -> void: | |
| 65 | clear_preview() | |
| 66 | ||
| 67 | ||
| 68 | func 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 | ||
| 83 | func 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 | ||
| 101 | func 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 | ||
| 110 | func 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 | ||
| 123 | func 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 | ||
| 140 | func adjust_ids() -> Array: | |
| 141 | return ["points_at", "corners", "offset", "radius"] | |
| 142 | ||
| 143 | ||
| 144 | func adjust_snapshot() -> Dictionary: | |
| 145 | return {"edges": _edges.duplicate(true), "centres": centres, "sharp": sharp, "offset": offset, "radius": radius} | |
| 146 | ||
| 147 | ||
| 148 | func 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 | ||
| 157 | func adjust_end() -> void: | |
| 158 | _frozen = false | |
| 159 | clear_preview() |
| … | … | @@ -0,0 +1,262 @@ |
| 1 | extends 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 | ||
| 21 | var fails := 0 | |
| 22 | var checks := 0 | |
| 23 | var EC: GDScript = null | |
| 24 | ||
| 25 | ||
| 26 | func _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 | ||
| 33 | func _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. | |
| 59 | func _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. | |
| 64 | func _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 | ||
| 72 | func _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 | ||
| 127 | func _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 | ||
| 131 | func _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 | ||
| 139 | func _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 | ||
| 144 | func _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. | |
| 154 | func _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. | |
| 162 | func _resolved(map: Node, keys: Array) -> Array: | |
| 163 | _set_edges(map, keys) | |
| 164 | return map.selected_edges() | |
| 165 | ||
| 166 | ||
| 167 | func _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 | ||
| 174 | func _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) |
| … | … | @@ -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"] | |
| 6 | script = ExtResource("1") |