extends "res://scenes/maps/shapes/param/param_shape.gd"
## A CABLE (#120 L3, docs/node-tree-and-stacks.md §11): Cablerator's cable, as convex
## brushes. Unlike every other type it reads a CURVE (params.curve), not a box, so it
## emits WORLD pieces (world_pieces); the shape's frame does not place them.
##
## BODY: segment k = the convex hull of profile ring k and ring k+1. Ring k lies in the
## plane of CurveMath's frame there (n, b — already turned by the point tilts), so a tilt
## twists a square or a strip. Neighbours share ring k (the same floats): watertight.
## Segment ends walk a fine arc-length sampling and cut where the turn since the last cut
## reaches max_angle or the length reaches max_len.
##
## CAPS (an open curve's ends; user 2026-10-18: "set connection caps for cables"): each
## end picks none / plug / flange / socket, sized by Cap size (width) and Cap length, all
## measured in the cable's radius r at that end, from the end inward along the end tangent
## — the end leaves its wall along the stored normal (F5), so a cap sits on the wall:
## plug a ring 1.6 r, 3 r long (the original connector);
## flange a thin plate 3 r, 0.6 r deep, on the wall + a neck 1.3 r, 2 r long;
## socket a SQUARE box 2.2 r, 2.5 r deep, whatever the cable's profile.
## The old `connectors` switch is kept hidden: on, it plugs every end left at none, so a
## cable recorded before caps folds to the same brushes. RINGS: every ring_every metres,
## a ring 1.3 x the radius, 0.8 x the radius wide.
##
## STRANDS (§14.1, Cablerator's multi-profile): `strands` copies of the profile around
## the axis, strand j's centre spread x r off it at 2πj / strands + twist x s (a rope).
## Each strand is its own hull per segment, strand-major in the output. strands = 1 takes
## no offset and no twist, so a cable recorded before strands folds to the same brushes.
## A closed cable's twist is rounded so strand j meets strand j + m after a whole loop.
## Caps and rings wrap the whole bundle: their radius is r x (1 + spread) when strands > 1.
## SEAMS: neighbours share their ring exactly on a straight run. Where the frame turns,
## the rings are not parallel, the side quads are skew, and dc_hull keeps one of two
## triangles closer than 0.8° — moving a corner by up to ~0.2 mm, ~0.7 mm with twist.
## The fine sampling the segment walk reads: about this spacing (metres) ...
const FINE_STEP := 0.02
## ... within these sample counts.
const FINE_MIN := 16
const FINE_MAX := 4096
const CONNECTOR_SCALE := 1.6
const CONNECTOR_LEN := 3.0
const CAPS := ["none", "plug", "flange", "socket"]
## Each cap's parts: [width x r, length x r, square?], the first part on the wall.
const CAP_PARTS: Dictionary = {
"plug": [[CONNECTOR_SCALE, CONNECTOR_LEN, false]],
"flange": [[3.0, 0.6, false], [1.3, 2.0, false]],
"socket": [[2.2, 2.5, true]],
}
const RING_SCALE := 1.3
const RING_WIDTH := 0.8
func id() -> String:
return "cable"
func params() -> Array:
return [
{"id": "curve", "label": "Curve", "type": "string", "default": "", "hidden": true},
{"id": "profile", "label": "Profile", "type": "enum", "options": ["round", "square", "strip", "custom"], "default": "round",
"tip": "Custom: the closed curve picked as Profile curve"},
# Custom (§14.2): the closed curve whose outline is the cross-section. Set by the
# Shape panel's Profile curve row, not typed.
{"id": "profile_curve", "label": "Profile curve", "type": "string", "default": "", "hidden": true},
{"id": "sides", "label": "Sides", "type": "int", "default": 8, "min": 3, "max": 32, "step": 1,
"tip": "Round profile only"},
{"id": "radius", "label": "Radius", "type": "float", "default": 0.05, "min": 0.005, "max": 2.0,
"step": 0.005, "unit": "m", "tip": "Half the width; times each curve point's radius"},
{"id": "flat", "label": "Flatness", "type": "float", "default": 0.3, "min": 0.05, "max": 1.0,
"step": 0.05, "tip": "Strip only: height / width"},
{"id": "max_angle", "label": "Max bend", "type": "float", "default": 10.0, "min": 1.0, "max": 45.0,
"step": 1.0, "unit": "°", "tip": "A segment turns at most this much"},
{"id": "max_len", "label": "Max segment", "type": "float", "default": 1.0, "min": 0.05, "max": 10.0,
"step": 0.05, "unit": "m"},
{"id": "start_cap", "label": "Start cap", "type": "enum", "options": CAPS, "default": "none",
"tip": "At the first point (open cables only)"},
{"id": "end_cap", "label": "End cap", "type": "enum", "options": CAPS, "default": "none",
"tip": "At the last point (open cables only)"},
{"id": "cap_size", "label": "Cap size", "type": "float", "default": 1.0, "min": 0.25, "max": 4.0,
"step": 0.05, "tip": "x the caps' width"},
{"id": "cap_length", "label": "Cap length", "type": "float", "default": 1.0, "min": 0.25, "max": 4.0,
"step": 0.05, "tip": "x how far the caps reach along the cable"},
# Before per-end caps: on = a plug at each end left at none (kept for old records).
{"id": "connectors", "label": "Connectors", "type": "bool", "default": false, "hidden": true},
{"id": "ring_every", "label": "Rings every", "type": "float", "default": 0.0, "min": 0.0, "max": 20.0,
"step": 0.1, "unit": "m", "tip": "0 = none"},
# STRANDS ARE TABLED (user 2026-10-19: "table strands until we do a performance
# review"): hidden, so no Shape panel row sets them; the fold still reads them.
# A 20-cable bundle at 7 strands is ~3 s per curve edit (§14.8 step 7).
{"id": "strands", "label": "Strands", "type": "int", "default": 1, "min": 1, "max": 12, "step": 1,
"tip": "Copies of the profile around the axis", "hidden": true},
{"id": "spread", "label": "Spread", "type": "float", "default": 1.0, "min": 0.0, "max": 8.0,
"step": 0.05, "tip": "Strands only: each strand's centre, x the radius off the axis", "hidden": true},
{"id": "twist", "label": "Twist", "type": "float", "default": 0.0, "min": -720.0, "max": 720.0,
"step": 5.0, "unit": "°/m", "tip": "Strands only: how far they turn per metre (a rope)", "hidden": true},
]
## Pieces [{planes, role}] in WORLD space for `curve` (a CurveFold state: {points, closed})
## and the resolved params `p`. Empty with no curve or a curve of fewer than 2 points.
## `profile` = the profile curve's state when `p.profile` is custom; with no usable
## outline (no curve, open, too few points) the cable folds round.
##
## CACHED BY CONTENT (hyper-games #127): every fold re-expands every shape, and a cable's
## curve is followed, so one curve edit re-generated EVERY cable in the room — ~250 ms for
## a 20-cable bundle, most of it the 2 cm sampling. The pieces depend on nothing but the
## curve's points + closed, the params and the profile curve's, so those ARE the key —
## not the curve's ver, which two authors' edits at one seq can share. A hash picks the
## slot; a deep compare of the inputs confirms it, so a collision is a miss, not a wrong
## cable. The pieces are SHARED: callers read them and must not write into them.
const CACHE_MAX := 1024
static var _cache: Dictionary = {} # hash -> [inputs, pieces]
func world_pieces(curve: Dictionary, p: Dictionary, profile: Dictionary = {}) -> Array:
var inputs := [curve.get("points", []), bool(curve.get("closed", false)), p,
profile.get("points", []), bool(profile.get("closed", false))]
var h := inputs.hash()
var hit: Variant = _cache.get(h)
if hit != null and (hit as Array)[0] == inputs:
return (hit as Array)[1]
var out := _world_pieces(curve, p, profile)
if _cache.size() >= CACHE_MAX:
_cache.clear() # a room's worth of cables is far below this
_cache[h] = [inputs.duplicate(true), out]
return out
func _world_pieces(curve: Dictionary, p: Dictionary, profile: Dictionary = {}) -> Array:
var out: Array = []
if curve.is_empty() or (curve.get("points", []) as Array).size() < 2:
return out
if String(p["profile"]) == "custom":
var ol := CurveMath.profile_outline(profile)
p = p.duplicate()
if ol.size() >= 3:
p["_outline"] = ol
else:
p["profile"] = "round"
var pts: Array = curve["points"]
var closed := bool(curve.get("closed", false))
var length := CurveMath.length(pts, closed)
if length < 1.0e-6:
return out
var fine_n := clampi(ceili(length / FINE_STEP) + 1, FINE_MIN, FINE_MAX)
var fs := CurveMath.sample(pts, closed, fine_n)
var r0 := float(p["radius"])
# ---- body: walk the fine samples, cut on turn or length ----
var cuts: Array[int] = [0]
var last := 0
for k in range(1, fs.size()):
var turn := rad_to_deg((fs[last]["t"] as Vector3).angle_to(fs[k]["t"]))
var run := float(fs[k]["s"]) - float(fs[last]["s"])
if turn >= float(p["max_angle"]) or run >= float(p["max_len"]) - 1.0e-9:
cuts.append(k)
last = k
if cuts[cuts.size() - 1] != fs.size() - 1 and not closed:
cuts.append(fs.size() - 1)
var strands := int(p["strands"])
var spread := float(p["spread"]) if strands > 1 else 0.0
var twist := deg_to_rad(float(p["twist"])) if strands > 1 else 0.0
if closed and strands > 1:
# Whole steps of 2π / strands over the loop, so strand j meets strand j + m.
var step := TAU / float(strands)
twist = roundf(twist * length / step) * step / length
for j in strands:
var rings: Array = []
for k: int in cuts:
var f: Dictionary = fs[k]
var rr := r0 * float(f["radius"])
var c: Vector3 = f["p"]
if strands > 1:
var a := TAU * float(j) / float(strands) + twist * float(f["s"])
c += ((f["n"] as Vector3) * cos(a) + (f["b"] as Vector3) * sin(a)) * spread * rr
rings.append(_ring(c, f["n"], f["b"], rr, p))
if closed:
if strands > 1 and absf(twist) > 1.0e-12:
# The closing ring: ring 0's frame, turned by the whole loop's twist.
var f0: Dictionary = fs[0]
var r00 := r0 * float(f0["radius"])
var a0 := TAU * float(j) / float(strands) + twist * length
rings.append(_ring((f0["p"] as Vector3) + ((f0["n"] as Vector3) * cos(a0)
+ (f0["b"] as Vector3) * sin(a0)) * spread * r00, f0["n"], f0["b"], r00, p))
else:
rings.append(rings[0]) # the closing segment ends ON ring 0
for i in range(1, rings.size()):
var pl := FaceKit.hull_sections(rings[i - 1], rings[i])
if not pl.is_empty():
out.append({"planes": pl, "role": "cable"})
# Caps and rings wrap every strand.
var wrap := 1.0 + spread if strands > 1 else 1.0
# ---- caps: an open curve's two ends ----
if not closed:
var caps := [String(p["start_cap"]), String(p["end_cap"])]
for i in 2:
if caps[i] == "none" and bool(p["connectors"]):
caps[i] = "plug"
var e: Dictionary = fs[0] if i == 0 else fs[fs.size() - 1]
var inward: Vector3 = e["t"] if i == 0 else -(e["t"] as Vector3)
out.append_array(_cap(caps[i], e, inward, r0 * float(e["radius"]) * wrap, p))
# ---- rings: every ring_every metres, the ends excluded ----
var every := float(p["ring_every"])
if every > 1.0e-6:
var steps := fs.size() if closed else fs.size() - 1
var s := every
while s < length - 1.0e-6:
var f2: Dictionary = fs[clampi(roundi(s / length * steps), 0, fs.size() - 1)]
var rr2 := r0 * float(f2["radius"]) * wrap
var half: Vector3 = (f2["t"] as Vector3) * rr2 * RING_WIDTH * 0.5
var c: Vector3 = f2["p"]
var pl3 := FaceKit.hull_sections(_ring(c - half, f2["n"], f2["b"], rr2 * RING_SCALE, p),
_ring(c + half, f2["n"], f2["b"], rr2 * RING_SCALE, p))
if not pl3.is_empty():
out.append({"planes": pl3, "role": "ring"})
s += every
return out
## One end's cap pieces (role "connector"): each part the hull of two rings, from the end
## point `e` inward. `rr` = the cable's radius there.
func _cap(kind: String, e: Dictionary, inward: Vector3, rr: float, p: Dictionary) -> Array:
var out: Array = []
var w := float(p["cap_size"])
var l := float(p["cap_length"])
for part: Array in (CAP_PARTS.get(kind, []) as Array):
var pp: Dictionary = p
if bool(part[2]):
pp = p.duplicate()
pp["profile"] = "square"
var c0: Vector3 = e["p"]
var c1 := c0 + inward * rr * float(part[1]) * l
var pl := FaceKit.hull_sections(_ring(c0, e["n"], e["b"], rr * float(part[0]) * w, pp),
_ring(c1, e["n"], e["b"], rr * float(part[0]) * w, pp))
if not pl.is_empty():
out.append({"planes": pl, "role": "connector"})
return out
## One profile ring at `c` in the plane (u, v), of half-width `r`.
func _ring(c: Vector3, u: Vector3, v: Vector3, r: float, p: Dictionary) -> PackedVector3Array:
var out := PackedVector3Array()
match String(p["profile"]):
"square":
for q: Vector2 in [Vector2(1, 1), Vector2(-1, 1), Vector2(-1, -1), Vector2(1, -1)]:
out.append(c + u * (q.x * r) + v * (q.y * r))
"strip":
# Width along v (sideways: the binormal), height along u (the normal).
var h := r * float(p["flat"])
for q2: Vector2 in [Vector2(1, 1), Vector2(-1, 1), Vector2(-1, -1), Vector2(1, -1)]:
out.append(c + u * (q2.x * h) + v * (q2.y * r))
"custom":
# The outline world_pieces put in (normalised: its farthest vertex is 1).
for q3: Vector2 in (p["_outline"] as PackedVector2Array):
out.append(c + u * (q3.x * r) + v * (q3.y * r))
_:
var n := int(p["sides"])
for k in n:
var a := TAU * float(k) / float(n)
out.append(c + (u * cos(a) + v * sin(a)) * r)
return out