OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / 919253b

#120 L3 §14.3: drop cables to the ground (Ctrl+Shift+D)
CurveDrop (pure; the ray handed in): spans longer than `step` get points, then every point but an open cable's ends falls straight down and rests the cable's radius above the hit; aligned/free handles laid onto the hit plane at their length, auto/vector re-derived. map.drop_cables: one curve.set per cable in one gesture, a physics ray that skips the cable's own pieces (load-bearing, checked by mutation). DropCableOperator: modal step, Adjust Last. Object menu + Q menu on a cable. gate_cable_drop 19/19 (in ci.yaml); 49 related gates green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
919253b9efa16081d423bc8ff218d59ee9a236fb
by plankatron <fleeky@gmail.com> · 2026-10-19T14:26:44.000Z

Changed files (9) +550 -2

.pear-git/ci.yaml +4 -0
……@@ -1114,6 +1114,10 @@ steps:
11141114 # Custom cable profiles (#120 L3 §14.2): a closed curve's convex outline as the
11151115 # cross-section, live; the Shape panel's Profile curve row; fallback to round ("r" names).
11161116 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_profile.tscn"
1117 - name: gate-cable-drop
1118 # Drop to the ground (#120 L3 §14.3): CurveDrop on a fake floor; real physics with the
1119 # cable's own pieces skipped; Ctrl+Shift+D tool, one gesture, Adjust Last.
1120 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"
11171121 - name: gate-curve-overlay
11181122 # Curves (wire, points, held handles) are an editor overlay: hidden in play mode, the
11191123 # Curves switch (Shift+Alt+K) and the master switch hide them in build (2026-10-18).
autoloads/keymap.gd +3 -0
……@@ -394,6 +394,9 @@ const DEFAULTS: Dictionary = {
394394 "builder.add_curve": {"label": "Curve (place points on surfaces; for cables, follow-curve)"},
395395 "builder.add_cable": {"label": "Cable (place points on surfaces; [ ] slack hangs each span)"},
396396 "builder.edit_curve": {"label": "Edit curve (the aimed point: move, Tab, X delete, I insert)"},
397 # #120 L3 §14.3 (Cablerator's Drop the Cable). Ctrl+Shift+D: with Shift+D / Alt+D
398 # beside it, and free in Blender's 3D view.
399 "builder.drop_cable": {"key": KEY_D, "ctrl": true, "shift": true, "label": "Drop the selected cables to the ground (ends stay)"},
397400 "builder.assign_generator": {"label": "Generator… (stairs, tunnel … on this shape; it stays the passage)"},
398401 "builder.add_modifier": {"key": KEY_M, "ctrl": true, "shift": true, "label": "Add modifier… (mirror, array, grid, radial, curve, chamfer, dice on this shape)"},
399402 # Menu-only, as in Blender (the outliner's / X menu's Delete Hierarchy): X keeps children.
docs/node-tree-and-stacks.md +8 -1
……@@ -939,6 +939,12 @@ Cablerator's Drop the Cable, adapted:
939939- **Handles:** `auto` / `vector` points re-derive from their new neighbours. `aligned` /
940940 `free` handles are projected onto the hit plane, keeping their length (Cablerator).
941941- The tool is modal (F3) only for `step`; Enter writes. Adjust Last re-runs it.
942- **Built 2026-10-19:** `CurveDrop` (pure, the ray handed in), `map.drop_cables` /
943 `dropped_points` / `_drop_ray`, `DropCableOperator`; `builder.drop_cable` =
944 **Ctrl+Shift+D**, in the Object menu and the Q menu on a cable. It acts on the selected
945 CABLES only: a bare curve cannot be selected in the viewport yet (the same gap as §14.2's
946 picker). The self-skip and the step are both load-bearing in `gate_cable_drop` (the skip
947 checked by mutation: without it the point lands on its own cable at y 104.97).
942948
943949### 14.4 The edge set (edge mode)
944950
……@@ -998,7 +1004,8 @@ Every command in a menu and on a key (standing rule):
9981004- Add ▸ Cable ▸ **From edges** and **Bundle**; Curve ▸ **Drop to ground**; the Q menu on
9991005 a cable or curve offers Drop.
10001006- Keys picked at build time against `keymap_gate` (no collisions, Blender meaning first)
1001 and recorded here.
1007 and recorded here:
1008 - Drop to ground: **Ctrl+Shift+D** (beside Shift+D / Alt+D; free in Blender's 3D view).
10021009
10031010### 14.8 Order and gates (each red first)
10041011
scenes/game/editor_top_bar.gd +1 -1
……@@ -36,7 +36,7 @@ const MENUS: Array = [
3636 "build_mode.csg_discard", "-", "build_mode.faces_hull", "build_mode.faces_extrude", "-",
3737 "builder.pivot_bbox", "builder.pivot_median", "builder.pivot_cursor", "builder.pivot_active",
3838 "builder.pivot_individual", "-", "builder.proportional", "builder.proportional_falloff",
39 "builder.proportional_connected", "-", "builder.make_parametric", "builder.assign_generator", "builder.add_modifier", "builder.edit_curve", "-", "builder.parent", "builder.clear_parent", "-",
39 "builder.proportional_connected", "-", "builder.make_parametric", "builder.assign_generator", "builder.add_modifier", "builder.edit_curve", "builder.drop_cable", "-", "builder.parent", "builder.clear_parent", "-",
4040 "builder.join", "builder.separate", "-", "builder.collection_new", "builder.collection_move",
4141 "builder.collection_link", "builder.collection_remove"]],
4242 ["Texture", ["builder.uv_menu", "-", "builder.texture_apply", "builder.texture_pick", "-",
scenes/maps/curves/curve_drop.gd +79 -0
……@@ -0,0 +1,79 @@
1extends RefCounted
2class_name CurveDrop
3## DROP A CURVE TO THE GROUND (#120 L3, docs/node-tree-and-stacks.md §14.3): Cablerator's
4## Drop the Cable. Pure and static — the map hands in the RAY (`ray.call(from)` ->
5## {position, normal} or {} on a miss), so a gate can drop onto a fake floor and the map
6## onto real physics with the cable's own pieces skipped.
7##
8## STEP FIRST: spans longer than `step` metres get interior points (even arc length within
9## the span, `auto` handles), so a cable follows the ground instead of bridging over it
10## (Cablerator's Add Points). Then every point but an OPEN curve's two ends (they stay on
11## their walls) falls straight down: it rests `radius` x its own radius above the hit, along
12## the hit normal, which becomes its `n`. A miss leaves the point. `aligned` / `free`
13## handles are laid onto the hit plane at their length; `auto` / `vector` re-derive from the
14## new neighbours (auto_handles).
15
16## Polyline steps per span when measuring and splitting it.
17const SPAN_STEPS := 64
18
19
20static func dropped(pts: Array, closed: bool, step: float, radius: float, ray: Callable) -> Array:
21 var src := subdivide(pts, closed, step) if step > 1.0e-6 else pts.duplicate(true)
22 var n := src.size()
23 for i in n:
24 if not closed and (i == 0 or i == n - 1):
25 continue
26 var q: Dictionary = src[i]
27 var hit: Dictionary = ray.call(q["p"])
28 if hit.is_empty():
29 continue
30 var nn: Vector3 = (hit["normal"] as Vector3).normalized()
31 q["p"] = (hit["position"] as Vector3) + nn * radius * float(q.get("radius", 1.0))
32 q["n"] = nn
33 var kind := CurveMath.handle_type(q)
34 if kind == "aligned" or kind == "free":
35 for key: String in ["h_in", "h_out"]:
36 var h: Vector3 = q.get(key, Vector3.ZERO)
37 var flat := h - nn * h.dot(nn)
38 if flat.length() > 1.0e-9:
39 q[key] = flat.normalized() * h.length()
40 return CurveMath.auto_handles(src, closed)
41
42
43## `pts` with interior points added so no span is longer than `step` (arc length). New
44## points sit ON the curve, with `auto` handles, tilt / radius interpolated, no normal.
45static func subdivide(pts: Array, closed: bool, step: float) -> Array:
46 var out: Array = []
47 var n := pts.size()
48 for i in n:
49 out.append((pts[i] as Dictionary).duplicate(true))
50 if not closed and i == n - 1:
51 break
52 if n < 2:
53 break
54 var c := CurveMath.controls(pts, closed, i)
55 # The span as a polyline: cumulative length at each of SPAN_STEPS + 1 parameters.
56 var acc := PackedFloat64Array([0.0])
57 var prev: Vector3 = c[0]
58 for k in range(1, SPAN_STEPS + 1):
59 var p := CurveMath.bezier(c, float(k) / SPAN_STEPS)
60 acc.append(acc[acc.size() - 1] + prev.distance_to(p))
61 prev = p
62 var span := acc[SPAN_STEPS]
63 var extra := ceili(span / step - 1.0e-9) - 1
64 if extra < 1:
65 continue
66 var a: Dictionary = pts[i]
67 var b: Dictionary = pts[(i + 1) % n]
68 var k2 := 0
69 for j in range(1, extra + 1):
70 var want := span * float(j) / float(extra + 1)
71 while k2 < SPAN_STEPS - 1 and acc[k2 + 1] < want:
72 k2 += 1
73 var f := (want - acc[k2]) / maxf(acc[k2 + 1] - acc[k2], 1.0e-12)
74 var t := (float(k2) + f) / SPAN_STEPS
75 var q := CurveMath.point(CurveMath.bezier(c, t), Vector3.ZERO)
76 q["tilt"] = lerpf(float(a.get("tilt", 0.0)), float(b.get("tilt", 0.0)), t)
77 q["radius"] = lerpf(float(a.get("radius", 1.0)), float(b.get("radius", 1.0)), t)
78 out.append(q)
79 return out
scenes/maps/map_dc_level.gd +111 -0
……@@ -606,6 +606,8 @@ func q_sections() -> Array:
606606 rows.append(["builder.assign_generator"])
607607 rows.append(["builder.add_modifier"])
608608 rows.append(["builder.make_real"])
609 if String(_persist.shape_state(subject).get("type", "")) == "cable":
610 rows.append(["builder.drop_cable"]) # #120 L3 §14.3
609611 else:
610612 rows.append(["builder.make_parametric"])
611613 if not op_stack(subject).is_empty():
……@@ -3023,6 +3025,7 @@ func _ensure_ops() -> OperatorStack:
30233025 _cable_op.as_cable = true
30243026 _cable_op = _make_op(_cable_op, "Cable")
30253027 _curve_edit = _make_op(CurveEditOperator.new(), "CurveEdit")
3028 _drop_op = _make_op(DropCableOperatorScript.new(), "DropCable") # #120 L3 §14.3
30263029 _grab_op = _make_op(ElementOperator.new(), "Grab")
30273030 _extrude = _make_op(ExtrudeOperator.new(), "Extrude")
30283031 _box = _make_op(CreateBoxOperator.new(), "CreateBox")
……@@ -4349,6 +4352,10 @@ func _builder_key(event: InputEvent) -> void:
43494352 if _ensure_ops().activate(_curve_edit):
43504353 print("[MapDCLevel] curve edit: the point follows your aim, Tab next, X delete, I insert, Enter applies")
43514354 return
4355 if Keymap.tapped(event, "builder.drop_cable"):
4356 if _ensure_ops().activate(_drop_op):
4357 print("[MapDCLevel] drop: the selected cables fall to the ground, [ ] point step, Enter drops")
4358 return
43524359 if Keymap.tapped(event, "builder.make_parametric"):
43534360 make_parametric()
43544361 return
……@@ -5569,6 +5576,110 @@ func curve_states() -> Dictionary:
55695576 return _persist.curve_states()
55705577
55715578
5579## DROP TO THE GROUND (#120 L3 §14.3): Cablerator's Drop the Cable. Preloaded, not by class
5580## name: a new class_name is unknown until the next import refreshes the class cache.
5581const CurveDropScript := preload("res://scenes/maps/curves/curve_drop.gd")
5582const DropCableOperatorScript := preload("res://scenes/maps/operators/drop_cable_operator.gd")
5583## How far down a dropping point looks for ground.
5584const DROP_REACH := 200.0
5585var _drop_op: LevelOperator = null
5586
5587
5588## The selected cables: the active shape and the shapes of the selected brushes, by dot.
5589func selected_cables() -> Array:
5590 _ensure_persistence()
5591 var cands: Array = [_sel_shape] if _sel_shape != "" else []
5592 for b: int in _sel_brushes:
5593 cands.append(shape_of_brush(b))
5594 var out: Array = []
5595 for d: String in cands:
5596 if d != "" and not out.has(d) and String(_persist.shape_state(d).get("type", "")) == "cable":
5597 out.append(d)
5598 return out
5599
5600
5601## Cable `dot`'s curve points as Drop would write them (the tool's preview reads this too);
5602## [] when it is not a cable on a live curve. A point rests the cable's radius above the hit
5603## — the whole bundle's when it has strands.
5604func dropped_points(dot: String, step: float) -> Array:
5605 _ensure_persistence()
5606 var st: Dictionary = _persist.shape_state(dot)
5607 if String(st.get("type", "")) != "cable":
5608 return []
5609 var cv: Dictionary = _persist.curve_states().get(String((st["params"] as Dictionary).get("curve", "")), {})
5610 if cv.is_empty():
5611 return []
5612 var p: Dictionary = ParamShapes.get_type("cable").resolve(st["params"])
5613 var rest := float(p["radius"]) * ((1.0 + float(p["spread"])) if int(p["strands"]) > 1 else 1.0)
5614 var own: Array = []
5615 for b: int in (level.get_group_brush_ids(dot) as PackedInt32Array):
5616 own.append(level.get_brush(b).get("planes", PackedFloat32Array()))
5617 return CurveDropScript.dropped(cv["points"], bool(cv.get("closed", false)), step, rest,
5618 func(from: Vector3) -> Dictionary: return _drop_ray(from, own))
5619
5620
5621## Drop each cable in `dots`: ONE curve.set per cable, all in one gesture. How many wrote.
5622func drop_cables(dots: Array, step: float = 1.0) -> int:
5623 _ensure_persistence()
5624 var targets: Array = []
5625 for d: Variant in dots:
5626 var pts := dropped_points(String(d), step)
5627 if pts.size() >= 2:
5628 targets.append([String((_persist.shape_state(String(d))["params"] as Dictionary)["curve"]), pts])
5629 var n := 0
5630 _persist.begin_gesture("Drop to ground")
5631 for t: Array in targets:
5632 if _persist.record("curve.set", {"target": t[0], "points": LevelPersistence.CurveFold.to_wire(t[1])}) != "":
5633 n += 1
5634 _persist.end_gesture()
5635 if n > 0:
5636 _persist.refold()
5637 _reselect_shape()
5638 return n
5639
5640
5641## Straight down from `from` to the first surface that is not one of `own` (the dropping
5642## cable's pieces, as plane arrays): {position, normal}, or {} on a miss. Players are
5643## excluded as for every editor ray. A hit on the cable itself restarts 1 mm below it.
5644func _drop_ray(from: Vector3, own: Array) -> Dictionary:
5645 var space := get_world_3d().direct_space_state
5646 var excl: Array[RID] = []
5647 var players := get_tree().root.get_node_or_null("Game/Players")
5648 if players != null:
5649 for pl in players.get_children():
5650 if pl is CollisionObject3D:
5651 excl.append((pl as CollisionObject3D).get_rid())
5652 var end := from + Vector3.DOWN * DROP_REACH
5653 var at := from
5654 for _i in 64:
5655 var q := PhysicsRayQueryParameters3D.create(at, end)
5656 q.collide_with_areas = false
5657 q.exclude = excl
5658 var hit: Dictionary = space.intersect_ray(q)
5659 if hit.is_empty():
5660 return {}
5661 var pos: Vector3 = hit["position"]
5662 if not _inside_any(pos, own):
5663 return {"position": pos, "normal": hit["normal"]}
5664 at = pos + Vector3.DOWN * 0.001
5665 if at.y <= end.y:
5666 return {}
5667 return {}
5668
5669
5670## Is `p` inside (or on, within FaceKit.ON_PLANE) any of the convex plane sets?
5671static func _inside_any(p: Vector3, sets: Array) -> bool:
5672 for pl: PackedFloat32Array in sets:
5673 var inside := pl.size() >= 16
5674 for k in range(0, pl.size(), 4):
5675 if Vector3(pl[k], pl[k + 1], pl[k + 2]).dot(p) - pl[k + 3] > FaceKit.ON_PLANE:
5676 inside = false
5677 break
5678 if inside:
5679 return true
5680 return false
5681
5682
55725683var _curve_view: MeshInstance3D = null
55735684
55745685
scenes/maps/operators/drop_cable_operator.gd +114 -0
……@@ -0,0 +1,114 @@
1extends LevelOperator
2## DROP TO THE GROUND (#120 L3, docs/node-tree-and-stacks.md §14.3): Cablerator's Drop the
3## Cable on the selected cables. Every point but an open cable's two ends falls straight
4## down and rests on what it hits; spans longer than `step` get points first, so the cable
5## follows the ground. The preview draws the dropped curves; Enter writes ONE curve.set per
6## cable (map.drop_cables, one gesture). Adjust Last re-runs it with another step.
7
8var step := 1.0 # metres between added points (0 = none added)
9var _targets: Array = [] # the cables' dots
10var _frozen := false # Adjust Last: act on the stored cables, not the selection
11
12
13func op_name() -> String:
14 return "drop to ground"
15
16
17func gesture_label() -> String:
18 return "Drop to ground"
19
20
21func preview_color() -> Color:
22 return Color(1.0, 0.85, 0.3, 0.9) # the curve view's amber
23
24
25func setup_modal(m: ModalControls) -> void:
26 m.confirm_label = "drop"
27 m.table([["", "cables", func() -> String: return "%d" % _targets.size()]])
28 m.bind("step", "add points every", func() -> float: return step,
29 func(v: float) -> void:
30 step = maxf(0.0, v)
31 update_preview(),
32 0.25, " m", 0.05)
33
34
35func status() -> String:
36 return modal().status_text()
37
38
39func on_activate() -> bool:
40 if not _frozen:
41 _targets = map.selected_cables() if map != null and map.has_method("selected_cables") else []
42 if _targets.is_empty():
43 print("[drop] select a cable first")
44 return false
45 update_preview()
46 return true
47
48
49func on_deactivate() -> void:
50 clear_preview()
51
52
53func handle_key(event: InputEvent) -> bool:
54 return modal().handle_key(event)
55
56
57## The dropped curves, as lines (nothing is written until confirm).
58func update_preview() -> void:
59 clear_preview()
60 var lines := PackedVector3Array()
61 for d: String in _targets:
62 var pts: Array = map.dropped_points(d, step)
63 var curves: Dictionary = map.curve_states()
64 var cd := String((map._persist.shape_state(d)["params"] as Dictionary).get("curve", ""))
65 var closed := bool(curves.get(cd, {}).get("closed", false))
66 if pts.size() < 2:
67 continue
68 var fs := CurveMath.sample(pts, closed, 64)
69 for i in range(1, fs.size()):
70 lines.append(fs[i - 1]["p"])
71 lines.append(fs[i]["p"])
72 draw_lines(lines)
73
74
75func confirm() -> bool:
76 var n: int = map.drop_cables(_targets, step)
77 if n == 0:
78 return false
79 ModalControls.summary("Dropped %d cable%s%s" % [n, "" if n == 1 else "s",
80 ", a point every %.2f m" % step if step > 0.0 else ""])
81 return true
82
83
84func tool_settings() -> Array:
85 return [{"id": "step", "label": "Add points every", "type": "float", "value": step, "min": 0.0,
86 "max": 20.0, "step": 0.25, "tip": "Spans longer than this get points first, so the cable follows the ground (0 = none)."}]
87
88
89func set_tool_setting(sid: String, value: Variant) -> bool:
90 if sid != "step":
91 return false
92 step = maxf(0.0, float(value))
93 if not _frozen:
94 update_preview()
95 return true
96
97
98func adjust_ids() -> Array:
99 return ["step"]
100
101
102func adjust_snapshot() -> Dictionary:
103 return {"targets": _targets.duplicate(), "step": step}
104
105
106func adjust_restore(snap: Dictionary) -> void:
107 _targets = (snap.get("targets", []) as Array).duplicate()
108 step = float(snap.get("step", step))
109 _frozen = true
110
111
112func adjust_end() -> void:
113 _frozen = false
114 clear_preview()
scenes/test/gate_cable_drop.gd +224 -0
……@@ -0,0 +1,224 @@
1extends Node
2## GATE — DROP A CABLE TO THE GROUND (#120 L3, docs/node-tree-and-stacks.md §14.3).
3## pure (CurveDrop, a fake flat ground at y = 0):
4## D1 interior points land at the radius above the ground, normal up; the ends stay
5## D2 step 1 on a 10 m span: 11 points, 1 m apart, the interior all dropped
6## D3 a miss leaves every point where it was
7## D4 an ALIGNED handle is laid onto the ground plane, its length kept, its type kept
8## D5 a closed curve drops every point
9## D6 a point's radius scales the rest height
10## in the map (real physics):
11## M1 a cable over a floor with a box on it: points over the box rest on the box, the
12## rest on the floor, the ends where they were
13## M2 a point above the cable's OWN lower part skips it and lands on the floor
14## M3 one cable = one curve.set; Ctrl+Z puts the curve back
15## M4 builder.drop_cable starts the tool on the selected cable; Enter writes; Adjust Last
16## with another step re-runs it as one live gesture
17## M5 the Q menu on a cable offers Drop; the key is bound
18##
19## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_drop.tscn
20
21var fails := 0
22var checks := 0
23var map: Node3D
24var persist: Node
25var level: Node
26var CD: GDScript = null
27
28
29func _ok(what: String, cond: bool, note: Variant = "") -> void:
30 checks += 1
31 if not cond:
32 fails += 1
33 print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
34
35
36func _ready() -> void:
37 if ResourceLoader.exists("res://scenes/maps/curves/curve_drop.gd"):
38 CD = load("res://scenes/maps/curves/curve_drop.gd")
39 _ok("setup: CurveDrop exists", CD != null)
40 if CD != null:
41 _pure()
42 var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
43 gs.route("world:level:*", 1, "peer")
44 var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
45 if keet != null:
46 keet.current_member_id = "gate-author"
47 get_tree().root.get_node("/root/GameMode").set_mode("build")
48 map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
49 add_child(map)
50 for i in 6:
51 await get_tree().process_frame
52 await _run()
53 get_tree().root.get_node("/root/GameMode").set_mode("play")
54 if checks < 16:
55 print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
56 fails += 1
57 print("\nGATE cable_drop: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
58 get_tree().quit(0 if fails == 0 else 1)
59
60
61func _flat(from: Vector3) -> Dictionary:
62 return {"position": Vector3(from.x, 0.0, from.z), "normal": Vector3.UP}
63
64
65func _miss(_from: Vector3) -> Dictionary:
66 return {}
67
68
69func _pts(ps: Array) -> Array:
70 var out: Array = []
71 for p: Vector3 in ps:
72 out.append(CurveMath.point(p, Vector3.ZERO))
73 return out
74
75
76func _ys(pts: Array) -> Array:
77 var out: Array = []
78 for q: Dictionary in pts:
79 out.append(snappedf((q["p"] as Vector3).y, 1.0e-4))
80 return out
81
82
83func _pure() -> void:
84 # ---- D1 ----
85 var three := CurveMath.auto_handles(_pts([Vector3(0, 5, 0), Vector3(5, 5, 0), Vector3(10, 5, 0)]), false)
86 var d1: Array = CD.dropped(three, false, 0.0, 0.1, _flat)
87 _ok("D1 the middle point rests 0.1 above the ground, n up; the ends stay at 5", d1.size() == 3
88 and _ys(d1) == [5.0, 0.1, 5.0] and (d1[1]["n"] as Vector3).is_equal_approx(Vector3.UP), _ys(d1))
89 # ---- D2 ----
90 var two := CurveMath.auto_handles(_pts([Vector3(0, 5, 0), Vector3(10, 5, 0)]), false)
91 var d2: Array = CD.dropped(two, false, 1.0, 0.1, _flat)
92 var even := d2.size() == 11
93 for i in range(1, d2.size()):
94 var dx := (d2[i]["p"] as Vector3).x - (d2[i - 1]["p"] as Vector3).x
95 even = even and absf(dx - 1.0) < 1.0e-2
96 var low := d2.size() == 11
97 for i in range(1, d2.size() - 1):
98 low = low and absf((d2[i]["p"] as Vector3).y - 0.1) < 1.0e-4
99 _ok("D2 step 1 on 10 m: 11 points, 1 m apart", even, d2.size())
100 _ok("D2 ... the 9 interior points all dropped", low, _ys(d2))
101 # ---- D3 ----
102 var d3: Array = CD.dropped(three, false, 0.0, 0.1, _miss)
103 _ok("D3 a miss leaves every point", _ys(d3) == [5.0, 5.0, 5.0], _ys(d3))
104 # ---- D4 ----
105 var al := _pts([Vector3(0, 5, 0), Vector3(5, 5, 0), Vector3(10, 5, 0)])
106 al[1]["handle"] = "aligned"
107 al[1]["h_out"] = Vector3(1, 1, 0)
108 al[1]["h_in"] = Vector3(-1, -1, 0)
109 var d4: Array = CD.dropped(CurveMath.auto_handles(al, false), false, 0.0, 0.1, _flat)
110 var ho: Vector3 = d4[1]["h_out"]
111 _ok("D4 an aligned handle lies on the ground, its length kept, still aligned",
112 ho.distance_to(Vector3(sqrt(2.0), 0, 0)) < 1.0e-4 and String(d4[1].get("handle", "")) == "aligned", ho)
113 # ---- D5 ----
114 var loop := CurveMath.auto_handles(_pts([Vector3(0, 5, 0), Vector3(4, 5, 0), Vector3(4, 5, 4), Vector3(0, 5, 4)]), true)
115 var d5: Array = CD.dropped(loop, true, 0.0, 0.1, _flat)
116 _ok("D5 a closed curve drops every point", _ys(d5) == [0.1, 0.1, 0.1, 0.1], _ys(d5))
117 # ---- D6 ----
118 var fat := _pts([Vector3(0, 5, 0), Vector3(5, 5, 0), Vector3(10, 5, 0)])
119 fat[1]["radius"] = 2.0
120 var d6: Array = CD.dropped(CurveMath.auto_handles(fat, false), false, 0.0, 0.1, _flat)
121 _ok("D6 point radius 2 x radius 0.1: rests 0.2 above", absf((d6[1]["p"] as Vector3).y - 0.2) < 1.0e-4, _ys(d6))
122
123
124func _box(origin: Vector3, size: Vector3) -> String:
125 persist.begin_gesture("Box")
126 var d: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
127 "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, origin)),
128 "size": [size.x, size.y, size.z], "material_id": 2, "level_id": 0})
129 persist.end_gesture()
130 return d
131
132
133func _curve_pts(cable: String) -> Array:
134 var cd := String((persist.shape_state(cable)["params"] as Dictionary).get("curve", ""))
135 return (persist.curve_states() as Dictionary).get(cd, {}).get("points", [])
136
137
138func _run() -> void:
139 persist = map.get("_persist")
140 level = map.get("level")
141 var ops: Node = map.call("_ensure_ops")
142 _box(Vector3(295, 90, 290), Vector3(50, 10, 20)) # floor: top at y 100
143 _box(Vector3(308, 100, 298), Vector3(4, 2, 4)) # a box on it: top 102, x 308..312
144 persist.refold()
145 for i in 4:
146 await get_tree().physics_frame
147 var cable := map.add_cable(_pts([Vector3(300, 105, 300), Vector3(320, 105, 300)]), false, {"radius": 0.1}) as String
148 var hook := map.add_cable(_pts([Vector3(330, 105, 300), Vector3(336, 105, 300), Vector3(336, 101, 300)]),
149 false, {"radius": 0.1}) as String
150 for i in 4:
151 await get_tree().physics_frame
152 var has_drop := map.has_method("drop_cables")
153 _ok("setup: map.drop_cables exists", has_drop)
154 if not has_drop:
155 return
156
157 # ---- M1 + M3 ----
158 var before: Array = _curve_pts(cable)
159 var n0: int = (persist.records(false) as Array).size()
160 var wrote: int = map.drop_cables([cable], 1.0)
161 var n1: int = (persist.records(false) as Array).size()
162 var after: Array = _curve_pts(cable)
163 var on_box := 0
164 var on_floor := 0
165 var bad: Array = []
166 for i in range(1, after.size() - 1):
167 var p: Vector3 = after[i]["p"]
168 if p.x > 308.2 and p.x < 311.8:
169 if absf(p.y - 102.1) < 0.01:
170 on_box += 1
171 else:
172 bad.append(p)
173 elif p.x < 307.8 or p.x > 312.2:
174 if absf(p.y - 100.1) < 0.01:
175 on_floor += 1
176 else:
177 bad.append(p)
178 _ok("M1 21 points: on the box at 102.1, on the floor at 100.1", after.size() == 21 and on_box >= 3
179 and on_floor >= 14 and bad.is_empty(), [after.size(), on_box, on_floor, bad])
180 _ok("M1 ... the ends where they were", after.size() >= 2 and (after[0]["p"] as Vector3).is_equal_approx(Vector3(300, 105, 300))
181 and (after[after.size() - 1]["p"] as Vector3).is_equal_approx(Vector3(320, 105, 300)),
182 [after[0]["p"], after[after.size() - 1]["p"]] if after.size() >= 2 else after)
183 _ok("M3 one cable: one record (curve.set), drop_cables says 1", n1 == n0 + 1 and wrote == 1, [n1 - n0, wrote])
184 persist.undo_last()
185 persist.refold()
186 _ok("M3 ... Ctrl+Z puts the curve back", _curve_pts(cable).size() == before.size()
187 and (_curve_pts(cable)[0]["p"] as Vector3).is_equal_approx(before[0]["p"]), _curve_pts(cable).size())
188
189 # ---- M2 ----
190 map.drop_cables([hook], 0.0)
191 var hp: Array = _curve_pts(hook)
192 _ok("M2 the point above its own cable skips it and lands on the floor (100.1)", hp.size() == 3
193 and absf((hp[1]["p"] as Vector3).y - 100.1) < 0.01, hp.map(func(q: Dictionary) -> Vector3: return q["p"]))
194 persist.undo_last()
195 persist.refold()
196
197 # ---- M4 ----
198 map.select_shape(cable)
199 var ev := InputEventAction.new()
200 ev.action = "builder.drop_cable"
201 ev.pressed = true
202 map._unhandled_input(ev)
203 var op: Object = map.get("_drop_op")
204 _ok("M4 builder.drop_cable: the drop tool is up", op != null and ops.get("active") == op, ops.get("active"))
205 if op != null and ops.get("active") == op:
206 op.set_tool_setting("step", 2.0)
207 ops.confirm()
208 _ok("M4 Enter with step 2: 11 points", _curve_pts(cable).size() == 11, _curve_pts(cable).size())
209 var live0: int = (persist.records(false) as Array).filter(func(r: Dictionary) -> bool: return not bool(r.get("undone", false))).size()
210 var adj: bool = ops.adjust("step", 1.0)
211 var live1: int = (persist.records(false) as Array).filter(func(r: Dictionary) -> bool: return not bool(r.get("undone", false))).size()
212 _ok("M4 Adjust Last step 1: 21 points, still one live drop", adj and _curve_pts(cable).size() == 21
213 and live1 == live0, [adj, _curve_pts(cable).size(), live0, live1])
214
215 # ---- M5 ----
216 map.select_shape(cable)
217 var qrows: Array = []
218 for sec: Array in map.q_sections():
219 for r: Array in sec[1]:
220 qrows.append(r[0])
221 _ok("M5 the Q menu on a cable offers Drop", qrows.has("builder.drop_cable"), qrows)
222 var spec: Dictionary = Keymap.DEFAULTS.get("builder", {}).get("builder.drop_cable", {})
223 _ok("M5 builder.drop_cable is bound (Ctrl+Shift+D)", int(spec.get("key", 0)) == KEY_D
224 and bool(spec.get("ctrl", false)) and bool(spec.get("shift", false)), spec)
scenes/test/gate_cable_drop.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_drop.gd" id="1"]
4
5[node name="CableDropGate" type="Node"]
6script = ExtResource("1")