Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-helix/src/layout.rs

//! The **helix layout** — pure, deterministic, dependency-free.
//!
//! Maps each node to a 3D point on a **helix that advances along the time axis**:
//! `t` → distance ALONG the central (time) axis; `system` → the **angular lane**;
//! `radius`/`turns` configurable. A coil can be **wound** onto its central pin
//! (`wind`), placed anywhere in the scene (`origin`/`axis`), and many coils coexist
//! ([`layout_scene`]). Edges are **rounded Catmull-Rom splines** that follow the
//! helix; with `tension > 0` they become **elastic rubberbands** that wobble and
//! settle (the easing curve is injected as a `fn`, so this core stays dep-free while
//! the view feeds it `facett_core::effects::easing::elastic`).
//!
//! Axis convention: a coil's **local** time axis is X; its cross-section is the
//! `(y, z)` plane, so a node is `[along, r·cos θ, r·sin θ]` before the coil's frame
//! maps it into world space.

use crate::model::{Coil, InterEdge, NodeId, PlanDag, Scene};
use serde::{Deserialize, Serialize};
use std::f32::consts::TAU;

// ─── tiny 3-vector helpers (no external math dep) ───────────────────────────────
type V3 = [f32; 3];
fn add(a: V3, b: V3) -> V3 {
    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn vsub(a: V3, b: V3) -> V3 {
    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn scale(a: V3, s: f32) -> V3 {
    [a[0] * s, a[1] * s, a[2] * s]
}
fn dot(a: V3, b: V3) -> f32 {
    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: V3, b: V3) -> V3 {
    [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn norm(a: V3) -> f32 {
    dot(a, a).sqrt()
}
fn normalize(a: V3) -> V3 {
    let n = norm(a);
    if n < 1e-6 { [1.0, 0.0, 0.0] } else { scale(a, 1.0 / n) }
}
fn lerp3(a: V3, b: V3, t: f32) -> V3 {
    add(a, scale(vsub(b, a), t))
}

/// Helix shape parameters (radius / winding / axis length). All configurable.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct HelixParams {
    /// Distance of a strand from the central time axis (the helix radius).
    pub radius: f32,
    /// Total winding turns across the **full** `t`-span (how tight the spiral is).
    pub turns: f32,
    /// Total length **along the time axis** spanned by the full `t`-range (unwound).
    pub axis_len: f32,
    /// Extra per-lane radial spread so different systems sit on slightly different
    /// shells (0 = a single shared cylinder).
    pub lane_spread: f32,
}

impl Default for HelixParams {
    fn default() -> Self {
        // A FAT tube, not a thin spring: radius comparable to a good fraction of the
        // axis length so the coil reads as a 3D tunnel you look into (Doctor-Who
        // time-vortex), with the strands clearly *wrapped around* a cylinder rather
        // than stretched out like a wire. `lane_spread` scales with the bigger radius
        // so multi-system shells stay visually separated on the wider barrel.
        Self { radius: 2.6, turns: 3.0, axis_len: 6.0, lane_spread: 0.22 }
    }
}

/// A coil's world frame: where its time axis starts (`origin`), the orthonormal basis
/// (`along`/`u`/`v`), and the **wound** axis length (shrinks toward 0 as `wind` → 1).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Frame {
    pub origin: V3,
    pub along: V3,
    pub u: V3,
    pub v: V3,
    pub eff_axis_len: f32,
}

impl Frame {
    /// The identity frame: origin, time axis = +X, unwound.
    #[must_use]
    pub fn identity(p: &HelixParams) -> Frame {
        Frame { origin: [0.0; 3], along: [1.0, 0.0, 0.0], u: [0.0, 1.0, 0.0], v: [0.0, 0.0, 1.0], eff_axis_len: p.axis_len }
    }
    /// Build a coil's frame: an orthonormal basis around its `axis`, origin at its
    /// `origin`, and the wound axis length `axis_len·(1 − wind)`.
    #[must_use]
    pub fn for_coil(coil: &Coil, p: &HelixParams) -> Frame {
        let along = normalize(coil.axis);
        // Choose an up vector not parallel to `along`, then Gram-Schmidt a basis.
        let up = if along[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [1.0, 0.0, 0.0] };
        let u = normalize(cross(up, along));
        let v = cross(along, u);
        Frame { origin: coil.origin, along, u, v, eff_axis_len: p.axis_len * (1.0 - coil.wind.clamp(0.0, 1.0)) }
    }
    /// Map a coil-local point `[along, y, z]` into world space.
    #[must_use]
    pub fn to_world(&self, local: V3) -> V3 {
        add(self.origin, add(scale(self.along, local[0]), add(scale(self.u, local[1]), scale(self.v, local[2]))))
    }
}

/// One laid-out node: its **world** point plus the scalar helix coordinates (kept so a
/// headless test can assert "at the right along-axis `t` and angular lane" as DATA).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Placed {
    /// The world 3D point.
    pub point: V3,
    /// Normalised time `u ∈ [0,1]` across the coil's `t`-span.
    pub u: f32,
    /// Distance along the (wound) time axis.
    pub along: f32,
    /// Winding angle (radians) = `u·turns·TAU + lane_offset`.
    pub theta: f32,
    /// The angular lane index.
    pub lane: usize,
}

/// A laid-out coil: one [`Placed`] per node, plus the [`Frame`]/params needed to
/// regenerate edge splines per frame (for the elastic wobble).
#[derive(Clone, Debug, PartialEq)]
pub struct HelixLayout {
    pub placed: Vec<Placed>,
    pub lanes: usize,
    pub frame: Frame,
    pub params: HelixParams,
}

impl HelixLayout {
    /// The world point for a node id (`None` if out of range).
    #[must_use]
    pub fn point(&self, id: NodeId) -> Option<V3> {
        self.placed.get(id.index()).map(|p| p.point)
    }

    /// Helix-following **control points** of an intra-coil edge (`a → b`), in world
    /// space: time + lane ease together while the winding term is re-evaluated at every
    /// sample, so the edge spirals along the tube. Run through [`elastic_spline`] to
    /// paint it.
    #[must_use]
    pub fn intra_controls(&self, a: NodeId, b: NodeId, segments: usize) -> Vec<V3> {
        let (pa, pb) = match (self.placed.get(a.index()), self.placed.get(b.index())) {
            (Some(pa), Some(pb)) => (*pa, *pb),
            _ => return Vec::new(),
        };
        let segments = segments.max(2);
        let lane_a = lane_offset(pa.lane, self.lanes);
        let lane_b = lane_offset(pb.lane, self.lanes);
        let r_a = self.params.radius + pa.lane as f32 * self.params.lane_spread;
        let r_b = self.params.radius + pb.lane as f32 * self.params.lane_spread;
        (0..segments)
            .map(|s| {
                let frac = s as f32 / (segments - 1) as f32;
                let u = pa.u + (pb.u - pa.u) * frac;
                let along = u * self.frame.eff_axis_len;
                let theta = u * self.params.turns * TAU + lane_a + (lane_b - lane_a) * frac;
                let r = r_a + (r_b - r_a) * frac;
                self.frame.to_world([along, r * theta.cos(), r * theta.sin()])
            })
            .collect()
    }

    /// The **outer tube radius** — the shell that encloses every lane's strand
    /// (`radius + (lanes-1)·lane_spread`). Cross-section [`Self::rings`] sit here so
    /// the drawn cylinder contains all the wound strands.
    #[must_use]
    pub fn outer_radius(&self) -> f32 {
        self.params.radius + (self.lanes.saturating_sub(1)) as f32 * self.params.lane_spread
    }

    /// The **central time axis** as a world-space polyline: `ticks` points marching
    /// down the middle of the tube (`radius = 0`) from `t`-start to `t`-end. This is
    /// the **timeline through the core of the axis** — the strands wind around it, so
    /// drawing it gives the tunnel a visible spine the view can tick/label by time.
    #[must_use]
    pub fn time_axis(&self, ticks: usize) -> Vec<V3> {
        let ticks = ticks.max(2);
        let len = self.frame.eff_axis_len;
        (0..ticks)
            .map(|i| {
                let along = i as f32 / (ticks - 1) as f32 * len;
                self.frame.to_world([along, 0.0, 0.0])
            })
            .collect()
    }

    /// **Cross-section rings** of the tube: `count` circles evenly spaced along the
    /// time axis, each a closed loop of `segments` world points at the
    /// [`Self::outer_radius`]. Drawing these is what turns the wound strands into a
    /// legible **cylinder / tunnel** — the ribs of the tube. Each returned ring is
    /// `segments + 1` points (first repeated) so a line strip closes the circle.
    #[must_use]
    pub fn rings(&self, count: usize, segments: usize) -> Vec<Vec<V3>> {
        let count = count.max(1);
        let segments = segments.max(3);
        let len = self.frame.eff_axis_len;
        let r = self.outer_radius();
        (0..count)
            .map(|c| {
                // Spread rings across the interior of the span (avoid the very ends).
                let along = if count == 1 { len * 0.5 } else { c as f32 / (count - 1) as f32 * len };
                (0..=segments)
                    .map(|s| {
                        let theta = s as f32 / segments as f32 * TAU;
                        self.frame.to_world([along, r * theta.cos(), r * theta.sin()])
                    })
                    .collect()
            })
            .collect()
    }
}

/// The full multi-helix scene laid out: one [`HelixLayout`] per coil.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SceneLayout {
    pub coils: Vec<HelixLayout>,
}

