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");
}
}
}