| … | … | @@ -1114,6 +1114,10 @@ steps: |
| 1114 | 1114 | # Custom cable profiles (#120 L3 §14.2): a closed curve's convex outline as the |
| 1115 | 1115 | # cross-section, live; the Shape panel's Profile curve row; fallback to round ("r" names). |
| 1116 | 1116 | 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" | |
| 1117 | 1121 | - name: gate-curve-overlay |
| 1118 | 1122 | # Curves (wire, points, held handles) are an editor overlay: hidden in play mode, the |
| 1119 | 1123 | # Curves switch (Shift+Alt+K) and the master switch hide them in build (2026-10-18). |
| … | … | @@ -394,6 +394,9 @@ const DEFAULTS: Dictionary = { |
| 394 | 394 | "builder.add_curve": {"label": "Curve (place points on surfaces; for cables, follow-curve)"}, |
| 395 | 395 | "builder.add_cable": {"label": "Cable (place points on surfaces; [ ] slack hangs each span)"}, |
| 396 | 396 | "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)"}, | |
| 397 | 400 | "builder.assign_generator": {"label": "Generator… (stairs, tunnel … on this shape; it stays the passage)"}, |
| 398 | 401 | "builder.add_modifier": {"key": KEY_M, "ctrl": true, "shift": true, "label": "Add modifier… (mirror, array, grid, radial, curve, chamfer, dice on this shape)"}, |
| 399 | 402 | # Menu-only, as in Blender (the outliner's / X menu's Delete Hierarchy): X keeps children. |
| … | … | @@ -939,6 +939,12 @@ Cablerator's Drop the Cable, adapted: |
| 939 | 939 | - **Handles:** `auto` / `vector` points re-derive from their new neighbours. `aligned` / |
| 940 | 940 | `free` handles are projected onto the hit plane, keeping their length (Cablerator). |
| 941 | 941 | - 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). | |
| 942 | 948 | |
| 943 | 949 | ### 14.4 The edge set (edge mode) |
| 944 | 950 | |
| … | … | @@ -998,7 +1004,8 @@ Every command in a menu and on a key (standing rule): |
| 998 | 1004 | - Add ▸ Cable ▸ **From edges** and **Bundle**; Curve ▸ **Drop to ground**; the Q menu on |
| 999 | 1005 | a cable or curve offers Drop. |
| 1000 | 1006 | - 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). | |
| 1002 | 1009 | |
| 1003 | 1010 | ### 14.8 Order and gates (each red first) |
| 1004 | 1011 |
| … | … | @@ -36,7 +36,7 @@ const MENUS: Array = [ |
| 36 | 36 | "build_mode.csg_discard", "-", "build_mode.faces_hull", "build_mode.faces_extrude", "-", |
| 37 | 37 | "builder.pivot_bbox", "builder.pivot_median", "builder.pivot_cursor", "builder.pivot_active", |
| 38 | 38 | "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", "-", | |
| 40 | 40 | "builder.join", "builder.separate", "-", "builder.collection_new", "builder.collection_move", |
| 41 | 41 | "builder.collection_link", "builder.collection_remove"]], |
| 42 | 42 | ["Texture", ["builder.uv_menu", "-", "builder.texture_apply", "builder.texture_pick", "-", |
| … | … | @@ -0,0 +1,79 @@ |
| 1 | extends RefCounted | |
| 2 | class_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. | |
| 17 | const SPAN_STEPS := 64 | |
| 18 | ||
| 19 | ||
| 20 | static 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. | |
| 45 | static 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 |
| … | … | @@ -606,6 +606,8 @@ func q_sections() -> Array: |
| 606 | 606 | rows.append(["builder.assign_generator"]) |
| 607 | 607 | rows.append(["builder.add_modifier"]) |
| 608 | 608 | 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 | |
| 609 | 611 | else: |
| 610 | 612 | rows.append(["builder.make_parametric"]) |
| 611 | 613 | if not op_stack(subject).is_empty(): |
| … | … | @@ -3023,6 +3025,7 @@ func _ensure_ops() -> OperatorStack: |
| 3023 | 3025 | _cable_op.as_cable = true |
| 3024 | 3026 | _cable_op = _make_op(_cable_op, "Cable") |
| 3025 | 3027 | _curve_edit = _make_op(CurveEditOperator.new(), "CurveEdit") |
| 3028 | _drop_op = _make_op(DropCableOperatorScript.new(), "DropCable") # #120 L3 §14.3 | |
| 3026 | 3029 | _grab_op = _make_op(ElementOperator.new(), "Grab") |
| 3027 | 3030 | _extrude = _make_op(ExtrudeOperator.new(), "Extrude") |
| 3028 | 3031 | _box = _make_op(CreateBoxOperator.new(), "CreateBox") |
| … | … | @@ -4349,6 +4352,10 @@ func _builder_key(event: InputEvent) -> void: |
| 4349 | 4352 | if _ensure_ops().activate(_curve_edit): |
| 4350 | 4353 | print("[MapDCLevel] curve edit: the point follows your aim, Tab next, X delete, I insert, Enter applies") |
| 4351 | 4354 | 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 | |
| 4352 | 4359 | if Keymap.tapped(event, "builder.make_parametric"): |
| 4353 | 4360 | make_parametric() |
| 4354 | 4361 | return |
| … | … | @@ -5569,6 +5576,110 @@ func curve_states() -> Dictionary: |
| 5569 | 5576 | return _persist.curve_states() |
| 5570 | 5577 | |
| 5571 | 5578 | |
| 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. | |
| 5581 | const CurveDropScript := preload("res://scenes/maps/curves/curve_drop.gd") | |
| 5582 | const DropCableOperatorScript := preload("res://scenes/maps/operators/drop_cable_operator.gd") | |
| 5583 | ## How far down a dropping point looks for ground. | |
| 5584 | const DROP_REACH := 200.0 | |
| 5585 | var _drop_op: LevelOperator = null | |
| 5586 | ||
| 5587 | ||
| 5588 | ## The selected cables: the active shape and the shapes of the selected brushes, by dot. | |
| 5589 | func 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. | |
| 5604 | func 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. | |
| 5622 | func 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. | |
| 5644 | func _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? | |
| 5671 | static 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 | ||
| 5572 | 5683 | var _curve_view: MeshInstance3D = null |
| 5573 | 5684 | |
| 5574 | 5685 |
| … | … | @@ -0,0 +1,114 @@ |
| 1 | extends 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 | ||
| 8 | var step := 1.0 # metres between added points (0 = none added) | |
| 9 | var _targets: Array = [] # the cables' dots | |
| 10 | var _frozen := false # Adjust Last: act on the stored cables, not the selection | |
| 11 | ||
| 12 | ||
| 13 | func op_name() -> String: | |
| 14 | return "drop to ground" | |
| 15 | ||
| 16 | ||
| 17 | func gesture_label() -> String: | |
| 18 | return "Drop to ground" | |
| 19 | ||
| 20 | ||
| 21 | func preview_color() -> Color: | |
| 22 | return Color(1.0, 0.85, 0.3, 0.9) # the curve view's amber | |
| 23 | ||
| 24 | ||
| 25 | func 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 | ||
| 35 | func status() -> String: | |
| 36 | return modal().status_text() | |
| 37 | ||
| 38 | ||
| 39 | func 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 | ||
| 49 | func on_deactivate() -> void: | |
| 50 | clear_preview() | |
| 51 | ||
| 52 | ||
| 53 | func handle_key(event: InputEvent) -> bool: | |
| 54 | return modal().handle_key(event) | |
| 55 | ||
| 56 | ||
| 57 | ## The dropped curves, as lines (nothing is written until confirm). | |
| 58 | func 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 | ||
| 75 | func 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 | ||
| 84 | func 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 | ||
| 89 | func 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 | ||
| 98 | func adjust_ids() -> Array: | |
| 99 | return ["step"] | |
| 100 | ||
| 101 | ||
| 102 | func adjust_snapshot() -> Dictionary: | |
| 103 | return {"targets": _targets.duplicate(), "step": step} | |
| 104 | ||
| 105 | ||
| 106 | func 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 | ||
| 112 | func adjust_end() -> void: | |
| 113 | _frozen = false | |
| 114 | clear_preview() |
| … | … | @@ -0,0 +1,224 @@ |
| 1 | extends 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 | ||
| 21 | var fails := 0 | |
| 22 | var checks := 0 | |
| 23 | var map: Node3D | |
| 24 | var persist: Node | |
| 25 | var level: Node | |
| 26 | var CD: GDScript = null | |
| 27 | ||
| 28 | ||
| 29 | func _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 | ||
| 36 | func _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 | ||
| 61 | func _flat(from: Vector3) -> Dictionary: | |
| 62 | return {"position": Vector3(from.x, 0.0, from.z), "normal": Vector3.UP} | |
| 63 | ||
| 64 | ||
| 65 | func _miss(_from: Vector3) -> Dictionary: | |
| 66 | return {} | |
| 67 | ||
| 68 | ||
| 69 | func _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 | ||
| 76 | func _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 | ||
| 83 | func _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 | ||
| 124 | func _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 | ||
| 133 | func _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 | ||
| 138 | func _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) |
| … | … | @@ -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"] | |
| 6 | script = ExtResource("1") |