impl SceneLayout {
    /// The bridge **control points** of an inter-helix link (a soft arc between the two
    /// coils' world points). `None` if a ref is out of range.
    #[must_use]
    pub fn link_controls(&self, link: &InterEdge, segments: usize, bow: f32) -> Option<Vec<V3>> {
        let a = self.coils.get(link.from.helix as usize)?.point(link.from.node)?;
        let b = self.coils.get(link.to.helix as usize)?.point(link.to.node)?;
        Some(bridge_controls(a, b, bow, segments))
    }
}

/// The angular offset (radians) of lane `lane` out of `lanes` — evenly spaced strands.
#[must_use]
pub fn lane_offset(lane: usize, lanes: usize) -> f32 {
    if lanes <= 1 { 0.0 } else { lane as f32 / lanes as f32 * TAU }
}

/// Place a single node from its scalar inputs in a coil [`Frame`] — the pure kernel.
#[must_use]
pub fn place_in(u: f32, lane: usize, lanes: usize, p: &HelixParams, frame: &Frame) -> Placed {
    let along = u * frame.eff_axis_len;
    let theta = u * p.turns * TAU + lane_offset(lane, lanes);
    let r = p.radius + lane as f32 * p.lane_spread;
    Placed { point: frame.to_world([along, r * theta.cos(), r * theta.sin()]), u, along, theta, lane }
}

