facett-helix/src/lib.rs
//! **facett-helix** — the **TIME-HELIX DAG view**. A plan / dependency DAG rendered
//! as a **helix that advances ALONG a time axis**: each node is a plan item (idea /
//! task / test / crate) tied to a `system` (its angular lane / strand); the DAG winds
//! *forward through time* rather than spinning in place. Edges are **soft rounded
//! rubberband splines** (Catmull-Rom, elastic settle); coils can be **wound onto a
//! pin** (`wind`); many coils coexist in one scene with **inter-helix links**; an
//! **animated traverse** flies a time-cursor along the axis. GPU-rendered with the
//! emissive **bloom** lane reused from `facett-graph3d`.
//!
//! ## Portability contract (the reason this crate exists)
//! [`model`] + [`layout`] depend on **nothing but `serde` + `std`** — no egui, no
//! facett, no nornir/modgunn. They are the seam that `git mv`s into **ordning**. Only
//! [`HelixView`] (this file) binds to facett's [`Facet`]/egui/`facett-graph3d`. Hosts
//! (nornir's funnel, modgunn) just *populate* a [`model::Scene`] through the
//! [`model::PlanDag::add_idea`] API; facett stays generic.
//!
//! ## FC contract
//! - **Serializable state** ([`state_json`](HelixView::state_json)): every node's
//! helix coordinates (along-axis `t`, angular lane, system) + the wind / level /
//! cursor / tension — the picture asserted as DATA.
//! - **[`Msg`] + [`update`](HelixView::update)**: the robot-/CLI-addressable surface
//! (seek the time cursor, wind a coil, filter dep levels, select) — FC-3.
//! - **Injected clock** ([`set_clock`](HelixView::set_clock) / [`Msg::Tick`]): the
//! traverse + the elastic wobble are pure functions of it (FC-7, determinism).
//! - **AccessKit**: each node rides into the tree as a queryable Button (FC-4).
pub mod layout;
pub mod model;
pub mod stability;
/// The real 3D **tube/pipe** field ([`TubeField`]) — data smeared on the cylinder
/// walls + data along the centre axis. Dependency-free (serde only), like [`model`].
pub mod tube;
/// Builds a [`facett_map3d::Mesh3`] from a [`tube::TubeField`] (cylinder wall +
/// centre-axis core tube).
pub mod mesh;
/// The [`TubeView`](tubeview::TubeView) Facet — orbits + lights + paints the tube mesh.
pub mod tubeview;
pub use tube::TubeField;
pub use tubeview::{Face, TubeMsg, TubeView};
pub use layout::{HelixLayout, HelixParams, SceneLayout};
// Mental-map stability for dynamic graphs (#4): anchor constraints bound a node's
// per-step travel so structural change is obvious without teleporting; the time axis
// maps to helix depth so the user flies through history. See [`stability`].
pub use stability::{anchor_sequence, snapshot_from_dag, stability, trajectory_world, Snapshot, Stability, Target};
pub use model::{Coil, Dep, Edge, Event, EventKind, HelixRef, InterEdge, Kind, Node, NodeId, PlanDag, Scene, Status};
use egui::{Align2, Color32, CornerRadius, FontId, Pos2, Sense, Stroke, vec2};
use facett_core::effects::easing;
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::{Facet, FacetCaps, a11y, theme};
use serde::{Deserialize, Serialize};
/// How fast a full traverse (cursor 0 → 1) runs, in seconds (live mode).
const TRAVERSE_PERIOD: f64 = 8.0;
/// Spin rate (rad/s) for the secondary tube rotation.
const SPIN_RATE: f32 = 0.35;
/// Elastic wobble cycles per second.
const WOBBLE_RATE: f64 = 0.6;
/// Horizontal compression of the world time-axis when projected to screen.
const AXIS_COMPRESS: f32 = 0.62;
/// A robot-/CLI-addressable control message. Applied by [`HelixView::update`].
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
/// Advance the injected clock by `dt` seconds (drives traverse + wobble).
Tick(f64),
/// Pin the injected clock to `seconds`.
SetClock(f64),
/// Seek the time cursor to `u ∈ [0,1]` along the axis (also stops auto-traverse).
Seek(f32),
/// Start / stop the animated traverse along the time axis.
ToggleTraverse(bool),
/// Set the tube-spin yaw (radians).
SetYaw(f32),
/// Start / stop the secondary tube spin.
ToggleSpin(bool),
/// Wind coil `helix` onto its pin (`1.0`) or splay it out (`0.0`).
SetWind(u32, f32),
/// Show only dependency edges at or below this depth (1 = direct only).
SetLevel(u8),
/// Elastic-edge tension (rubberband wobble amplitude).
SetTension(f32),
/// Select a node (scene-global ref).
Select(HelixRef),
/// Clear the selection.
ClearSelection,
}
/// Side work as data (FC-8). The helix does **no** I/O — every input is folded into
/// the [`Model`](facett_core::Elm::Model) synchronously by [`update`](HelixView::update),
/// so this is intentionally **uninhabited**: the honest statement that there is nothing
/// for a host to run after a `Msg`.
#[derive(Debug)]
pub enum Effect {}
/// The TIME-HELIX DAG [`Facet`].
///
/// **FC-1 / FC-3 (Elm `Model`)**: every field is observable state that
/// `Serialize`/`Deserialize`/`Clone`/`PartialEq` round-trip — there is no ephemeral
/// render cache — so `HelixView` **is** its own [`Elm::Model`](facett_core::Elm::Model)
/// (`state(&self) -> &Self`). Mutation flows only through [`update`](Self::update); the
/// view ([`render`](Self::render)) is a pure function of `&self`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct HelixView {
pub title: String,
pub scene: Scene,
pub params: HelixParams,
/// Time cursor along the axis, `u ∈ [0,1]`.
cursor_u: f32,
/// Auto-advance the cursor along the time axis.
traverse: bool,
/// Secondary tube-spin yaw (radians) + whether it spins.
yaw: f32,
spin: bool,
/// Dependency-depth filter (edges with `level ≤ max_level` are shown).
max_level: u8,
/// Selected node (scene-global).
selected: Option<HelixRef>,
/// Emissive peak for the GPU bloom lane (read by the `wgpu` `gpu_scene`).
#[cfg_attr(not(feature = "wgpu"), allow(dead_code))]
emissive: f32,
/// **Injected clock** (seconds; FC-7). `Some` ⇒ traverse + wobble are pure
/// functions of it. `None` ⇒ follow egui's ambient time (live UX).
clock_s: Option<f64>,
}
impl HelixView {
/// Build a view over a [`Scene`].
pub fn new(title: impl Into<String>, scene: Scene) -> Self {
let max_level = scene.max_level();
Self {
title: title.into(),
scene,
params: HelixParams::default(),
cursor_u: 0.0,
traverse: true,
yaw: 0.5,
spin: true,
max_level,
selected: None,
emissive: 3.0,
clock_s: None,
}
}
/// Build a single-coil view from one [`PlanDag`] (the common plan-DAG case).
pub fn from_dag(title: impl Into<String>, dag: PlanDag) -> Self {
Self::new(title, Scene::single(dag))
}
/// A self-contained **demo scene**: two crate-coils (modgunn-style) winding through
/// release time, with intra-coil deps at mixed depth levels, a cross-coil
/// (system-level) link, and a CVE event — exercises every capability with no host.
pub fn demo() -> Self {
// Coil A — a crate evolving across four releases, two subsystems (lanes).
let mut a = PlanDag::new();
let a0 = a.add_idea("init", "core", 0.0, Kind::Crate);
let a1 = a.add_idea("api", "core", 1.0, Kind::Task);
let a2 = a.add_idea("io", "net", 2.0, Kind::Task);
let a3 = a.add_idea("v1.0", "core", 3.0, Kind::Test);
a.add_dep(a1, a0);
a.add_dep(a2, a1);
a.add_dep_at(a3, a0, 2); // a transitive (level-2) dep
a.add_dep(a3, a2);
a.set_status(a3, Status::Active);
a.add_event(a2, 2.4, EventKind::Cve, "RUSTSEC-2025-0007");
// Coil B — a dependent crate, offset in Z so it shares the time (X) axis.
let mut b = PlanDag::new();
let b0 = b.add_idea("init", "lib", 0.5, Kind::Crate);
let b1 = b.add_idea("v0.9", "lib", 2.5, Kind::Task);
b.add_dep(b1, b0);
b.add_event(b1, 2.6, EventKind::Delta, "bumped a-crate");
let mut scene = Scene::new();
scene.tension = 0.6;
let ha = scene.add_coil(Coil::new(a).at([0.0, 0.0, 0.0]));
let hb = scene.add_coil(Coil::new(b).at([0.0, 0.0, 3.2]));
// System-level link: B's init depends on A's api (cross-coil).
scene.add_link(HelixRef { helix: hb, node: b0 }, HelixRef { helix: ha, node: a1 }, 1);
Self::new("time-helix", scene)
}
/// Pin the animation clock (seconds; FC-7).
pub fn set_clock(&mut self, seconds: f64) {
self.clock_s = Some(seconds);
}
/// Builder form of [`set_clock`](Self::set_clock).
#[must_use]
pub fn with_clock(mut self, seconds: f64) -> Self {
self.clock_s = Some(seconds);
self
}
/// The effective time cursor `u ∈ [0,1]` for an ambient clock value — auto-traverse
/// advances it (looping); a manual [`Msg::Seek`] pins it. Pure in the clock.
fn cursor_for(&self, time_s: f64) -> f32 {
if self.traverse {
((self.cursor_u as f64 + time_s / TRAVERSE_PERIOD).rem_euclid(1.0)) as f32
} else {
self.cursor_u
}
}
/// The elastic wobble phase `∈ [0,1)` for an ambient clock value (FC-7).
fn wobble_phase(&self, time_s: f64) -> f32 {
(time_s * WOBBLE_RATE).rem_euclid(1.0) as f32
}
/// The spin yaw for an ambient clock value.
fn yaw_for(&self, time_s: f64) -> f32 {
if self.spin { self.yaw + time_s as f32 * SPIN_RATE } else { self.yaw }
}
/// Apply a [`Msg`]. Returns `true` if it changed observable state.
pub fn update(&mut self, msg: Msg) -> bool {
match msg {
Msg::Tick(dt) => {
self.clock_s = Some(self.clock_s.unwrap_or(0.0) + dt);
true
}
Msg::SetClock(s) => {
let changed = self.clock_s != Some(s);
self.clock_s = Some(s);
changed
}
Msg::Seek(u) => {
let u = u.clamp(0.0, 1.0);
let changed = self.cursor_u != u || self.traverse;
self.cursor_u = u;
self.traverse = false;
changed
}
Msg::ToggleTraverse(on) => {
let changed = self.traverse != on;
self.traverse = on;
changed
}
Msg::SetYaw(y) => {
let changed = self.yaw != y;
self.yaw = y;
changed
}
Msg::ToggleSpin(on) => {
let changed = self.spin != on;
self.spin = on;
changed
}
Msg::SetWind(helix, w) => self.scene.set_wind(helix, w),
Msg::SetLevel(l) => {
let l = l.clamp(1, self.scene.max_level());
let changed = self.max_level != l;
self.max_level = l;
changed
}
Msg::SetTension(t) => {
let t = t.clamp(0.0, 3.0);
let changed = (self.scene.tension - t).abs() > f32::EPSILON;
self.scene.tension = t;
changed
}
Msg::Select(r) => {
let valid = self.scene.coils.get(r.helix as usize).is_some_and(|c| r.node.index() < c.dag.len());
if valid && self.selected != Some(r) {
self.selected = Some(r);
true
} else {
false
}
}
Msg::ClearSelection => self.selected.take().is_some(),
}
}
// Read accessors.
#[must_use]
pub fn level(&self) -> u8 {
self.max_level
}
#[must_use]
pub fn cursor(&self) -> f32 {
self.cursor_u
}
#[must_use]
pub fn selected(&self) -> Option<HelixRef> {
self.selected
}
/// The base colour of a node by kind, tinted by status.
fn node_color(kind: &Kind, status: Status) -> Color32 {
let base = match kind {
Kind::Idea => Color32::from_rgb(60, 200, 230),
Kind::Task => Color32::from_rgb(90, 215, 130),
Kind::Test => Color32::from_rgb(230, 190, 70),
Kind::Crate => Color32::from_rgb(180, 130, 235),
Kind::Other(_) => Color32::from_rgb(170, 175, 190),
};
match status {
Status::Blocked => Color32::from_rgb(228, 86, 76),
Status::Done => base,
Status::Active => base.gamma_multiply(1.0),
Status::Planned => base.gamma_multiply(0.7),
}
}
/// Project a **world** helix point: pan the time axis (X) by the cursor, rotate the
/// cross-section by `yaw`, orthographic to screen. Returns `(screen, depth)`; depth
/// (rotated Z) drives size/brightness. A pure function (no egui state).
fn project(world: [f32; 3], cam_x: f32, yaw: f32, center: Pos2, scale: f32) -> (Pos2, f32) {
let x = world[0] - cam_x;
let (s, c) = yaw.sin_cos();
let y = world[1] * c + world[2] * s;
let z = -world[1] * s + world[2] * c;
(center + vec2(x * AXIS_COMPRESS, -y) * scale, z)
}
/// The scene's world-X span (the time-axis extent the cursor sweeps).
fn world_x_span(lay: &SceneLayout) -> (f32, f32) {
let mut lo = f32::INFINITY;
let mut hi = f32::NEG_INFINITY;
for coil in &lay.coils {
for pl in &coil.placed {
lo = lo.min(pl.point[0]);
hi = hi.max(pl.point[0]);
}
}
if lo.is_finite() { (lo, hi) } else { (0.0, 1.0) }
}
/// The number of timeline events whose `t` falls within a small window of the time
/// cursor — what the traverse is "passing through" right now (folded into state).
fn active_events(&self, cursor_u: f32) -> usize {
let mut count = 0;
for coil in &self.scene.coils {
let (t_min, t_max) = coil.dag.t_span();
let span = (t_max - t_min) as f32;
for n in coil.dag.nodes() {
for e in &n.events {
let eu = if span.abs() < f32::EPSILON { 0.0 } else { ((e.t - t_min) as f32 / span).clamp(0.0, 1.0) };
if (eu - cursor_u).abs() < 0.06 {
count += 1;
}
}
}
}
count
}
}
/// The elastic easing the rubberband edges settle with — **facett-core's**
/// `easing::elastic` (the reuse seam). `f(1) = 1` ⇒ the wobble rests to zero.
fn elastic(t: f32) -> f32 {
easing::elastic(t)
}
impl HelixView {
/// The copyable text: the selected node's `label (system)` when a node is
/// selected, else every node label across all coils, one per line.
pub fn copy_text(&self) -> Option<String> {
if let Some(r) = self.selected && let Some(n) = self.scene.coil(r.helix).and_then(|c| c.dag.node(r.node)) {
return Some(if n.system.is_empty() { n.label.clone() } else { format!("{} ({})", n.label, n.system) });
}
let labels: Vec<String> =
self.scene.coils.iter().flat_map(|c| c.dag.nodes().iter().map(|n| n.label.clone())).collect();
(!labels.is_empty()).then(|| labels.join("\n"))
}
}
// ── typed copy (§16) — read-only: selected node label or the label list ───────
impl CopySource for HelixView {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
self.copy_text().map(ClipPayload::Text)
}
}
impl Facet for HelixView {
fn title(&self) -> &str {
&self.title
}
fn copy(&mut self) -> Option<String> {
self.copy_payload().map(|p| p.as_text())
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().selectable().resizable().copyable()
}
/// **FC-9 bridge.** The view is pure ([`render`](HelixView::render) paints + emits
/// `Msg`s); FC-2: mutation flows ONLY through [`update`](HelixView::update). The
/// immediate-mode trait just bridges the two — it does not itself mutate on paint.
fn ui(&mut self, ui: &mut egui::Ui) {
facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
for m in self.render(ui) {
self.update(m);
}
}
fn state_json(&self) -> serde_json::Value {
let lay = layout::layout_scene(&self.scene, &self.params);
let clock = self.clock_s.unwrap_or(0.0);
let cursor_u = self.cursor_for(clock);
let coils: Vec<serde_json::Value> = self
.scene
.coils
.iter()
.zip(&lay.coils)
.enumerate()
.map(|(ci, (coil, hl))| {
let nodes: Vec<serde_json::Value> = coil
.dag
.nodes()
.iter()
.zip(&hl.placed)
.map(|(n, pl)| {
serde_json::json!({
"id": n.id.0,
"label": n.label,
"system": n.system,
"kind": n.kind.key(),
"status": n.status.key(),
"t": n.t,
// the helix coordinates, AS DATA (the asserted picture):
"along": pl.along,
"theta": pl.theta,
"lane": pl.lane,
"events": n.events.len(),
})
})
.collect();
serde_json::json!({
"helix": ci,
"wind": coil.wind,
"lanes": hl.lanes,
"node_count": coil.dag.len(),
"edge_count": coil.dag.edges_at_level(self.max_level).len(),
"nodes": nodes,
})
})
.collect();
// `winds_forward`: the helix angle advances monotonically with t on coil 0.
let winds_forward = lay
.coils
.first()
.map(|c| c.placed.windows(2).all(|w| w[1].theta >= w[0].theta - 1e-4))
.unwrap_or(true);
serde_json::json!({
"title": self.title,
"coil_count": self.scene.coils.len(),
"link_count": self.scene.links.iter().filter(|l| l.level <= self.max_level).count(),
"total_nodes": self.scene.coils.iter().map(|c| c.dag.len()).sum::<usize>(),
"tension": self.scene.tension,
"max_level": self.max_level,
"scene_max_level": self.scene.max_level(),
"cursor_u": cursor_u,
"traverse": self.traverse,
"spin": self.spin,
"clock_s": self.clock_s,
"active_events": self.active_events(cursor_u),
"event_total": self.scene.coils.iter().map(|c| c.dag.event_count()).sum::<usize>(),
"winds_forward": winds_forward,
"selected": self.selected.map(|r| serde_json::json!({ "helix": r.helix, "node": r.node.0 })),
"coils": coils,
})
}
fn selection_json(&self) -> serde_json::Value {
match self.selected.and_then(|r| self.scene.coil(r.helix).and_then(|c| c.dag.node(r.node)).map(|n| (r, n))) {
Some((r, n)) => serde_json::json!({
"helix": r.helix, "node": n.id.0, "label": n.label, "system": n.system, "kind": n.kind.key(), "t": n.t,
}),
None => serde_json::Value::Null,
}
}
}
impl HelixView {
/// **FC-9 — the PURE view.** Paints the whole time-helix and *returns* the [`Msg`]s
/// the frame produced (a checkbox/slider toggle, a node click), mutating **nothing**
/// on `&self`. [`Facet::ui`] (and [`Elm::view`](facett_core::Elm::view)) apply the
/// returned messages through [`update`](Self::update) — so the render pass is a pure
/// function of the [`Model`](facett_core::Elm::Model) and never edits it mid-paint.
///
/// The one-frame latency this introduces (an interaction is observed on frame *n*
/// and folded into the model for frame *n+1*) is the standard, intended Elm tradeoff.
pub fn render(&self, ui: &mut egui::Ui) -> Vec<Msg> {
let mut msgs = Vec::new();
let th = theme(ui);
// ── header controls (traverse / level / wind) ───────────────────────────
ui.horizontal_wrapped(|ui| {
let mut trav = self.traverse;
if ui.checkbox(&mut trav, "traverse").changed() {
msgs.push(Msg::ToggleTraverse(trav));
}
let mut spin = self.spin;
if ui.checkbox(&mut spin, "spin").changed() {
msgs.push(Msg::ToggleSpin(spin));
}
if self.scene.max_level() > 1 {
ui.separator();
ui.label(egui::RichText::new("dep level:").color(th.text_dim));
let mut lvl = self.max_level;
if ui.add(egui::Slider::new(&mut lvl, 1..=self.scene.max_level())).changed() {
msgs.push(Msg::SetLevel(lvl));
}
}
if self.scene.coils.len() > 1 || self.scene.coils.first().is_some_and(|c| c.wind > 0.0) {
ui.separator();
ui.label(egui::RichText::new("wind:").color(th.text_dim));
for i in 0..self.scene.coils.len() {
let mut w = self.scene.coils[i].wind;
if ui.add(egui::Slider::new(&mut w, 0.0..=1.0).text(format!("c{i}"))).changed() {
msgs.push(Msg::SetWind(i as u32, w));
}
}
}
});
ui.separator();
// ── lay out the scene + resolve the animation clock ──────────────────────
let lay = layout::layout_scene(&self.scene, &self.params);
let ambient = ui.input(|i| i.time);
// FC-7: with an injected clock everything is a pure function of it; without one
// we follow ambient time and keep repainting for the live animation.
let clock = self.clock_s.unwrap_or(ambient);
if self.clock_s.is_none() && (self.traverse || self.spin || self.scene.tension > 0.0) {
ui.ctx().request_repaint();
}
let cursor_u = self.cursor_for(clock);
let yaw = self.yaw_for(clock);
let phase = self.wobble_phase(clock);
let (rect, _resp) = ui.allocate_exact_size(ui.available_size().max(vec2(320.0, 280.0)), Sense::hover());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::ZERO, th.bg);
let center = rect.center();
let scale = rect.size().min_elem() * 0.20;
// A soft radial vignette so the dark ground has depth (the CPU stand-in for
// the GPU bloom's ambient lift): a few faint, widening haloes toward center.
for k in 0..4 {
let rr = rect.size().min_elem() * (0.30 + k as f32 * 0.16);
painter.circle_filled(center, rr, th.accent.gamma_multiply(0.012));
}
let (x_min, x_max) = Self::world_x_span(&lay);
let cam_x = x_min + (x_max - x_min) * cursor_u;
let half_w = (rect.width() * 0.42).max(1.0);
// ── the TUBE the strands wrap around ─────────────────────────────────────
// Faint cross-section RINGS (the ribs of the cylinder) + the glowing CENTRAL
// TIME AXIS down the core. Drawn first, dim, as a backdrop so the bright
// strands/nodes read as a 3D tunnel with time running through its middle —
// the fix that turns the thin spring into a Doctor-Who time-vortex.
for ci in 0..self.scene.coils.len() {
let hl = &lay.coils[ci];
let rib = th.text_dim.gamma_multiply(0.5);
for ring in hl.rings(9, 40) {
paint_spline(&painter, &ring, cam_x, yaw, center, scale, rib, 0.8);
}
// The timeline through the core of the axis (the strands wind around it).
let spine = hl.time_axis(48);
paint_spline(&painter, &spine, cam_x, yaw, center, scale, th.accent.gamma_multiply(0.7), 1.2);
}
// ── the HELIX BACKBONE — the glowing spiral tube itself ──────────────────
// Sample each lane's continuous strand across the coil's t-span and paint it
// as a multi-pass emissive ribbon. This is what makes the scene read as a
// *helix* (before, only the dep edges drew, so the coil was invisible between
// nodes). Depth-sorted brightness + a traverse-sweep lift near the "now" plane.
for (ci, _coil) in self.scene.coils.iter().enumerate() {
let hl = &lay.coils[ci];
let steps = 88usize;
for lane in 0..hl.lanes {
let mut poly: Vec<[f32; 3]> = Vec::with_capacity(steps + 1);
for s in 0..=steps {
let u = s as f32 / steps as f32;
poly.push(layout::place_in(u, lane, hl.lanes, &hl.params, &hl.frame).point);
}
paint_strand(&painter, &poly, cam_x, yaw, center, scale, strand_color(ci, lane, hl.lanes), half_w);
}
}
// ── intra-coil rubberband edges (level-filtered) ─────────────────────────
let tension = self.scene.tension;
for (ci, coil) in self.scene.coils.iter().enumerate() {
let hl = &lay.coils[ci];
for e in coil.dag.edges_at_level(self.max_level) {
let ctrl = hl.intra_controls(e.from, e.to, 6);
if ctrl.len() < 2 {
continue;
}
let poly = layout::elastic_spline(&ctrl, tension, phase, elastic, 5);
let col = Self::node_color(&coil.dag.node(e.to).map(|n| n.kind.clone()).unwrap_or(Kind::Idea), Status::Done);
paint_spline(&painter, &poly, cam_x, yaw, center, scale, col, 1.4);
}
}
// ── inter-helix (system-level) links — same soft rubberband ──────────────
for link in &self.scene.links {
if link.level > self.max_level {
continue;
}
if let Some(ctrl) = lay.link_controls(link, 6, 1.1) {
let poly = layout::elastic_spline(&ctrl, tension.max(0.4), phase, elastic, 5);
paint_spline(&painter, &poly, cam_x, yaw, center, scale, Color32::from_rgb(235, 170, 90), 1.8);
}
}
// ── nodes (depth-sorted) + AccessKit ─────────────────────────────────────
let base_id = ui.id().with("helix-nodes");
let mut draw: Vec<(usize, usize, Pos2, f32)> = Vec::new();
for (ci, coil) in self.scene.coils.iter().enumerate() {
for (ni, _n) in coil.dag.nodes().iter().enumerate() {
let world = lay.coils[ci].placed[ni].point;
let (p, z) = Self::project(world, cam_x, yaw, center, scale);
draw.push((ci, ni, p, z));
}
}
draw.sort_by(|a, b| a.3.partial_cmp(&b.3).unwrap_or(std::cmp::Ordering::Equal));
let mut clicked: Option<HelixRef> = None;
for (ci, ni, p, z) in draw {
let coil = &self.scene.coils[ci];
let n = &coil.dag.nodes()[ni];
// traverse-sweep proximity: 1 at the "now" plane, 0 at the pane edge —
// a node blooms as the time cursor passes over it.
let prox = (1.0 - ((p.x - center.x).abs() / half_w).min(1.0)).max(0.0);
let flare = prox * prox; // sharpen the sweep
let r = (3.5 + (z + 1.0) * 2.2) * (1.0 + 0.45 * flare);
let col = Self::node_color(&n.kind, n.status);
let bright = ((0.5 + (z + 1.0) * 0.25) * (1.0 + 0.5 * flare)).clamp(0.35, 1.0);
let rf = HelixRef { helix: ci as u32, node: n.id };
let is_sel = self.selected == Some(rf);
// layered soft glow under the node (the CPU bloom stand-in) — lifts as
// the traverse sweep crosses it.
painter.circle_filled(p, r * 3.0, col.gamma_multiply(0.05 + 0.14 * flare));
painter.circle_filled(p, r * 2.2, col.gamma_multiply(0.10 + 0.12 * flare));
painter.circle_filled(p, r * 1.5, col.gamma_multiply(0.16));
painter.circle_filled(p, r, col.gamma_multiply(bright));
if is_sel {
painter.circle_stroke(p, r + 3.0, Stroke::new(2.0_f32, th.accent));
}
// event marker pip if the node carries timeline events — pulses out as
// the sweep crosses it (a CVE / delta announcing itself in time).
if !n.events.is_empty() {
let ring = Color32::from_rgb(235, 120, 90);
painter.circle_stroke(p, r + 1.5, Stroke::new(1.0_f32, ring));
if flare > 0.02 {
let pr = r + 2.0 + phase * 7.0 * flare;
painter.circle_stroke(p, pr, Stroke::new(1.5_f32, ring.gamma_multiply((1.0 - phase) * flare)));
}
}
// AccessKit node (FC-4): queryable Button carrying the along-axis t as value.
let nrect = egui::Rect::from_center_size(p, vec2(r * 2.0 + 6.0, r * 2.0 + 6.0));
let sem = a11y::Semantics::button(format!("{} · {} · {}", n.label, n.system, n.kind.key()))
.selected(is_sel)
.value(n.t);
let resp = a11y::node(ui, base_id, (ci, ni), Sense::click(), nrect, sem);
if resp.clicked() {
clicked = Some(rf);
}
// labels for small scenes only.
if coil.dag.len() + self.scene.coils.len() <= 24 {
painter.text(p + vec2(0.0, -(r + 3.0)), Align2::CENTER_BOTTOM, &n.label, FontId::proportional(10.0), th.text.gamma_multiply(bright));
}
}
// FC-9: the click is COLLECTED, not applied — the toggle is computed against the
// model this frame observes, and folded in by `update` on the next.
if let Some(rf) = clicked {
if self.selected == Some(rf) {
msgs.push(Msg::ClearSelection);
} else {
msgs.push(Msg::Select(rf));
}
}
// ── the time-cursor "now" plane — a glowing vertical sweep band ──────────
let top = Pos2::new(center.x, rect.top() + 8.0);
let bot = Pos2::new(center.x, rect.bottom() - 8.0);
for &(w, a) in &[(11.0f32, 0.045f32), (5.0, 0.10), (1.5, 0.65)] {
painter.line_segment([top, bot], Stroke::new(w, th.accent.gamma_multiply(a)));
}
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-helix::HelixView::ui",
"ui_render",
!self.scene.coils.is_empty(),
&format!("coils={} cursor={cursor_u:.3} level={} tension={tension:.2}", self.scene.coils.len(), self.max_level),
);
msgs
}
}
// ── FC-2 / FC-9 canonical `Elm` trait ────────────────────────────────────────
//
// `HelixView` already carries the Model/Msg/Effect/pure-view split, so it implements
// `facett_core::Elm` by delegating to its inherent methods — no behaviour change.
// NOTE: the hand-written `impl Facet` above is intentionally **KEPT** (not replaced by
// `impl_facet_via_elm!`) for the same reason `facett-security` keeps its own: this view
// publishes a **richer** `state_json` (per-node helix coordinates, `winds_forward`,
// active-events, level-filtered edge counts) than the macro's hardcoded
// `serde(state())`, and the macro cannot override `state_json` (it would be a duplicate
// method). The Elm impl + the pure `render` are what the migration needs; the macro is
// the shortcut only for components with no custom `state_json`.
impl facett_core::Elm for HelixView {
/// `HelixView` **is** its own model — every field is round-trippable observable state.
type Model = HelixView;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &HelixView {
self
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
// Delegate to the inherent `update` (returns a `changed` dirty-bit); the helix
// does no I/O, so there are never any effects to hand back.
HelixView::update(self, msg);
Vec::new()
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
self.render(ui)
}
}
/// Project a world-space spline polyline and paint it as a connected emissive-ish
/// strip (the soft rounded edge). A free helper so the borrow of `self` stays simple.
#[allow(clippy::too_many_arguments)]
fn paint_spline(painter: &egui::Painter, poly: &[[f32; 3]], cam_x: f32, yaw: f32, center: Pos2, scale: f32, color: Color32, width: f32) {
if poly.len() < 2 {
return;
}
let pts: Vec<(Pos2, f32)> = poly.iter().map(|&w| HelixView::project(w, cam_x, yaw, center, scale)).collect();
for seg in pts.windows(2) {
let depth = (seg[0].1 + seg[1].1) * 0.5;
let a = ((110.0 + depth * 90.0).clamp(30.0, 220.0)) as u8;
painter.line_segment([seg[0].0, seg[1].0], Stroke::new(width, color.gamma_multiply(a as f32 / 255.0)));
}
}
/// A soft neon strand tint for coil `ci`, lane `lane` — hues spread around a wheel so
/// multiple coils / lanes stay visually distinct without a host-supplied palette.
fn strand_color(ci: usize, lane: usize, lanes: usize) -> Color32 {
const WHEEL: [Color32; 6] = [
Color32::from_rgb(80, 200, 245), // cyan
Color32::from_rgb(150, 130, 245), // violet
Color32::from_rgb(90, 225, 165), // teal-green
Color32::from_rgb(240, 170, 90), // amber
Color32::from_rgb(235, 110, 160), // magenta-rose
Color32::from_rgb(120, 210, 110), // green
];
WHEEL[(ci.wrapping_mul(lanes.max(1)).wrapping_add(lane)) % WHEEL.len()]
}
/// Paint a helix strand as a **multi-pass emissive ribbon** — a wide faint halo, a mid
/// glow, and a thin bright core — each depth- and sweep-attenuated so the tube glows
/// brighter as it nears the "now" plane at `center.x` (± `half_w`). The CPU stand-in
/// for the GPU bloom lane; the strand spans the coil's full t-span.
#[allow(clippy::too_many_arguments)]
fn paint_strand(painter: &egui::Painter, poly: &[[f32; 3]], cam_x: f32, yaw: f32, center: Pos2, scale: f32, color: Color32, half_w: f32) {
if poly.len() < 2 {
return;
}
let pts: Vec<(Pos2, f32)> = poly.iter().map(|&w| HelixView::project(w, cam_x, yaw, center, scale)).collect();
for &(width, base_a) in &[(6.5f32, 0.05f32), (3.0, 0.11), (1.3, 0.55)] {
for seg in pts.windows(2) {
let depth = (seg[0].1 + seg[1].1) * 0.5;
let db = (0.45 + (depth + 1.0) * 0.28).clamp(0.3, 1.1);
let mx = (seg[0].0.x + seg[1].0.x) * 0.5;
let prox = (1.0 - ((mx - center.x).abs() / half_w).min(1.0)).max(0.0);
let a = (base_a * db * (0.7 + 0.6 * prox)).clamp(0.0, 1.0);
painter.line_segment([seg[0].0, seg[1].0], Stroke::new(width, color.gamma_multiply(a)));
}
}
}
// ─── GPU bloom lane (feature `wgpu`) — reuse facett-graph3d's offscreen + bloom ────
#[cfg(feature = "wgpu")]
mod gpu {
use super::*;
use facett_graph3d::graph_gpu::{EdgeInstance, GraphScene, NodeInstance};
impl HelixView {
/// **Build the GPU-lane scene** — project every node + every (level-filtered)
/// rubberband spline into a `w×h` px pane and emit instanced emissive node discs
/// + edge filaments for [`facett_graph3d::graph_gpu`]. Node/edge radiance is
/// scaled **above 1.0** so the additive HDR pass + bloom catch the glow. Pure
/// projection (no egui), so it is unit-testable headless.
#[must_use]
pub fn gpu_scene(&self, w: f32, h: f32) -> GraphScene {
let lay = layout::layout_scene(&self.scene, &self.params);
let clock = self.clock_s.unwrap_or(0.0);
let cursor_u = self.cursor_for(clock);
let yaw = self.yaw_for(clock);
let phase = self.wobble_phase(clock);
let center = Pos2::new(w * 0.5, h * 0.5);
let scale = w.min(h) * 0.20;
let (x_min, x_max) = Self::world_x_span(&lay);
let cam_x = x_min + (x_max - x_min) * cursor_u;
let radiance = |c: Color32, depth: f32, mul: f32| -> [f32; 4] {
let lin = |b: u8| {
let s = b as f32 / 255.0;
s * s
};
let dbright = (0.55 + (depth + 1.0) * 0.35).clamp(0.4, 1.3);
let k = self.emissive * dbright * mul;
[lin(c.r()) * k, lin(c.g()) * k, lin(c.b()) * k, 1.0]
};
let mut nodes = Vec::new();
for (ci, coil) in self.scene.coils.iter().enumerate() {
for (ni, n) in coil.dag.nodes().iter().enumerate() {
let (p, z) = Self::project(lay.coils[ci].placed[ni].point, cam_x, yaw, center, scale);
nodes.push(NodeInstance {
center: [p.x, p.y],
radius: 3.0 + (z + 1.0) * 2.5,
_pad: 0.0,
color: radiance(Self::node_color(&n.kind, n.status), z, 1.0),
});
}
}
let mut edges = Vec::new();
let mut push_spline = |poly: &[[f32; 3]], col: Color32| {
let pts: Vec<(Pos2, f32)> = poly.iter().map(|&wld| Self::project(wld, cam_x, yaw, center, scale)).collect();
for seg in pts.windows(2) {
let depth = (seg[0].1 + seg[1].1) * 0.5;
edges.push(EdgeInstance {
a: [seg[0].0.x, seg[0].0.y],
b: [seg[1].0.x, seg[1].0.y],
width: 1.5,
_pad: 0.0,
color: radiance(col, depth, 0.6),
});
}
};
for (ci, coil) in self.scene.coils.iter().enumerate() {
for e in coil.dag.edges_at_level(self.max_level) {
let ctrl = lay.coils[ci].intra_controls(e.from, e.to, 6);
if ctrl.len() < 2 {
continue;
}
let poly = layout::elastic_spline(&ctrl, self.scene.tension, phase, elastic, 5);
let col = Self::node_color(&coil.dag.node(e.to).map(|n| n.kind.clone()).unwrap_or(Kind::Idea), Status::Done);
push_spline(&poly, col);
}
}
for link in &self.scene.links {
if link.level > self.max_level {
continue;
}
if let Some(ctrl) = lay.link_controls(link, 6, 1.1) {
let poly = layout::elastic_spline(&ctrl, self.scene.tension.max(0.4), phase, elastic, 5);
push_spline(&poly, Color32::from_rgb(235, 170, 90));
}
}
GraphScene { background: [0.01, 0.01, 0.02, 1.0], nodes, edges }
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn typed_copy_is_selected_node_or_all_node_labels() {
use facett_core::clip::{ClipKind, CopySource};
let v = HelixView::demo();
// No selection -> every node label across coils (multi-line).
let p = v.copy_payload().expect("a demo scene copies");
assert_eq!(p.kind(), ClipKind::Text);
assert!(p.as_text().contains('\n'), "label list is multi-line: {}", p.as_text());
assert_eq!(p.as_text().lines().count() as u64, v.state_json()["total_nodes"].as_u64().unwrap());
}
#[test]
fn demo_builds_a_multi_coil_scene() {
let v = HelixView::demo();
let st = v.state_json();
assert_eq!(st["coil_count"], 2);
assert!(st["total_nodes"].as_u64().unwrap() >= 6);
assert_eq!(st["winds_forward"], true);
assert!(st["link_count"].as_u64().unwrap() >= 1, "the cross-coil system link is present");
}
#[test]
fn seek_moves_the_cursor_and_stops_traverse() {
let mut v = HelixView::demo();
assert!(v.update(Msg::Seek(0.5)));
assert!((v.cursor() - 0.5).abs() < 1e-6);
assert_eq!(v.state_json()["traverse"], false);
assert_eq!(v.state_json()["cursor_u"], 0.5);
}
#[test]
fn injected_clock_makes_the_traverse_deterministic() {
let v = HelixView::demo().with_clock(2.0);
let a = v.state_json()["cursor_u"].as_f64().unwrap();
let b = HelixView::demo().with_clock(2.0).state_json()["cursor_u"].as_f64().unwrap();
assert_eq!(a, b, "same state + clock ⇒ same cursor (FC-7)");
let c = HelixView::demo().with_clock(5.0).state_json()["cursor_u"].as_f64().unwrap();
assert_ne!(a, c, "a different clock advances the traverse");
}
#[test]
fn level_filter_thins_the_dependency_edges() {
let mut v = HelixView::demo();
let full = v.state_json()["coils"][0]["edge_count"].as_u64().unwrap();
v.update(Msg::SetLevel(1));
let direct = v.state_json()["coils"][0]["edge_count"].as_u64().unwrap();
assert!(direct < full, "direct-only ({direct}) shows fewer edges than all levels ({full})");
}
#[test]
fn winding_a_coil_is_observable() {
let mut v = HelixView::demo();
assert_eq!(v.state_json()["coils"][1]["wind"], 0.0);
assert!(v.update(Msg::SetWind(1, 1.0)));
assert_eq!(v.state_json()["coils"][1]["wind"], 1.0);
}
#[test]
fn nodes_carry_helix_coordinates_as_data() {
let v = HelixView::demo();
let st = v.state_json();
for coil in st["coils"].as_array().unwrap() {
let nodes = coil["nodes"].as_array().unwrap();
// along-axis t is monotone in node t (sorted insertion), lanes are indices.
for n in nodes {
assert!(n["along"].is_number() && n["theta"].is_number());
assert!(n["lane"].as_u64().is_some());
}
}
}
#[test]
fn selection_round_trips_through_msg() {
let mut v = HelixView::demo();
let r = HelixRef { helix: 0, node: NodeId(0) };
assert!(v.update(Msg::Select(r)));
assert_eq!(v.selected(), Some(r));
assert_eq!(v.selection_json()["helix"], 0);
assert!(v.update(Msg::ClearSelection));
assert!(v.selected().is_none());
}
// ── FC-2 → FC-3 via the headless Elm harness (no egui / no GPU) ──────────────
//
// `facett_core::harness::{drive,snapshot}` feed a `Vec<Msg>` through `Elm::update`
// and read back the resulting `Model` (= `HelixView`) — exactly FC-2 (writable Msg
// surface) proven as a property against FC-3 (observable state), with `view()` (the
// only egui-touching part) never called. These run anywhere, deterministically.
use facett_core::harness::{drive, snapshot};
#[test]
fn harness_seek_stops_traverse_and_moves_cursor() {
let mut v = HelixView::demo();
assert!(v.state_json()["traverse"].as_bool().unwrap(), "demo auto-traverses");
let snap = snapshot(&mut v, [Msg::Seek(0.5)]);
assert!((snap.cursor() - 0.5).abs() < 1e-6, "Seek folds into the cursor");
assert_eq!(snap.state_json()["traverse"], false, "Seek stops the auto-traverse");
assert_eq!(snap.state_json()["cursor_u"], 0.5);
}
#[test]
fn harness_level_and_wind_fold_into_state() {
let mut v = HelixView::demo();
let full = v.state_json()["coils"][0]["edge_count"].as_u64().unwrap();
let snap = snapshot(&mut v, [Msg::SetLevel(1), Msg::SetWind(1, 1.0)]);
assert_eq!(snap.level(), 1, "SetLevel(1) is observable as the dep-level filter");
assert!(
snap.state_json()["coils"][0]["edge_count"].as_u64().unwrap() < full,
"direct-only thins the edges",
);
assert_eq!(snap.state_json()["coils"][1]["wind"], 1.0, "SetWind reels the coil onto its pin");
}
#[test]
fn harness_selection_round_trips_through_msgs() {
let mut v = HelixView::demo();
let r = HelixRef { helix: 0, node: NodeId(0) };
let snap = snapshot(&mut v, [Msg::Select(r)]);
assert_eq!(snap.selected(), Some(r), "Select folds the ref into the model");
// The same component, now cleared.
let snap = snapshot(&mut v, [Msg::ClearSelection]);
assert!(snap.selected().is_none(), "ClearSelection empties it");
}
#[test]
fn harness_tick_advances_the_traverse_cursor() {
// With an injected clock the traverse is a pure function of it: ticking the clock
// forward advances the observable cursor (FC-7 determinism through the Msg surface).
let mut v = HelixView::demo().with_clock(0.0);
let c0 = v.state_json()["cursor_u"].as_f64().unwrap();
let snap = snapshot(&mut v, [Msg::Tick(2.0)]);
let c1 = snap.state_json()["cursor_u"].as_f64().unwrap();
assert!(c1 > c0, "Tick advances the traverse cursor along the time axis ({c0} → {c1})");
}
#[test]
fn harness_drive_reports_no_effects_fc8() {
// FC-8: the helix does no I/O — every Msg produces zero effects.
let mut v = HelixView::demo();
let effects = drive(
&mut v,
[Msg::Tick(1.0), Msg::Seek(0.3), Msg::SetLevel(1), Msg::SetTension(1.5), Msg::ClearSelection],
);
assert!(effects.is_empty(), "an idle-by-construction component yields no Effects");
}
#[test]
fn model_round_trips_through_serde_fc3() {
// FC-3: the driven Model serialises and deserialises back to an equal value.
let mut v = HelixView::demo();
let snap = snapshot(&mut v, [Msg::Seek(0.25), Msg::SetWind(1, 0.8), Msg::SetTension(1.2), Msg::ToggleSpin(false)]);
let json = serde_json::to_string(&snap).expect("Model serialises");
let back: HelixView = serde_json::from_str(&json).expect("Model deserialises");
assert_eq!(back, snap, "the observable Model round-trips through serde");
}
#[test]
fn proptest_style_msg_sequences_keep_state_wellformed() {
// Dependency-free deterministic LCG: throw many random Msg sequences at fresh
// views and assert the model stays well-formed — no panic, `winds_forward` holds,
// levels stay in range, and the Model round-trips through serde (contract §5.1).
let mut rng: u64 = 0x1234_5678_9ABC_DEF0;
let mut next = || {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
rng
};
for _ in 0..200 {
let mut v = HelixView::demo();
let scene_max = v.state_json()["scene_max_level"].as_u64().unwrap() as u8;
let len = (next() % 10) as usize;
let msgs: Vec<Msg> = (0..len)
.map(|_| match next() % 8 {
0 => Msg::Tick((next() % 100) as f64 / 10.0),
1 => Msg::Seek((next() % 100) as f32 / 100.0),
2 => Msg::ToggleTraverse(next() % 2 == 0),
3 => Msg::ToggleSpin(next() % 2 == 0),
4 => Msg::SetLevel((next() % 4) as u8),
5 => Msg::SetWind((next() % 2) as u32, (next() % 100) as f32 / 100.0),
6 => Msg::SetTension((next() % 300) as f32 / 100.0),
_ => Msg::ClearSelection,
})
.collect();
let snap = snapshot(&mut v, msgs);
let st = snap.state_json();
// Invariant 1: the level filter stays clamped into the valid range.
let lvl = st["max_level"].as_u64().unwrap() as u8;
assert!((1..=scene_max.max(1)).contains(&lvl), "level {lvl} out of range");
// Invariant 2: the helix still winds forward through time.
assert_eq!(st["winds_forward"], true, "the helix must keep winding forward");
// Invariant 3: the serde round-trip is a STABLE FIXPOINT (FC-3). We assert a
// fixpoint rather than raw `back == snap` because an accumulated `Tick` can
// land on an f64 like 15.399999999999999 whose shortest decimal serde_json
// re-parses to the adjacent bits (15.4) — a one-time normalisation, after
// which the value is stable. Both directions must agree once normalised.
let back: HelixView = serde_json::from_str(&serde_json::to_string(&snap).unwrap()).unwrap();
let back2: HelixView = serde_json::from_str(&serde_json::to_string(&back).unwrap()).unwrap();
assert_eq!(back, back2, "the observable Model is a serde round-trip fixpoint");
}
}
}