OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / shapes / param / param_cable.gd

259 lines · gd ref:
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