/// Place a node in the **identity** frame (origin, +X, unwound) — handy for pure tests.
#[must_use]
pub fn place(u: f32, lane: usize, lanes: usize, p: &HelixParams) -> Placed {
    place_in(u, lane, lanes, p, &Frame::identity(p))
}

/// Lay out one [`Coil`] (its `origin`/`axis`/`wind` applied).
#[must_use]
pub fn layout_coil(coil: &Coil, p: &HelixParams) -> HelixLayout {
    let systems = coil.dag.systems();
    let lanes = systems.len().max(1);
    let frame = Frame::for_coil(coil, p);
    let (t_min, t_max) = coil.dag.t_span();
    let span = (t_max - t_min) as f32;
    let placed = coil
        .dag
        .nodes()
        .iter()
        .map(|n| {
            let u = if span.abs() < f32::EPSILON { 0.0 } else { ((n.t - t_min) as f32 / span).clamp(0.0, 1.0) };
            let lane = systems.iter().position(|s| *s == n.system).unwrap_or(0);
            place_in(u, lane, lanes, p, &frame)
        })
        .collect();
    HelixLayout { placed, lanes, frame, params: *p }
}

/// Lay out a single DAG as one identity-placed coil (the common plan-DAG case).
#[must_use]
pub fn layout(dag: &PlanDag, p: &HelixParams) -> HelixLayout {
    layout_coil(&Coil::new(dag.clone()), p)
}

/// Lay out a whole [`Scene`] — every coil placed in world space.
#[must_use]
pub fn layout_scene(scene: &Scene, p: &HelixParams) -> SceneLayout {
    SceneLayout { coils: scene.coils.iter().map(|c| layout_coil(c, p)).collect() }
}

/// Straight-arc **control points** bridging two world points with a perpendicular
/// `bow` (so an inter-helix link curves out instead of cutting straight across).
#[must_use]
pub fn bridge_controls(a: V3, b: V3, bow: f32, segments: usize) -> Vec<V3> {
    let segments = segments.max(2);
    let chord = vsub(b, a);
    let perp = transverse(chord);
    (0..segments)
        .map(|s| {
            let frac = s as f32 / (segments - 1) as f32;
            let base = lerp3(a, b, frac);
            add(base, scale(perp, bow * (frac * std::f32::consts::PI).sin()))
        })
        .collect()
}

/// A unit vector perpendicular to `chord` (in a stable plane), for the bow / wobble.
fn transverse(chord: V3) -> V3 {
    let up = if chord[1].abs() < norm(chord) * 0.95 { [0.0, 1.0, 0.0] } else { [1.0, 0.0, 0.0] };
    let p = cross(chord, up);
    if norm(p) < 1e-6 { [0.0, 0.0, 1.0] } else { normalize(p) }
}

/// A uniform **Catmull-Rom** point on `p1..p2` (neighbours `p0`,`p3`), `t ∈ [0,1]`.
#[must_use]
pub fn catmull_rom(p0: V3, p1: V3, p2: V3, p3: V3, t: f32) -> V3 {
    let t2 = t * t;
    let t3 = t2 * t;
    let mut out = [0.0f32; 3];
    for c in 0..3 {
        out[c] = 0.5
            * ((2.0 * p1[c])
                + (-p0[c] + p2[c]) * t
                + (2.0 * p0[c] - 5.0 * p1[c] + 4.0 * p2[c] - p3[c]) * t2
                + (-p0[c] + 3.0 * p1[c] - 3.0 * p2[c] + p3[c]) * t3);
    }
    out
}

/// Subdivide a control polyline into a smooth **Catmull-Rom** polyline (`sub` samples
/// per segment), passing through every control point.
#[must_use]
pub fn catmull_rom_chain(ctrl: &[V3], sub: usize) -> Vec<V3> {
    if ctrl.len() < 2 {
        return ctrl.to_vec();
    }
    let sub = sub.max(1);
    let n = ctrl.len();
    let at = |i: isize| ctrl[i.clamp(0, n as isize - 1) as usize];
    let mut out = Vec::with_capacity((n - 1) * sub + 1);
    out.push(ctrl[0]);
    for i in 0..n - 1 {
        let p0 = at(i as isize - 1);
        let p1 = ctrl[i];
        let p2 = ctrl[i + 1];
        let p3 = at(i as isize + 2);
        for s in 1..=sub {
            out.push(catmull_rom(p0, p1, p2, p3, s as f32 / sub as f32));
        }
    }
    out
}

/// **The elastic rubberband spline.** Displace the interior control points transverse
/// to the chord by a bell-shaped, `tension`-scaled amount that the injected easing
/// `ease` drives over `phase` — a springy overshoot that **settles** (with
/// `ease = elastic`, `ease(1) = 1` ⇒ zero wobble at rest; mid-phase oscillates around
/// rest). Endpoints stay pinned. Deterministic in `phase` (FC-7). Pass `tension = 0`
/// for the plain rounded (rigid) spline.
#[must_use]
pub fn elastic_spline(ctrl: &[V3], tension: f32, phase: f32, ease: impl Fn(f32) -> f32, sub: usize) -> Vec<V3> {
    if ctrl.len() < 3 || tension.abs() < 1e-6 {
        return catmull_rom_chain(ctrl, sub);
    }
    let n = ctrl.len();
    let chord = vsub(ctrl[n - 1], ctrl[0]);
    let perp = transverse(chord);
    // Settle envelope: elastic(phase) overshoots/undershoots 1 then rests at 1.
    let envelope = ease(phase.clamp(0.0, 1.0)) - 1.0;
    let mut displaced = Vec::with_capacity(n);
    for (i, &c) in ctrl.iter().enumerate() {
        if i == 0 || i == n - 1 {
            displaced.push(c);
            continue;
        }
        let frac = i as f32 / (n - 1) as f32;
        let amp = tension * (frac * std::f32::consts::PI).sin() * envelope;
        displaced.push(add(c, scale(perp, amp)));
    }
    catmull_rom_chain(&displaced, sub)
}

/// The plain rounded **edge spline** between two placed nodes (identity-frame helix
/// follow + Catmull-Rom). Used by tests / the rigid default.
#[must_use]
pub fn edge_spline(a: &Placed, b: &Placed, lanes: usize, p: &HelixParams) -> Vec<V3> {
    // Re-derive identity-frame controls between the two placed nodes.
    let lane_a = lane_offset(a.lane, lanes);
    let lane_b = lane_offset(b.lane, lanes);
    let r_a = p.radius + a.lane as f32 * p.lane_spread;
    let r_b = p.radius + b.lane as f32 * p.lane_spread;
    let ctrl: Vec<V3> = (0..6)
        .map(|s| {
            let frac = s as f32 / 5.0;
            let u = a.u + (b.u - a.u) * frac;
            let along = u * p.axis_len;
            let theta = u * p.turns * TAU + lane_a + (lane_b - lane_a) * frac;
            let r = r_a + (r_b - r_a) * frac;
            [along, r * theta.cos(), r * theta.sin()]
        })
        .collect();
    catmull_rom_chain(&ctrl, 6)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{Coil, HelixRef, Kind, PlanDag, Scene};

    fn radius_of(pt: V3) -> f32 {
        (pt[1] * pt[1] + pt[2] * pt[2]).sqrt()
    }

    fn sample_dag() -> PlanDag {
        let mut dag = PlanDag::new();
        for i in 0..5 {
            let sys = if i % 2 == 0 { "alpha" } else { "beta" };
            dag.add_idea(format!("n{i}"), sys, i as f64, Kind::Task);
        }
        dag
    }

    #[test]
    fn along_axis_increases_with_t_helix_winds_forward() {
        let dag = sample_dag();
        let p = HelixParams::default();
        let lay = layout(&dag, &p);
        for w in lay.placed.windows(2) {
            assert!(w[1].along > w[0].along, "time axis advances");
            assert!(w[1].point[0] > w[0].point[0], "the world X (time) coordinate advances");
        }
        let first = lay.placed.first().unwrap();
        let last = lay.placed.last().unwrap();
        let wind_first = first.theta - lane_offset(first.lane, lay.lanes);
        let wind_last = last.theta - lane_offset(last.lane, lay.lanes);
        assert!((wind_last - wind_first - p.turns * TAU).abs() < 1e-3, "winds forward by exactly `turns`");
    }

    #[test]
    fn system_picks_the_angular_lane() {
        let dag = sample_dag();
        let lay = layout(&dag, &HelixParams::default());
        assert_eq!(lay.lanes, 2);
        assert_eq!(lay.placed[0].lane, 0); // alpha
        assert_eq!(lay.placed[1].lane, 1); // beta
        assert!((lane_offset(1, 2) - lane_offset(0, 2) - TAU / 2.0).abs() < 1e-5);
    }

    #[test]
    fn layout_is_deterministic() {
        let dag = sample_dag();
        let p = HelixParams::default();
        assert_eq!(layout(&dag, &p), layout(&dag, &p));
    }

    #[test]
    fn winding_reels_the_coil_onto_the_pin() {
        let p = HelixParams::default();
        let dag = sample_dag();
        let out = layout_coil(&Coil::new(dag.clone()).wound(0.0), &p);
        let reeled = layout_coil(&Coil::new(dag).wound(1.0), &p);
        // Unwound: the last node is a full axis_len down the time axis.
        let spread_out = out.placed.last().unwrap().along - out.placed.first().unwrap().along;
        // Reeled: the along-axis spread collapses toward the pin (origin end)…
        let spread_reeled = reeled.placed.last().unwrap().along - reeled.placed.first().unwrap().along;
        assert!(spread_out > 5.0, "unspooled spans the time axis");
        assert!(spread_reeled.abs() < 1e-4, "wound onto the pin: t-spread collapses");
        // …but the topology (winding angle per node) is preserved.
        for (a, b) in out.placed.iter().zip(&reeled.placed) {
            assert!((a.theta - b.theta).abs() < 1e-5, "winding angle unchanged by reeling");
        }
    }

    #[test]
    fn edge_spline_follows_the_helix_not_a_straight_chord() {
        let p = HelixParams { turns: 0.5, lane_spread: 0.0, ..HelixParams::default() };
        let a = place(0.0, 0, 1, &p);
        let b = place(1.0, 0, 1, &p);
        let spline = edge_spline(&a, &b, 1, &p);
        assert!(spline.len() > 6, "subdivided into a rounded polyline");
        assert!((spline.first().unwrap()[0] - a.point[0]).abs() < 1e-4);
        assert!((spline.last().unwrap()[0] - b.point[0]).abs() < 1e-4);
        let mid = spline[spline.len() / 2];
        assert!(radius_of(mid) > p.radius * 0.6, "edge bows along the tube, not through the axis");
        for w in spline.windows(2) {
            assert!(w[1][0] >= w[0][0] - 1e-4, "spline advances along the time axis");
        }
    }

    #[test]
    fn catmull_rom_passes_through_control_points() {
        let ctrl = [[0.0, 0.0, 0.0], [1.0, 2.0, 0.0], [2.0, -1.0, 0.0], [3.0, 0.5, 0.0]];
        let chain = catmull_rom_chain(&ctrl, 8);
        assert_eq!(chain.first().unwrap(), &ctrl[0]);
        assert_eq!(chain.last().unwrap(), &ctrl[3]);
        for cp in &ctrl {
            assert!(chain.iter().any(|q| (q[0] - cp[0]).abs() < 1e-4 && (q[1] - cp[1]).abs() < 1e-4));
        }
    }

    #[test]
    fn elastic_edges_wobble_then_settle() {
        // A decaying-sine ease so the envelope is non-zero mid-phase and exactly 0 at
        // rest (phase = 1) — the rubberband twang. (The view feeds facett-core's
        // `easing::elastic`, which has the same f(1)=1 property.)
        let ease = |t: f32| {
            if t == 0.0 || t == 1.0 {
                t
            } else {
                2.0_f32.powf(-10.0 * t) * ((t * 10.0 - 0.75) * (std::f32::consts::TAU / 3.0)).sin() + 1.0
            }
        };
        let ctrl: Vec<V3> = (0..6).map(|i| [i as f32, 0.0, 0.0]).collect();
        let rigid = elastic_spline(&ctrl, 0.0, 0.3, ease, 6);
        let sprung = elastic_spline(&ctrl, 1.0, 0.3, ease, 6);
        let settled = elastic_spline(&ctrl, 1.0, 1.0, ease, 6);
        // Mid-phase with tension deviates transversely from the rigid curve…
        let mid = sprung.len() / 2;
        let dev: f32 = (sprung[mid][1] - rigid[mid][1]).abs() + (sprung[mid][2] - rigid[mid][2]).abs();
        assert!(dev > 1e-3, "tension makes the edge bow elastically (dev {dev})");
        // …and at rest (phase 1) it settles back onto the rigid spline.
        for (a, b) in settled.iter().zip(&rigid) {
            assert!((a[1] - b[1]).abs() < 1e-4 && (a[2] - b[2]).abs() < 1e-4, "settles to rest");
        }
        // Endpoints are always pinned (to within Catmull-Rom float residual).
        let close = |a: V3, b: V3| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
        assert!(close(*sprung.first().unwrap(), *rigid.first().unwrap()), "start pinned");
        assert!(close(*sprung.last().unwrap(), *rigid.last().unwrap()), "end pinned");
    }

    #[test]
    fn multi_helix_scene_lays_out_and_bridges_coils() {
        // Two coils offset in space, each one node; an inter-helix link between them.
        let mut a = PlanDag::new();
        let a0 = a.add_idea("a", "crateA", 0.0, Kind::Crate);
        a.add_idea("a1", "crateA", 1.0, Kind::Crate);
        let mut b = PlanDag::new();
        let b0 = b.add_idea("b", "crateB", 0.0, Kind::Crate);
        b.add_idea("b1", "crateB", 1.0, Kind::Crate);

        let mut scene = Scene::new();
        let ha = scene.add_coil(Coil::new(a).at([0.0, 0.0, 0.0]));
        let hb = scene.add_coil(Coil::new(b).at([6.0, 0.0, 0.0]));
        scene.add_link(HelixRef { helix: ha, node: a0 }, HelixRef { helix: hb, node: b0 }, 1);

        let p = HelixParams::default();
        let lay = layout_scene(&scene, &p);
        assert_eq!(lay.coils.len(), 2);
        // The two coils are placed at distinct origins (≈ 6 apart on X).
        let pa = lay.coils[0].point(a0).unwrap();
        let pb = lay.coils[1].point(b0).unwrap();
        assert!((pb[0] - pa[0]).abs() > 4.0, "coils are spatially separated");
        // The inter-helix bridge connects the two world points and bows out.
        let bridge = lay.link_controls(&scene.links[0], 6, 1.0).unwrap();
        assert!((vsub(bridge[0], pa)).iter().map(|x| x.abs()).sum::<f32>() < 1e-4, "bridge starts at coil A's node");
        assert!((vsub(*bridge.last().unwrap(), pb)).iter().map(|x| x.abs()).sum::<f32>() < 1e-4, "bridge ends at coil B's node");
        let chord_mid = lerp3(pa, pb, 0.5);
        assert!(norm(vsub(bridge[bridge.len() / 2], chord_mid)) > 0.1, "the bridge bows, not a straight chord");
    }

    #[test]
    fn time_axis_runs_down_the_center_of_the_tube() {
        let dag = sample_dag();
        let p = HelixParams::default();
        let lay = layout(&dag, &p);
        let spine = lay.time_axis(8);
        assert_eq!(spine.len(), 8);
        // Every spine point sits ON the axis (radius ≈ 0), unlike the wound strands.
        for pt in &spine {
            assert!(radius_of(*pt) < 1e-4, "spine is on the central axis, r≈0: {pt:?}");
        }
        // It advances monotonically along the time (X) axis from 0 to eff_axis_len.
        for w in spine.windows(2) {
            assert!(w[1][0] > w[0][0], "spine advances along time");
        }
        assert!((spine.first().unwrap()[0]).abs() < 1e-4, "starts at t-origin");
        assert!((spine.last().unwrap()[0] - lay.frame.eff_axis_len).abs() < 1e-4, "ends at axis end");
    }

    #[test]
    fn rings_wrap_the_tube_at_the_outer_radius() {
        let dag = sample_dag();
        let p = HelixParams::default();
        let lay = layout(&dag, &p);
        let rings = lay.rings(5, 24);
        assert_eq!(rings.len(), 5);
        let outer = lay.outer_radius();
        assert!(outer > p.radius - 1e-6, "outer radius encloses every lane's shell");
        for ring in &rings {
            assert_eq!(ring.len(), 25, "closed loop: segments + 1");
            // Every ring point is at the tube's outer radius…
            for pt in ring {
                assert!((radius_of(*pt) - outer).abs() < 1e-3, "ring rides the tube surface");
            }
            // …and the loop closes (first == last).
            let (a, b) = (ring.first().unwrap(), ring.last().unwrap());
            assert!((a[0] - b[0]).abs() < 1e-4 && (a[1] - b[1]).abs() < 1e-4 && (a[2] - b[2]).abs() < 1e-4);
        }
        // Successive rings step forward along the time axis.
        for w in rings.windows(2) {
            assert!(w[1][0][0] > w[0][0][0], "rings march down the tube");
        }
    }
}