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

nordisk/facett

facett-graph3d/src/lib.rs

//! **facett-graph3d** — a **3D graph viewer**. The idea behind nornir's
//! `callgraph.rs` (3D layouts, Y-axis spin, depth cues), rebuilt **generic**:
//! the consumer supplies `(label, color)` nodes + edges, picks a [`Layout3D`],
//! and gets a rotating, depth-shaded node cloud. A themed [`Facet`].
//!
//! CPU projection today (orthographic + a Y-rotation, depth → size/brightness);
//! this is exactly the kind of thing the wgpu kernel will accelerate later.
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! This is a **GPU-aware** component, so the split follows the peer 3D/canvas
//! components: the [`facett_core::Elm`] `Model` ([`Graph3DView`]) carries **only the
//! observable CONTROL surface** — the 3D camera (orbit `yaw` + auto-`spin`), the
//! active [`Layout3D`] + label toggle, the injected animation clock (FC-7), the
//! neon-[`DecorPolicy`], and the selected node index (FC-5). The **raw node/edge
//! geometry** ([`Node3`] cloud + index edges, whose `Color32` fills and `[f32;3]`
//! positions aren't the state a driver mutates) and the wgpu scene stay as plain
//! fields on [`Graph3D`], OUTSIDE the Model.
//! - **[`Msg`] + [`Graph3D::update`]** — the single mutation path (FC-2): every input
//!   (orbit drag, live-spin tick, layout/label/decor toggle, click-select, ⊙ fit,
//!   injected-clock set/tick) is a [`Msg`]; a headless driver ([`facett_core::harness`])
//!   reaches the identical transitions the canvas gestures drive.
//! - **[`Graph3D::view`]** — a **pure** paint (FC-9): it reads `&self`, projects +
//!   paints the depth-sorted cloud, and *returns* the [`Msg`]s the interactions
//!   produced; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge
//!   applies them via [`update`](Graph3D::update). The only `&self`-interior mutation
//!   is the ephemeral gyro `label_anchors` paint cache (FC-7), outside the Model.
//! - **FC-5** — the selection keys on the node's **index**, which is invariant across
//!   camera spin AND relayout (a relayout repositions node `i` but never reorders the
//!   cloud), so a driver's `SelectNode(i)` survives every camera move + layout switch.

use std::cell::RefCell;

use egui::{Color32, Pos2, Sense, Stroke, vec2};
use serde::{Deserialize, Serialize};

use facett_core::look::EffectsPolicy;
use facett_core::render::decor;
use facett_core::{FacetCaps, theme};

pub mod logo3d;
pub use logo3d::RabbitLogo3D;

/// **Generated graph DATASETS with real structure** — long chains, braided cycles
/// and high-degree hubs, so a force layout has something to fold into a VOLUME
/// instead of relaxing onto the sphere shell it was seeded on. Deterministic and
/// size-knobbed; see [`dataset`].
/// **CPU-painter node budget.** The instanced GPU cloud draws the whole cloud as
/// instances, so node count stopped being the constraint — but when no renderer is
/// installed (`draw_cloud` returned false: no `RenderState`, a GPU-less box, wasm),
/// the fall-back CPU painter tessellates a disc + a glow orb per node and a line per
/// edge, on ONE core. That is the measured 33.75 ms/frame wall at 30000 nodes; at the
/// demo's 100000 it would be seconds per frame.
///
/// So the fallback is BUDGETED, exactly like the OSM-2D point layer: above this many
/// nodes it paints a depth-strided SAMPLE of the cloud rather than all of it. The
/// pane stays interactive and still reads as a big cloud; the GPU lane, when present,
/// is unaffected and draws every node. Below the budget the stride is 1 and the paint
/// is byte-identical to before (L2 — every existing golden is a small graph).
pub const CPU_PAINT_MAX_NODES: usize = 12_000;
/// The same budget for the edge pass (edges are cheaper per item but far more
/// numerous).
pub const CPU_PAINT_MAX_EDGES: usize = 24_000;

/// **The node count this viewer is exercised and bounded at** — the number a HOST
/// should use when it asks "can the 3D wireframe show this graph?".
///
/// It is deliberately NOT [`CPU_PAINT_MAX_NODES`], and the difference is the whole
/// reason this constant exists. That one is the CPU **fallback's** per-frame
/// tessellation budget: above it the fallback paints a depth-strided SAMPLE, which is
/// bounded and legible, not a refusal. A host that read the fallback's budget as the
/// viewer's ceiling would decline a 20 000-node graph the pane draws perfectly well —
/// on the instanced GPU cloud, every node; without a renderer, a strided sample.
///
/// What backs the number, stated as measured-or-not (no dressing up):
/// * `tests/cpu_paint_budget.rs` renders a **100 000**-node cloud on the CPU lane and
///   asserts it stays bounded (painted nodes ≤ [`CPU_PAINT_MAX_NODES`]), non-blank,
///   and no more than ~3× a budget-sized cloud's real vertex count.
/// * `facett-demo`'s `graph3d` tab runs at `GRAPH3D_DEFAULT_NODES` = 100 000 by
///   default, on the instanced GPU lane when the demo is built with `--features gpu`.
/// * `benches/heavy_graph3d.rs` sweeps the dataset, layout and per-frame projection
///   to the same 100 000.
///
/// It is a WORKING size, not a hard wall: nothing in the crate refuses more, and
/// `$FACETT_GRAPH3D_NODES` raises the demo past it. Above this the crate makes no
/// claim, which is what a ceiling is for.
pub const VIEWER_MAX_NODES: usize = 100_000;

/// Node count at/above which [`Layout3D::Force`] drops to [`LARGE_GRAPH_ITERS`]
/// relaxation passes. Below it the historical 100 passes run unchanged, so every
/// existing snapshot/determinism golden is byte-identical (L2, additive).
pub const LARGE_GRAPH_ITER_N: usize = 20_000;
/// Relaxation passes used at/above [`LARGE_GRAPH_ITER_N`].
pub const LARGE_GRAPH_ITERS: usize = 30;

pub mod dataset;
pub use dataset::{generate as generate_dataset, GeneratedGraph, GraphSpec, MIN_CHAIN_LEN};

pub mod dnahelix;
pub use dnahelix::{DnaAxis, DnaHelix, DnaRung};

pub mod timehelix;
pub use timehelix::{DiscPlacement, TimeHelix, TimePoint};

pub mod callgraph_layouts;
pub use callgraph_layouts::{CallGraphLayout, louvain};

pub mod codevault;
pub use codevault::{CodeGraph, CodeNode, CodeVault, CodeVaultMsg, Material, NodeState};

pub mod pipes;
pub use pipes::{SystemMap, SystemMapMsg};

pub mod travels;
pub use travels::{NodeId, RouteKind, Spline, Travel, TravelRoute};

pub mod neon;
use neon::DIM_ALPHA;

/// **EDGE-RATE overlay** (feature `rate`): per-edge rates + the injected clock
/// drive pulses along curved 3D arcs — facett-flow's `rate` feature, in 3D.
#[cfg(feature = "rate")]
pub mod rate;
#[cfg(feature = "rate")]
pub use rate::EdgeRates;

/// T3.4: the instanced emissive node/edge GPU lane with real bloom (feature `wgpu`).
///
/// **This is a re-export, not a second renderer (L5).** The lane now lives in
/// [`facett_core::render::gpu::graphcloud`] so the 2-D graph family
/// (`facett-graphpan` / `facett-graphview` / `facett-graphnav`) paints its cloud
/// through the *same* instanced pipelines, install flag and paint callback that
/// `Graph3D` does. Everything reachable under `facett_graph3d::graph_gpu::…` still
/// resolves exactly as before.
#[cfg(feature = "wgpu")]
pub use facett_core::render::gpu::graphcloud as graph_gpu;

/// Cycles-per-second the injected clock drives the decor animations at (breathing,
/// particle flow, shockwave). A gentle ~0.5 Hz so the neon "breathes".
const EFFECT_RATE: f32 = 0.5;

/// **How much neon decor [`Graph3D`] paints.** Default [`FromTheme`](DecorPolicy::FromTheme):
/// the beauty is driven by the active [`LookTheme`](facett_core::LookTheme)'s
/// [`EffectsPolicy`] (published via `Theme::apply`) — so a `device`/airgap console
/// stays flat while a `Full` theme lights up — and `with_decor` is the explicit
/// override. With no theme applied this resolves to flat (today's render), so
/// existing consumers are unaffected by default.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum DecorPolicy {
    /// Today's flat render — no decor, regardless of theme.
    Off,
    /// Follow the active theme's [`EffectsPolicy`] (the default).
    FromTheme,
    /// Glow halos + selection focus, no particles/strobe (accessibility).
    Reduced,
    /// The orgasmic mode: neon palette, breathing, glow+AO orbs, particle tracers,
    /// click→shockwave + subgraph focus.
    Full,
}

impl DecorPolicy {
    /// Resolve to a concrete [`EffectsPolicy`] given the published theme policy
    /// (read from the ui's context for [`FromTheme`](DecorPolicy::FromTheme)).
    fn resolve(self, ambient: EffectsPolicy) -> EffectsPolicy {
        match self {
            DecorPolicy::Off => EffectsPolicy::None,
            DecorPolicy::FromTheme => ambient,
            DecorPolicy::Reduced => EffectsPolicy::Reduced,
            DecorPolicy::Full => EffectsPolicy::Full,
        }
    }
    fn label(self) -> &'static str {
        match self {
            DecorPolicy::Off => "off",
            DecorPolicy::FromTheme => "from_theme",
            DecorPolicy::Reduced => "reduced",
            DecorPolicy::Full => "full",
        }
    }
}

/// **The node-disc INK BUDGET** — the share of the pane every node disc together may
/// cover before a cloud of points stops being points and becomes a fill.
///
/// The node radius here is `3.0 + (z + 1.0) * 2.5` — 3 to 8 px — and it did not depend
/// on how many nodes there were. A 40-node call graph and a 89 130-node road network
/// therefore drew the SAME disc, and at the large end the arithmetic is decisive:
/// 89 130 discs of mean radius 5.5 px cover 8.5 million px² on a 948 × 796 pane of
/// 754 608 px, an **eleven-fold** over-coverage. MEASURED on oden 2026-09-03 —
/// korp's Sweden world shot came out a solid white silhouette with a coloured fringe
/// (`~/scratch/ge-lane-proof/world-shot/sweden-3d-speed-z-flat.png`,
/// 11.6 % of the canvas above 235/255 on every channel), and the emissive GPU lane
/// accumulates, so it burned out rather than merely crowding.
///
/// 0.35 is the standard scatter rule — about a third of the surface inked still reads
/// as points with visible ground between them; past half it reads as a region. It is
/// applied as a SHRINK ([`ink_shrink`]) that is exactly `1.0` whenever the discs
/// already fit, so every existing cloud, snapshot and golden is byte-identical.
pub const NODE_INK_BUDGET: f32 = 0.35;

/// **The factor node radii are multiplied by** so `n` discs of radius `base_r` cover at
/// most [`NODE_INK_BUDGET`] of a `w × h` pane. `1.0` — no change at all — whenever they
/// already do, which is every cloud small enough for the budget to be irrelevant.
///
/// It does not rescue a scene the pane cannot hold: at 89 130 nodes clumped into 5 % of
/// korp's canvas there are **0.42 pixels per node** in the occupied region, and no
/// radius makes 0.42 px legible. What it does is turn a saturated white blob into a
/// density texture, where the road network's own structure is what varies. The wall is
/// the pixel count, and this makes the wall visible instead of hiding it under white.
#[must_use]
pub fn ink_shrink(n: usize, w: f32, h: f32, base_r: f32) -> f32 {
    if n == 0 || w <= 0.0 || h <= 0.0 || base_r <= 0.0 {
        return 1.0;
    }
    let ink = n as f32 * std::f32::consts::PI * base_r * base_r;
    let budget = NODE_INK_BUDGET * w * h;
    if ink <= budget { 1.0 } else { (budget / ink).sqrt() }
}

/// The mean node radius the budget is computed against — the midpoint of the
/// depth-scaled `3.0 + (z + 1.0) * 2.5` over `z ∈ [-1, 1]`. Named once so the painter
/// and the instanced GPU scene cannot compute the shrink against different bases.
pub(crate) const BASE_NODE_RADIUS: f32 = 5.5;

/// A node at a 3D position (normalised ~[-1, 1]).
#[derive(Clone)]
pub struct Node3 {
    pub label: String,
    pub color: Color32,
    pub pos: [f32; 3],
}

/// The **pure** per-node orthographic projection kernel: rotate `p` about Y by the
/// precomputed `(sin, cos)` of the yaw, drop to the screen plane, and return
/// `(screen, depth)` (depth in `-1..1`). Shared by the serial `Graph3D::project`
/// path AND the gatling fan-out ([`Graph3D::project_all`]) so both compute the
/// **byte-identical** result — the per-node math is a deterministic function of its
/// inputs, order-independent, no accumulation.
#[inline]
fn project_pt(p: [f32; 3], center: Pos2, scale: f32, sin: f32, cos: f32) -> (Pos2, f32) {
    let x = p[0] * cos + p[2] * sin;
    let z = -p[0] * sin + p[2] * cos;
    (center + vec2(x, -p[1]) * scale, z)
}

/// Node count at/above which projecting the cloud is fanned across every core, through
/// the **parked** gatling pool ([`project_all`](Graph3D::project_all)). Below it the
/// serial map is faster. The kernel is identical on both lanes, so the output is
/// byte-for-byte the same either way and small snapshot goldens are unaffected.
///
/// # This number is MEASURED, and it has been wrong twice
///
/// Reproduce with `cargo run --release -p facett-graph3d --example aos_crossover`.
/// oden (Threadripper PRO 3975WX, 32 threads), 2026-09-05, projecting the **real
/// `&[Node3]`** — serial vs the parked pool:
///
/// | nodes | serial | pooled | |
/// |---|---|---|---|
/// | 30 000 | 0.032 ms | 0.434 ms | 0.07× — pool 14× SLOWER |
/// | 100 000 | 0.098 ms | 0.301 ms | 0.33× — pool 3× slower |
/// | 300 000 | 0.953 ms | 1.160 ms | 0.82× — still slower |
/// | 1 000 000 | 3.447 ms | 2.222 ms | **1.55× — pool finally wins** |
///
/// So the crossover is around **half a million nodes**, and every graph any host draws
/// today (korp's 3D wireframe and facett-demo's `graph3d` tab both cap at 100 000) runs
/// the SERIAL arm. That is not a defeat: the lane this replaced — `gatling_for_each` at
/// grain 1 — cost **4.032 ms** at 100 000 where serial costs 0.098 ms.
///
/// **Both previous values were set from a measurement of the wrong thing.** `2_048`
/// assumed dispatch was cheap; it is ~0.5 ms of thread creation per call. `100_000` came
/// from a probe that fed the pool a packed `Vec<[f32;3]>` — a layout this crate does not
/// have. Projecting out of `Node3 { String, Color32, [f32;3] }` drags ~40 bytes for every
/// 12 that matter, so a cache line carries barely one point and the whole kernel is
/// memory-bound. Packing the positions is worth **2.3–4.1×** on the pooled lane (same
/// run) and would pull this threshold down toward 200 000 — the real fix, not yet done.
///
/// If you change this number, change it from a run of `aos_crossover`, not from a probe
/// over a layout the crate does not use.
const PROJ_PARALLEL_MIN: usize = 500_000;

/// The process-wide **parked** projection pool, created on first use and never on a
/// process that does not render a cloud this big. See [`PROJ_PARALLEL_MIN`] for why the
/// threshold is where it is.
static PROJ_POOL: std::sync::OnceLock<znippy_zoomies::gatling_map::Pool> = std::sync::OnceLock::new();

#[inline]
fn proj_pool() -> &'static znippy_zoomies::gatling_map::Pool {
    PROJ_POOL.get_or_init(|| znippy_zoomies::gatling_map::Pool::new(0))
}

/// 3D placement strategies (positions are filled by [`Graph3D::layout`]).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum Layout3D {
    /// Fibonacci sphere — even spread on a sphere shell.
    Sphere,
    /// Helix along the Y axis.
    Helix,
    /// Deterministic 3D Fruchterman–Reingold — edges pull, all nodes repel. The
    /// "call graph" look. Exact O(n²) for small graphs; at/above
    /// [`facett_core::barnes_hut::BH_THRESHOLD`] the repulsion runs through the shared
    /// Barnes–Hut octree (O(n log n), parallel) so it scales to 30 000+ nodes.
    Force,
    /// Regular 3D **grid** lattice — nodes on an even cube grid (row-major), no
    /// forces, no shell. A clean spread-out wireframe: every node visible, edges as
    /// straight struts, nothing clumps into a cloud.
    Grid,
    /// **THE HOST PLACED THEM.** Positions come from
    /// [`Graph3D::with_positions`](Graph3D::with_positions), not from this crate.
    ///
    /// Every other variant here derives a position from the node INDEX or from the
    /// edge set, which is the right answer for a graph that knows nothing about
    /// itself — and the wrong one for a graph whose nodes have a place in the world.
    /// A road junction is *at* a lon/lat; a node ranked by a measured metric belongs
    /// at that height. Neither is expressible as "the i-th point on a sphere", so
    /// before this variant a consumer had no way to say it: writing through the `pub
    /// nodes` field worked until the next [`Msg::SetLayout`] or [`Graph3D::absorb`]
    /// silently overwrote the lot.
    ///
    /// The given placement is RETAINED, so switching to another layout and back
    /// restores it. A placement that does not cover every node falls back to
    /// [`Grid`](Layout3D::Grid) and raises `facet-graph3d-5` — the same law korp's
    /// geographic layout already runs on, and for the same reason: a partly-placed
    /// cloud draws the rest at the origin, which looks like data.
    ///
    /// The fallback is the LATTICE and not the force blob on purpose, twice over. It
    /// is `O(n)`, so constructing a `Given` graph before its placement arrives costs
    /// nothing — with the force fallback, `Graph3D::new(.., Given)` on korp's 89 130-node
    /// road slice ran a full Barnes–Hut layout that was thrown away one line later.
    /// And a regular cube lattice is unmistakably NOT data, where a force blob looks
    /// exactly like a graph somebody meant to draw.
    Given,
}

impl Layout3D {
    pub const ALL: [(Layout3D, &'static str); 5] = [
        (Layout3D::Sphere, "sphere"),
        (Layout3D::Helix, "helix"),
        (Layout3D::Force, "force"),
        (Layout3D::Grid, "grid"),
        (Layout3D::Given, "given"),
    ];
    pub fn label(self) -> &'static str {
        match self {
            Layout3D::Sphere => "sphere",
            Layout3D::Helix => "helix",
            Layout3D::Force => "force",
            Layout3D::Grid => "grid",
            Layout3D::Given => "given",
        }
    }
}

/// **The complete observable CONTROL state (FC-1 / FC-3)** of a [`Graph3D`], in one
/// serializable, round-trippable struct: the 3D camera (orbit `yaw` + auto-`spin` at
/// `spin_rate`), the active [`Layout3D`] + label toggle, the injected animation clock
/// (FC-7), the neon-[`DecorPolicy`], and the selected node index (FC-5). This is the
/// [`facett_core::Elm`] `Model` — [`Graph3D::state`] hands back a `&Graph3DView` and a
/// headless driver ([`facett_core::harness`]) snapshots it after feeding a `Vec<Msg>`.
///
/// The **raw node/edge geometry** and any wgpu buffers are NOT here — they are the
/// *input* the viewer renders (their `Color32` fills aren't serde-friendly and no
/// [`Msg`] mutates the cloud's identity), held as plain fields on [`Graph3D`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Graph3DView {
    /// The active 3D placement strategy.
    pub layout: Layout3D,
    /// Orbit angle (radians) about the Y axis — the camera.
    pub yaw: f32,
    /// Auto-spin the camera (paused on an orbit drag).
    pub spin: bool,
    /// Spin rate in radians/sec while `spin` is on (drives the injected-clock yaw).
    pub spin_rate: f32,
    /// Show billboarded, gyro-stabilized labels (§19). Off for big graphs.
    pub show_labels: bool,
    /// **Injected animation clock** (seconds; FC-7). When `Some`, the spin yaw is a
    /// *pure* function `yaw_base + clock_s·spin_rate`, so two renders from the same
    /// state + clock produce identical frames + AccessKit. When `None`, the spin
    /// follows egui's ambient `stable_dt` (the live, real-time UX).
    pub clock_s: Option<f64>,
    /// How much neon decor to paint (default [`DecorPolicy::FromTheme`]).
    pub decor: DecorPolicy,
    /// The selected (clicked) node index, if any — its neighbour subgraph is lit and
    /// the rest dimmed. An addressable value keyed on the stable node index (FC-5),
    /// invariant across camera spin and relayout.
    pub selected: Option<usize>,
    /// **Draw the inline control row** above the canvas — the "3D rotating" + "labels"
    /// checkboxes (and the node/edge count), opt-in and **off by default** so the bare
    /// viewer and every existing headless snapshot are byte-identical. On, the two
    /// toggles that were reachable only through [`Msg`] become reachable with the
    /// mouse; the checkboxes emit [`Msg::SetSpin`] / [`Msg::SetShowLabels`], so the
    /// gesture and the driver walk the SAME FC-2 path.
    ///
    /// Hosts carried their own copy of this row (dwarves' warehouse-nav world graph,
    /// its funnel 3D DAG, its kernel scene — three spellings of one checkbox) because
    /// the pane never offered one. This is that row, once, where the state lives.
    #[serde(default)]
    pub controls: bool,
    /// **The camera target** — the world point projected at the canvas centre (the
    /// orbit's look-at). `[0; 3]` = the historical framing.
    #[serde(default)]
    pub focus: [f32; 3],
    /// **The camera zoom** on top of the pane-fit scale (`1` = the historical framing;
    /// the orthographic twin of the orbit distance: distance ∝ 1 / zoom).
    #[serde(default = "one")]
    pub zoom: f32,
    /// What a click on a node does to the camera: 📌 Stay / 🎯 Focus
    /// ([`facett_core::fly`]).
    #[serde(default)]
    pub nav_mode: facett_core::fly::NavMode,
    /// Whether `nav_mode` was set (else the first frame loads the viewer's choice).
    #[serde(default)]
    pub nav_mode_set: bool,
    /// A host-placed cloud ([`Layout3D::Given`]) was framed on its first paint (fitted
    /// and centred in the canvas). The generated layouts are centred in `[-1, 1]³` by
    /// construction and need none.
    #[serde(default)]
    pub framed: bool,
    /// The camera flight under way (target + zoom on one eased curve; UI time).
    #[serde(skip)]
    pub flight: Option<facett_core::fly::Flight>,
}

fn one() -> f32 {
    1.0
}

/// The zoom range a viewer can reach: far out to see a whole crowd, close enough for
/// one node to fill the pane.
pub const ZOOM_MIN: f32 = 0.1;
/// See [`ZOOM_MIN`].
pub const ZOOM_MAX: f32 = 400.0;

/// The most 🎯 Focus zooms in (a node with no neighbours lands at [`FOCUS3D_ALONE`]).
pub const FOCUS3D_MAX: f32 = 4.0;
/// The zoom 🎯 Focus lands a node with no neighbours at.
pub const FOCUS3D_ALONE: f32 = 2.0;

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the [`Graph3DView`] control state through, the same
/// effect a canvas gesture produces. Applied by [`Graph3D::update`], the **only**
/// mutation path for the Model.
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    /// Orbit the camera by a drag delta-x: `yaw += dx·0.01`, and pause the auto-spin
    /// (the drag-to-rotate gesture).
    DragYaw(f32),
    /// Set the orbit angle (yaw) directly (programmatic / a pasted portable state).
    SetYaw(f32),
    /// Advance the live auto-spin by `dt` seconds: `yaw += dt·spin_rate` (the ambient
    /// real-time spin, emitted per frame while spinning with no injected clock).
    AdvanceSpin(f32),
    /// Enable/disable the auto-spin.
    SetSpin(bool),
    /// Reset the camera: yaw → the base `0.6`, spin back on (the ⊙ fit affordance).
    Fit,
    /// Switch the 3D [`Layout3D`] — recomputes the node positions.
    SetLayout(Layout3D),
    /// Toggle the billboarded labels.
    SetShowLabels(bool),
    /// Show/hide the inline control row (the "3D rotating" + "labels" checkboxes).
    SetControls(bool),
    /// Set the neon-[`DecorPolicy`].
    SetDecor(DecorPolicy),
    /// Select (light) a node by its **stable index** (its neighbour subgraph lights,
    /// the rest dims; FC-5). Out-of-range indices are ignored.
    SelectNode(usize),
    /// 📌 Stay / 🎯 Focus.
    SetNavMode(facett_core::fly::NavMode),
    /// 🎯 Focus node `i`: fly the target to it (it lands in the canvas centre) and the
    /// zoom to a readable framing of it and its neighbours in a `viewport` (px) canvas,
    /// keeping the view direction. `now` = the UI clock (s).
    FocusNode { i: usize, now: f64, viewport: (f32, f32) },
    /// ⊙ fit: fly back to the whole graph (target 0, zoom 1 — or, for a host-placed
    /// cloud, its own frame), keeping the view direction. Carries the canvas size.
    FitFly(f64),
    /// Put the camera at `(focus, zoom)` at once (the first frame of a host-placed cloud).
    FrameAt { focus: [f32; 3], zoom: f32 },
    /// Advance a camera flight to `now`.
    FlyTick(f64),
    /// **Zoom by `factor` toward a screen point**: `at` = the point's offset from the
    /// canvas centre (px), `px_scale` = the pane's fit scale (`min(w, h) · 0.40`). The
    /// world point under `at` stays under it (the wheel / pinch / ± gesture).
    ZoomAt { factor: f32, at: [f32; 2], px_scale: f32 },
    /// Clear the selection (an empty-canvas click, or a programmatic clear).
    ClearSelection,
    /// Pin the injected animation clock to `seconds` (FC-7 determinism).
    SetClock(f64),
    /// Advance the injected clock by `dt` seconds (initialising from 0 if unset).
    Tick(f64),
    // ── TRAVELS controls (guided tour; no-ops when no route is set) ───────────────
    /// Play/resume the guided tour.
    TravelPlay,
    /// Pause the guided tour.
    TravelPause,
    /// Toggle play/pause of the guided tour.
    TravelToggle,
    /// Jump to the next tour stop.
    TravelNextStop,
    /// Jump to the previous tour stop.
    TravelPrevStop,
    /// Set the tour timeline speed multiplier.
    TravelSpeed(f32),
    /// Advance ONLY the tour timeline by `dt` seconds (the live-ambient tour tick — it
    /// does NOT pin the graph's animation clock, so the camera spin stays live).
    TravelTick(f64),
    /// **Set the per-edge rates** (feature `rate`) — one `Option<f32>` per edge,
    /// indexed like the edge list (facett-flow's `Msg::Rates`, in 3D). A table
    /// whose length does not match the edge list, or that carries a non-finite
    /// or negative rate, is REFUSED (ignored), exactly like flow's `set_rates`.
    #[cfg(feature = "rate")]
    SetRates(rate::EdgeRates),
    /// Clear the rates — back to the plain (unrated) render.
    #[cfg(feature = "rate")]
    ClearRates,
}

/// Side work as data (FC-8). The viewer does no I/O — every [`Msg`] mutates only the
/// in-memory [`Graph3DView`] (a [`SetLayout`](Msg::SetLayout) also repositions the
/// in-memory cloud) — so this is uninhabited on purpose: the type-checked statement
/// that [`Graph3D::update`] never asks the host to do anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}

/// The 3D graph viewer.
pub struct Graph3D {
    pub title: String,
    /// The node cloud — the *input* geometry (positions filled by [`layout`](Self::layout)),
    /// held OUTSIDE the [`Graph3DView`] Model. `pub` so a caller reads back the
    /// laid-out positions (the shared-placement seam nornir's funnel3d uses).
    pub nodes: Vec<Node3>,
    /// The index edges — input geometry, held OUTSIDE the Model.
    pub edges: Vec<(usize, usize)>,
    /// All observable CONTROL state (FC-1 / FC-3) — the [`facett_core::Elm`] Model.
    pub view: Graph3DView,
    /// **The optional guided TOUR** ([`travels::Travel`]) — a glowing ghost + camera
    /// flying a spline through a route of node ids. `None` by default (**default OFF**),
    /// so every existing render/golden is byte-identical; when a route is set the ghost
    /// overlay paints and the camera flies. Held OUTSIDE the Model (render input +
    /// deterministic animation, like `nodes`/`edges`); its observable surface is merged
    /// into `state_json` only when present.
    pub travel: Option<travels::Travel>,
    /// **The model VERSION** — bumped by every path that moves a node, recolours one, or
    /// changes the edge set. A GPU host holds `CloudBuffers` across frames and re-uploads
    /// only when this number moves; that is what turns a 96 MB-per-frame instance stream
    /// into a 48-byte camera write.
    ///
    /// # The seam this cannot see, stated plainly
    /// `nodes` is `pub`, and `codevault` documents writing `Node3::pos` through it. A
    /// counter cannot observe a write to a public field, so any caller that pokes `nodes`
    /// directly MUST call [`touch`](Self::touch) — the two in-tree callers that do are
    /// `codevault::set_positions` and `facet-wrapped`'s `Layout3D::Given` path, both
    /// updated. The by-construction fix is to make `nodes` private behind a mutator that
    /// bumps; that is ~90 call sites inside this crate and is deliberately not attempted
    /// here. Until it happens, a missed `touch` shows as a FROZEN cloud — visible, not
    /// silent, which is why this is a safe interim.
    generation: u64,
    /// Per-node smoothed label anchor (screen space) — the temporal-smoothing state
    /// that kills label bobbing (GYRO-2). **Ephemeral paint cache, not observable
    /// state** — interior-mutable so the FC-9 pure [`view`](Self::view) can record it
    /// through a `&self` borrow (FC-7); deliberately kept OUT of the Model. Rebuilt
    /// to match `nodes.len()`.
    label_anchors: RefCell<Vec<Option<egui::Pos2>>>,
    /// Last frame's `(node_stride, edge_stride)` on the CPU-painter fallback lane —
    /// `(1, 1)` when the whole cloud was painted (or when the GPU lane carried it).
    /// Ephemeral paint cache (FC-7), published in `state_json` so the budget's effect
    /// is observable data rather than an invisible degradation. See
    /// [`CPU_PAINT_MAX_NODES`].
    last_cpu_stride: std::cell::Cell<(usize, usize)>,
    /// The whole-cloud frame for the last painted canvas ([`Graph3D::frame_camera`]) —
    /// what ⊙ fit flies to (the update has no canvas size).
    view_frame: std::cell::Cell<Option<([f32; 3], f32)>>,
    /// **The HOST's placement** for [`Layout3D::Given`] — empty when none was
    /// supplied. Held here rather than written straight into `nodes` so it survives a
    /// round trip through another layout: `Given → Sphere → Given` restores exactly
    /// what the host handed over, instead of the sphere it was overwritten with.
    given: Vec<[f32; 3]>,
    /// **The per-edge RATES** (feature `rate`) — `None` until a consumer sets
    /// them, so every existing render is byte-identical. Indexed like `edges`;
    /// held OUTSIDE the Model like the geometry it annotates (the raw per-edge
    /// attribute table is input, not control state — the FC input is
    /// [`Msg::SetRates`], the clock is the already-injected FC-7 clock).
    #[cfg(feature = "rate")]
    rates: Option<rate::EdgeRates>,
    /// **Host opt-in to the instanced GPU cloud paint** (feature `wgpu`). When set (and
    /// the host installed [`graph_gpu::cloud::install_cloud_renderer`]), the FC-9
    /// [`view`](Self::view) draws the 30k node/edge cloud as GPU **instances** via an
    /// `egui_wgpu` callback instead of egui tessellating 30k discs on ONE CPU core —
    /// the rotate-bench single-core paint wall. Default `false`: the CPU painter draws
    /// (the snapshot golden path), so all existing snapshots stay byte-identical.
    #[cfg(feature = "wgpu")]
    use_gpu: bool,
}

impl Graph3D {
    /// Build from `(label, color)` nodes + edges; positions come from `layout`.
    pub fn new(nodes: Vec<(String, Color32)>, edges: Vec<(usize, usize)>, layout: Layout3D) -> Self {
        let nodes: Vec<Node3> = nodes.into_iter().map(|(label, color)| Node3 { label, color, pos: [0.0; 3] }).collect();
        let n = nodes.len();
        let mut g = Self {
            title: "graph3d".into(),
            nodes,
            edges,
            view: Graph3DView {
                layout,
                yaw: 0.6,
                spin: true,
                spin_rate: 0.4,
                // Labels readable only when the graph is small enough to not clutter.
                show_labels: n <= 40,
                clock_s: None,
                decor: DecorPolicy::FromTheme,
                selected: None,
                // OFF by default: the canvas-only pane is what every existing golden
                // and every current host renders.
                controls: false,
                focus: [0.0; 3],
                zoom: 1.0,
                nav_mode: facett_core::fly::NavMode::Stay,
                nav_mode_set: false,
                framed: false,
                flight: None,
            },
            travel: None,
            generation: 0,
            label_anchors: RefCell::new(vec![None; n]),
            last_cpu_stride: std::cell::Cell::new((1, 1)),
            view_frame: std::cell::Cell::new(None),
            given: Vec::new(),
            #[cfg(feature = "rate")]
            rates: None,
            #[cfg(feature = "wgpu")]
            use_gpu: false,
        };
        g.layout();
        g
    }

    /// **Place the cloud from the HOST's own coordinates** ([`Layout3D::Given`]).
    ///
    /// `pos` is one `[x, y, z]` per node, in the crate's normalised `~[-1, 1]` cube —
    /// the host owns the projection (korp runs its road junctions through the same
    /// `mapquery::merc_xy` the map uses) and the metric that earns the z axis. The
    /// vector is RETAINED: another layout can be selected and this one selected back.
    ///
    /// A `pos` shorter than the node list is not drawn: [`layout`](Self::layout)
    /// falls back to [`Layout3D::Force`] and the pane reports `facet-graph3d-5`,
    /// because a half-placed cloud puts the remainder at the origin and that reads as
    /// data. Longer is fine — the tail is ignored.
    #[must_use]
    pub fn with_positions(mut self, pos: Vec<[f32; 3]>) -> Self {
        self.set_positions(pos);
        self
    }

    /// [`with_positions`](Self::with_positions) on an existing graph — re-lays out
    /// immediately when [`Layout3D::Given`] is the selected layout.
    pub fn set_positions(&mut self, pos: Vec<[f32; 3]>) {
        // Bumped at ENTRY, not at exit: this is a VERSION, not a count, so bumping twice
        // (this call plus the `layout()` below) is harmless, and entry cannot be skipped
        // by an early return the way an exit-side bump can.
        self.touch();
        self.given = pos;
        if self.view.layout == Layout3D::Given {
            self.layout();
        }
    }

    /// The host placement supplied through [`with_positions`](Self::with_positions),
    /// empty when none was.
    #[must_use]
    pub fn positions(&self) -> &[[f32; 3]] {
        &self.given
    }

    /// **Does the selected layout actually cover every node?** `false` only for
    /// [`Layout3D::Given`] with a short (or absent) placement — the one case where
    /// what is DRAWN is not what was CHOSEN. Read by
    /// [`layout`](Self::layout), by `state_json` and by `severity`, so the picture,
    /// the report and the gate signal cannot disagree.
    #[must_use]
    pub fn placement_is_complete(&self) -> bool {
        self.view.layout != Layout3D::Given || self.given.len() >= self.nodes.len()
    }

    /// **wgpu feature.** Opt this graph into the instanced **GPU cloud paint** — the
    /// host must also install the renderer once via
    /// [`graph_gpu::cloud::install_cloud_renderer`]. With it on, rotating a large cloud
    /// draws the nodes/edges as GPU instances (no per-frame single-core egui
    /// tessellation of 30k discs); off (the default) the CPU painter draws, unchanged.
    #[cfg(feature = "wgpu")]
    pub fn with_gpu(mut self, on: bool) -> Self {
        self.use_gpu = on;
        self
    }
    pub fn with_title(mut self, t: impl Into<String>) -> Self {
        self.title = t.into();
        self
    }

    /// The current model version — see [`generation`](Self::generation) on the field.
    #[must_use]
    pub fn generation(&self) -> u64 {
        self.generation
    }

    /// **Declare the model changed.** Call after writing `Node3::pos`/`color` through the
    /// public `nodes` seam, or a GPU host holding uploaded buffers will keep drawing the
    /// old cloud. Every mutator ON this type calls it already.
    pub fn touch(&mut self) {
        self.generation = self.generation.wrapping_add(1);
    }

    /// **The cloud model** — the node array in the form the GPU wants: one
    /// `CloudPoint` per node, 16 bytes, position plus packed RGBA8. Built on a
    /// [`generation`](Self::generation) change, not per frame.
    #[cfg(feature = "wgpu")]
    #[must_use]
    pub fn cloud_points(&self) -> Vec<facett_core::render::gpu::CloudPoint> {
        self.nodes
            .iter()
            .map(|n| facett_core::render::gpu::CloudPoint::new(n.pos, n.color))
            .collect()
    }

    /// The edge list as GPU index pairs — 8 bytes each, and camera-independent, so it
    /// survives every frame of a spin untouched.
    #[cfg(feature = "wgpu")]
    #[must_use]
    pub fn cloud_edges(&self) -> Vec<facett_core::render::gpu::CloudEdge> {
        self.edges
            .iter()
            .filter(|&&(a, b)| a < self.nodes.len() && b < self.nodes.len())
            .map(|&(a, b)| [a as u32, b as u32])
            .collect()
    }

    /// **FC-3** — read the complete observable control state (camera/layout/selection)
    /// at any frame boundary.
    pub fn state(&self) -> &Graph3DView {
        &self.view
    }

    /// **Pin the animation clock** (seconds; FC-7). The spin yaw becomes a pure
    /// function of this value (`yaw + clock·spin_rate` applied per frame), so an
    /// injected/host clock makes the rotation reproducible — identical state +
    /// clock ⇒ identical frame + AccessKit tree. Routes through the FC-2
    /// [`update`](Self::update) mutation path.
    pub fn set_clock(&mut self, seconds: f64) {
        let _ = self.update(Msg::SetClock(seconds));
    }

    /// Advance the injected clock by `dt` seconds (initialising from 0 if unset) —
    /// the host-driven analogue of [`Msg::Tick`] for deterministic stepping.
    pub fn tick(&mut self, dt: f64) {
        let _ = self.update(Msg::Tick(dt));
    }

    /// Builder form of [`set_clock`](Self::set_clock).
    pub fn with_clock(mut self, seconds: f64) -> Self {
        self.view.clock_s = Some(seconds);
        self
    }

    /// Set the auto-spin **rate** (radians/sec) — how fast the camera orbits while
    /// `spin` is on. A livelier rate makes the cloud read as ALIVE (used by the
    /// attract-mode `Graphs` tab). Clamped non-negative; does not touch the yaw.
    pub fn set_spin_rate(&mut self, rate: f32) {
        self.view.spin_rate = rate.max(0.0);
    }

    /// Builder form of [`set_spin_rate`](Self::set_spin_rate).
    pub fn with_spin_rate(mut self, rate: f32) -> Self {
        self.view.spin_rate = rate.max(0.0);
        self
    }

    /// **Draw the inline control row** above the canvas — a "3D rotating" checkbox
    /// (the auto-spin) and a "labels" checkbox, plus the node/edge count. Off by
    /// default, so a host that does not ask for it renders exactly what it always
    /// did. Routes through the FC-2 [`update`](Self::update) path.
    pub fn set_controls(&mut self, on: bool) {
        let _ = self.update(Msg::SetControls(on));
    }

    /// Builder form of [`set_controls`](Self::set_controls).
    pub fn with_controls(mut self, on: bool) -> Self {
        self.view.controls = on;
        self
    }

    /// **Explicit decor override** (§look-and-feel): force the neon-decor level
    /// instead of following the active theme's [`EffectsPolicy`]. `Off` is today's
    /// flat render; `Full` is the orgasmic mode.
    pub fn with_decor(mut self, policy: DecorPolicy) -> Self {
        self.view.decor = policy;
        self
    }

    /// Select (light) a node and its neighbour subgraph, dimming the rest; `None`
    /// clears the focus. Builder + setter forms for hosts and the click handler.
    pub fn with_selected(mut self, node: usize) -> Self {
        self.view.selected = (node < self.nodes.len()).then_some(node);
        self
    }
    /// A thin wrapper over the FC-2 [`update`](Self::update) mutation path.
    pub fn select(&mut self, node: Option<usize>) {
        let _ = match node {
            Some(i) => self.update(Msg::SelectNode(i)),
            None => self.update(Msg::ClearSelection),
        };
    }

    /// **Set the guided TOUR** (the owner's TRAVELS) over a [`travels::TravelRoute`]. The
    /// spline control points are the route stops' laid-out node centres, so this must
    /// be called after [`new`](Self::new)/[`Msg::SetLayout`] positions the cloud. `None`
    /// clears the tour (back to the plain render). Additive — no tour until this is set.
    pub fn set_travel(&mut self, route: travels::TravelRoute) {
        let centers: Vec<[f32; 3]> = self.nodes.iter().map(|n| n.pos).collect();
        self.travel = Some(travels::Travel::new(route, &centers));
    }
    /// Clear the tour.
    pub fn clear_travel(&mut self) {
        self.travel = None;
    }
    /// Builder form of [`set_travel`](Self::set_travel).
    pub fn with_travel(mut self, route: travels::TravelRoute) -> Self {
        self.set_travel(route);
        self
    }
    /// Read the active tour (if any).
    pub fn travel(&self) -> Option<&travels::Travel> {
        self.travel.as_ref()
    }
    /// The camera yaw that faces the tour's ghost marker (the "camera fly" target) —
    /// `None` when no tour, so the host applies it only while touring.
    pub fn travel_camera_yaw(&self) -> Option<f32> {
        self.travel.as_ref().filter(|t| t.is_active() && t.camera_follow()).map(|t| t.camera_yaw())
    }

    /// **Set the per-edge rates** (feature `rate`) — the inherent twin of
    /// [`Msg::SetRates`], validated exactly like facett-flow's `set_rates`:
    /// one optional, finite, non-negative value per edge, or the whole table
    /// is refused and the current rates stand.
    #[cfg(feature = "rate")]
    pub fn set_rates(&mut self, rates: rate::EdgeRates) -> Result<(), String> {
        if rates.edges.len() != self.edges.len()
            || rates.edges.iter().flatten().any(|v| !v.is_finite() || *v < 0.0)
        {
            return Err("Rates require one finite nonnegative optional value per edge".into());
        }
        self.rates = Some(rates);
        Ok(())
    }

    /// Builder form of [`set_rates`](Self::set_rates) — an invalid table is
    /// dropped (the builder cannot report, exactly like `with_positions`).
    #[cfg(feature = "rate")]
    #[must_use]
    pub fn with_rates(mut self, rates: rate::EdgeRates) -> Self {
        let _ = self.set_rates(rates);
        self
    }

    /// The rates a consumer set, `None` until [`set_rates`](Self::set_rates).
    #[cfg(feature = "rate")]
    #[must_use]
    pub fn rates(&self) -> Option<&rate::EdgeRates> {
        self.rates.as_ref()
    }

    /// The scene's maximum rate (0 when none is set) — what pulse speed and
    /// brightness are normalised against.
    #[cfg(feature = "rate")]
    #[must_use]
    pub fn max_rate(&self) -> f32 {
        self.rates
            .as_ref()
            .map(|r| r.edges.iter().flatten().fold(0.0f32, |m, &v| m.max(v)))
            .unwrap_or(0.0)
    }

    /// **The projected, depth-carrying curve of rated edge `i`** — the sampled
    /// 3D arc ([`rate::edge_curve`]) pushed through the SAME camera the cloud
    /// paints with, as `(screen, depth)` per sample. `None` for an unrated or
    /// out-of-range edge. This is the seam the painter draws from and the seam
    /// the tests assert on, so the picture and the proof cannot disagree.
    #[cfg(feature = "rate")]
    #[must_use]
    pub fn rated_edge_projection(&self, i: usize, center: Pos2, scale: f32, yaw: f32) -> Option<Vec<(Pos2, f32)>> {
        self.rates.as_ref()?.edges.get(i).copied().flatten()?;
        let &(a, b) = self.edges.get(i)?;
        if a >= self.nodes.len() || b >= self.nodes.len() {
            return None;
        }
        let pts = rate::edge_curve(self.nodes[a].pos, self.nodes[b].pos, rate::CURVE_SEGMENTS);
        Some(pts.into_iter().map(|p| self.project(p, center, scale, yaw)).collect())
    }

    /// The injected-clock **effect phase** (cycles) — the *deterministic* phase used
    /// by `state_json`. `0.0` when no clock is injected (the reported value is
    /// clock-only so the observable surface stays pure).
    fn effect_phase(&self) -> f32 {
        self.view.clock_s.unwrap_or(0.0) as f32 * EFFECT_RATE
    }

    /// The **render** effect phase (cycles) driving every decor animation —
    /// breathing/strobe/particles/shockwave. With an injected clock it is a pure
    /// function of that clock (a pinned clock ⇒ a byte-stable frame, the determinism
    /// LAW). Without one it follows the host's `ambient_time_s` (egui's `i.time`),
    /// exactly like the live spin accumulates ambient `dt` — so the neon breathes in
    /// real time in a live app without the host having to wire a clock.
    fn render_phase(&self, ambient_time_s: f64) -> f32 {
        self.view.clock_s.unwrap_or(ambient_time_s) as f32 * EFFECT_RATE
    }

    /// The direct neighbours of node `s` (both edge directions), sorted + deduped.
    fn neighbours(&self, s: usize) -> Vec<usize> {
        let n = self.nodes.len();
        let mut set = std::collections::BTreeSet::new();
        for &(a, b) in &self.edges {
            if a == s && b < n {
                set.insert(b);
            }
            if b == s && a < n {
                set.insert(a);
            }
        }
        set.into_iter().collect()
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s the host should run. Every canvas gesture routes its
    /// [`Msg`] here too, so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::DragYaw(dx) => {
                self.view.yaw += dx * 0.01;
                self.view.spin = false;
                // a drag takes the camera from wherever the flight had it
                self.view.flight = None;
            }
            Msg::SetNavMode(m) => {
                self.view.nav_mode = m;
                self.view.nav_mode_set = true;
            }
            Msg::FocusNode { i, now, viewport } => {
                if let Some((target, zoom)) = self.focus_camera(i, viewport) {
                    self.fly_to(target, zoom, now);
                }
            }
            Msg::FitFly(now) => {
                let (f, z) = self.view_frame.get().unwrap_or(([0.0; 3], 1.0));
                self.fly_to(f, z, now);
            }
            Msg::FrameAt { focus, zoom } => {
                self.view.flight = None;
                self.view.focus = focus;
                self.view.zoom = zoom.clamp(ZOOM_MIN, ZOOM_MAX);
                self.view.framed = true;
            }
            Msg::ZoomAt { factor, at, px_scale } => {
                if factor.is_finite() && factor > 0.0 && px_scale > 0.0 {
                    self.view.flight = None;
                    let z0 = self.view.zoom;
                    let z1 = (z0 * factor).clamp(ZOOM_MIN, ZOOM_MAX);
                    // keep the world point under `at` where it is: the target moves in the
                    // screen plane by at/px_scale · (1/z0 − 1/z1)
                    let k = 1.0 / z0 - 1.0 / z1;
                    let (px, py) = (at[0] / px_scale * k, at[1] / px_scale * k);
                    let (s, c) = self.render_yaw().sin_cos();
                    // screen x ↔ world (cos, 0, sin); screen y (down) ↔ world −y
                    let f = &mut self.view.focus;
                    f[0] += px * c;
                    f[2] += px * s;
                    f[1] -= py;
                    self.view.zoom = z1;
                }
            }
            Msg::FlyTick(now) => {
                if let Some(f) = self.view.flight {
                    let (c, w) = f.at(now);
                    self.view.focus = [c[0] as f32, c[1] as f32, c[2] as f32];
                    self.view.zoom = (1.0 / w.max(1e-9)) as f32;
                    if f.done(now) {
                        self.view.flight = None;
                    }
                }
            }
            Msg::SetYaw(y) => self.view.yaw = y,
            Msg::AdvanceSpin(dt) => self.view.yaw += dt * self.view.spin_rate,
            Msg::SetSpin(s) => self.view.spin = s,
            Msg::Fit => {
                self.view.yaw = 0.6;
                self.view.spin = true;
            }
            Msg::SetLayout(l) => {
                self.view.layout = l;
                self.layout();
            }
            Msg::SetShowLabels(b) => self.view.show_labels = b,
            Msg::SetControls(b) => self.view.controls = b,
            Msg::SetDecor(d) => self.view.decor = d,
            Msg::SelectNode(i) => self.view.selected = (i < self.nodes.len()).then_some(i),
            Msg::ClearSelection => self.view.selected = None,
            Msg::SetClock(s) => {
                self.view.clock_s = Some(s);
                // Drive the tour timeline off the SAME clock (determinism seam).
                if let Some(t) = self.travel.as_mut() {
                    let _ = t.seek(s);
                }
            }
            Msg::Tick(dt) => {
                self.view.clock_s = Some(self.view.clock_s.unwrap_or(0.0) + dt);
                if let Some(t) = self.travel.as_mut() {
                    let _ = t.advance(dt);
                }
            }
            Msg::TravelPlay => {
                if let Some(t) = self.travel.as_mut() {
                    t.play();
                }
            }
            Msg::TravelPause => {
                if let Some(t) = self.travel.as_mut() {
                    t.pause();
                }
            }
            Msg::TravelToggle => {
                if let Some(t) = self.travel.as_mut() {
                    if t.is_playing() {
                        t.pause();
                    } else {
                        t.play();
                    }
                }
            }
            Msg::TravelNextStop => {
                if let Some(t) = self.travel.as_mut() {
                    t.next_stop();
                }
            }
            Msg::TravelPrevStop => {
                if let Some(t) = self.travel.as_mut() {
                    t.prev_stop();
                }
            }
            Msg::TravelSpeed(s) => {
                if let Some(t) = self.travel.as_mut() {
                    t.set_speed(s);
                }
            }
            Msg::TravelTick(dt) => {
                if let Some(t) = self.travel.as_mut() {
                    let _ = t.advance(dt);
                }
            }
            #[cfg(feature = "rate")]
            Msg::SetRates(r) => {
                let _ = self.set_rates(r);
            }
            #[cfg(feature = "rate")]
            Msg::ClearRates => self.rates = None,
        }
        Vec::new()
    }

    /// (Re)compute node positions for the current layout.
    ///
    /// [`Layout3D::Given`] is the one layout this crate does not invent: with a
    /// complete host placement it is a copy, and with a short one it DEGRADES to
    /// [`Layout3D::Grid`] rather than leaving the unplaced tail at the origin (see
    /// [`placement_is_complete`](Self::placement_is_complete); the degradation is
    /// observable as `layout_fallback` + `facet-graph3d-5`, never silent).
    pub fn layout(&mut self) {
        self.touch();
        let n = self.nodes.len();
        if self.view.layout == Layout3D::Given && self.placement_is_complete() {
            for (node, p) in self.nodes.iter_mut().zip(self.given.iter()) {
                node.pos = *p;
            }
            return;
        }
        // Past this point `Given` means "short placement" and is drawn by the GRID
        // arm — one arm, so the fallback cannot drift from the layout it names.
        match self.view.layout {
            Layout3D::Sphere => {
                // Fibonacci sphere.
                let golden = std::f32::consts::PI * (3.0 - 5.0_f32.sqrt());
                for (i, node) in self.nodes.iter_mut().enumerate() {
                    let y = if n > 1 { 1.0 - (i as f32 / (n - 1) as f32) * 2.0 } else { 0.0 };
                    let r = (1.0 - y * y).max(0.0).sqrt();
                    let th = golden * i as f32;
                    node.pos = [th.cos() * r, y, th.sin() * r];
                }
            }
            Layout3D::Helix => {
                for (i, node) in self.nodes.iter_mut().enumerate() {
                    let t = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.5 };
                    let a = t * std::f32::consts::TAU * 3.0;
                    node.pos = [a.cos(), t * 2.0 - 1.0, a.sin()];
                }
            }
            Layout3D::Force => {
                // Deterministic 3D Fruchterman–Reingold, seeded on a sphere.
                let golden = std::f32::consts::PI * (3.0 - 5.0_f32.sqrt());
                let mut p: Vec<[f32; 3]> = (0..n)
                    .map(|i| {
                        let y = if n > 1 { 1.0 - (i as f32 / (n - 1) as f32) * 2.0 } else { 0.0 };
                        let r = (1.0 - y * y).max(0.0).sqrt();
                        let th = golden * i as f32;
                        [th.cos() * r, y, th.sin() * r]
                    })
                    .collect();
                let k = (1.0 / (n.max(1) as f32).cbrt()).clamp(0.05, 1.0);
                // The FR step body — repulsion + per-node attraction gather + a
                // temperature-capped integrate — lives ONCE in `facett_core::barnes_hut`
                // (the same delegation `callgraph_layouts::force2d` uses): the parallel
                // Barnes–Hut lane [`fr_step`] at/above `BH_THRESHOLD` (O(n log n), across
                // every core, deterministic — the 30k killer the old O(n²) all-pairs loop
                // couldn't meet), its single-core mirror [`fr_step_serial`] below it (exact
                // all-pairs repulsion, so the snapshot/determinism goldens stay byte-identical
                // — additive). `temp = 0.04` is the per-step displacement cap; attraction is
                // gathered from `adjacency(n, edges)` (multiplicity preserved, fixed order).
                let use_bh = n >= facett_core::barnes_hut::BH_THRESHOLD;
                let theta = facett_core::barnes_hut::BH_THETA;
                let adj = facett_core::barnes_hut::adjacency(n, &self.edges);
                // ── ITERATION BUDGET ──────────────────────────────────────────
                // Fruchterman–Reingold convergence is a property of the *step*
                // schedule, not of `n`: 100 passes was picked for graphs of a few
                // hundred and then paid, unchanged, by graphs of a hundred thousand
                // — 5.6 s of startup for a 100k cloud, all of it after the layout
                // had already settled. Above LARGE_GRAPH_ITER_N the budget drops to
                // LARGE_GRAPH_ITERS, which is measured to reach the same structure
                // (see `dataset_fills_the_volume_not_the_shell`) for a fraction of
                // the wall clock. Strictly below the threshold NOTHING changes, so
                // every existing golden and determinism test is byte-identical.
                let iters = if n >= LARGE_GRAPH_ITER_N { LARGE_GRAPH_ITERS } else { 100 };
                for _ in 0..iters {
                    p = if use_bh {
                        facett_core::barnes_hut::fr_step::<3>(&p, &adj, k, theta, 0.04)
                    } else {
                        facett_core::barnes_hut::fr_step_serial::<3>(&p, &adj, k, theta, 0.04)
                    };
                }
                // Normalise into the unit cube.
                let (mut mn, mut mx) = ([f32::MAX; 3], [f32::MIN; 3]);
                for v in &p {
                    for c in 0..3 {
                        mn[c] = mn[c].min(v[c]);
                        mx[c] = mx[c].max(v[c]);
                    }
                }
                for (node, v) in self.nodes.iter_mut().zip(p) {
                    node.pos = [0, 1, 2].map(|c| {
                        let span = (mx[c] - mn[c]).max(1e-3);
                        ((v[c] - mn[c]) / span - 0.5) * 2.0
                    });
                }
            }
            Layout3D::Grid | Layout3D::Given => {
                // Even 3D cube lattice (row-major) — no forces, no shell. Side = cbrt(n)
                // rounded up; each node at its grid cell, normalised to the unit cube.
                // A clean spread wireframe that never clumps into a cloud.
                let s = (n as f64).cbrt().ceil().max(1.0) as usize;
                let denom = s.saturating_sub(1).max(1) as f32;
                for (i, node) in self.nodes.iter_mut().enumerate() {
                    let ix = (i % s) as f32;
                    let iy = ((i / s) % s) as f32;
                    let iz = (i / (s * s)) as f32;
                    node.pos = [
                        (ix / denom) * 2.0 - 1.0,
                        (iy / denom) * 2.0 - 1.0,
                        (iz / denom) * 2.0 - 1.0,
                    ];
                }
            }
        }
    }

    /// **Incremental absorb** — grow the cloud by `new_nodes` + `new_edges` while
    /// KEEPING every existing node's 3-D position. New nodes are seeded at their
    /// already-placed neighbours' centroid and relaxed with a few LOCAL Barnes–Hut
    /// force iterations (existing nodes frozen), so a consumer (korp) can stream nodes
    /// into the scene WITHOUT the full [`layout`](Self::layout) rebuild `::new` does.
    ///
    /// The layout is frozen after [`new`](Self::new)/[`Msg::SetLayout`] (it is NOT
    /// re-run per frame — `tick` only advances the animation clock), so this is the
    /// one entry point that mutates positions after construction. For the index-driven
    /// [`Layout3D::Sphere`]/[`Layout3D::Helix`] there is nothing incremental to
    /// preserve, so the (cheap, O(n)) placement is simply recomputed.
    pub fn absorb(&mut self, new_nodes: Vec<(String, Color32)>, new_edges: Vec<(usize, usize)>) {
        self.touch();
        if new_nodes.is_empty() && new_edges.is_empty() {
            return;
        }
        let old_n = self.nodes.len();
        for (label, color) in new_nodes {
            self.nodes.push(Node3 { label, color, pos: [0.0; 3] });
        }
        for e in new_edges {
            self.edges.push(e);
        }
        let new_n = self.nodes.len();
        self.label_anchors.borrow_mut().resize(new_n, None);

        if self.view.layout != Layout3D::Force || new_n <= old_n {
            // Index-driven layout (or no growth): recompute the cheap placement.
            self.layout();
            return;
        }

        // Seed each new node at its already-placed neighbours' centroid (fallback: a
        // small deterministic offset from the cloud centroid), keeping existing frozen.
        let mut pos: Vec<[f32; 3]> = self.nodes.iter().map(|nd| nd.pos).collect();
        let centroid = {
            let mut c = [0.0f32; 3];
            if old_n > 0 {
                for p in &pos[..old_n] {
                    for d in 0..3 {
                        c[d] += p[d];
                    }
                }
                for cd in &mut c {
                    *cd /= old_n as f32;
                }
            }
            c
        };
        for i in old_n..new_n {
            let mut sum = [0.0f32; 3];
            let mut cnt = 0.0f32;
            for &(a, b) in &self.edges {
                let other = if a == i && b < i {
                    Some(b)
                } else if b == i && a < i {
                    Some(a)
                } else {
                    None
                };
                if let Some(o) = other {
                    for d in 0..3 {
                        sum[d] += pos[o][d];
                    }
                    cnt += 1.0;
                }
            }
            if cnt > 0.0 {
                let j = (i as f32 * 12.9898).sin() * 0.01;
                pos[i] = [sum[0] / cnt + j, sum[1] / cnt + j, sum[2] / cnt + j];
            } else {
                let a = std::f32::consts::TAU * (i - old_n) as f32 / (new_n - old_n).max(1) as f32;
                pos[i] = [centroid[0] + 0.05 * a.cos(), centroid[1] + 0.05 * a.sin(), centroid[2]];
            }
        }
        let k = (1.0 / (new_n.max(1) as f32).cbrt()).clamp(0.05, 1.0);
        facett_core::barnes_hut::relax_new::<3>(&mut pos, &self.edges, old_n, k, facett_core::barnes_hut::BH_THETA, 12, 0.04);
        for (nd, p) in self.nodes.iter_mut().zip(pos) {
            nd.pos = p;
        }
        // ── L4 functional-status: the incremental absorb path RAN, keeping old nodes ──
        #[cfg(feature = "testmatrix")]
        {
            let ok = self.nodes.iter().all(|nd| nd.pos.iter().all(|c| c.is_finite()));
            facett_core::testmatrix::emit(
                "facett-graph3d::Graph3D::absorb",
                "incremental_absorb",
                ok,
                &format!("kept={old_n} added={}", new_n - old_n),
            );
        }
    }

    /// Project a node to screen + a depth in `-1..1` after the Y-rotation by
    /// `yaw` radians (passed in so the render uses the injected-clock spin yaw).
    fn project(&self, p: [f32; 3], center: Pos2, scale: f32, yaw: f32) -> (Pos2, f32) {
        let (s, c) = yaw.sin_cos();
        let (f, scale) = (self.view.focus, scale * self.view.zoom);
        project_pt([p[0] - f[0], p[1] - f[1], p[2] - f[2]], center, scale, s, c)
    }

    /// The 🎯 Focus camera for node `i` in a `viewport` (px) canvas at the displayed
    /// yaw: target = the node (it lands in the centre), zoom = the most that keeps its
    /// neighbours inside the canvas (capped at [`FOCUS3D_MAX`]; [`FOCUS3D_ALONE`] for
    /// a node with none). `None` for a node that does not exist.
    #[must_use]
    pub fn focus_camera(&self, i: usize, viewport: (f32, f32)) -> Option<([f32; 3], f32)> {
        let p = self.nodes.get(i)?.pos;
        let (s, c) = self.render_yaw().sin_cos();
        let scale = viewport.0.min(viewport.1) * 0.40;
        let half = (viewport.0.min(viewport.1) * 0.5 - 24.0).max(8.0);
        let mut extent = 0.0f32;
        for j in self.neighbours(i) {
            let q = self.nodes[j].pos;
            let (sp, _) = project_pt([q[0] - p[0], q[1] - p[1], q[2] - p[2]], Pos2::ZERO, 1.0, s, c);
            extent = extent.max(sp.x.abs()).max(sp.y.abs());
        }
        let zoom = if extent > 0.0 { (half / (extent * scale)).min(FOCUS3D_MAX) } else { FOCUS3D_ALONE };
        Some((p, zoom.max(0.05)))
    }

    /// Start (or retarget, from where the camera IS) a flight to `target` / `zoom`,
    /// keeping the yaw — and stopping the auto-spin, so the view never turns under it.
    fn fly_to(&mut self, target: [f32; 3], zoom: f32, now: f64) {
        use facett_core::fly::Flight;
        let to = ([f64::from(target[0]), f64::from(target[1]), f64::from(target[2])], 1.0 / f64::from(zoom.max(1e-6)));
        if self.view.spin {
            self.view.yaw = self.render_yaw();
            self.view.spin = false;
        }
        self.view.flight = Some(match self.view.flight {
            Some(f) => f.retarget(now, to.0, to.1),
            None => {
                let f = self.view.focus;
                Flight::new(now, [f64::from(f[0]), f64::from(f[1]), f64::from(f[2])], 1.0 / f64::from(self.view.zoom.max(1e-6)), to.0, to.1)
            }
        });
    }

    /// **The frame of the whole cloud** in a `w × h` canvas at the displayed yaw: the
    /// camera target whose projection is the centre of the projected box, and the zoom
    /// that fits that box with a margin. For a host-placed cloud (any extent, anywhere);
    /// a generated layout's frame is the historical `([0; 3], 1)`.
    #[must_use]
    pub fn frame_camera(&self, w: f32, h: f32) -> ([f32; 3], f32) {
        if self.view.layout != Layout3D::Given || self.nodes.is_empty() {
            return ([0.0; 3], 1.0);
        }
        let (s, c) = self.render_yaw().sin_cos();
        let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
        for n in &self.nodes {
            let p = n.pos;
            let (x, y) = (p[0] * c + p[2] * s, p[1]);
            x0 = x0.min(x);
            x1 = x1.max(x);
            y0 = y0.min(y);
            y1 = y1.max(y);
        }
        let (cx, cy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5);
        let unit = w.min(h) * 0.40;
        let zoom = ((w * 0.85) / ((x1 - x0).max(1e-3) * unit)).min((h * 0.85) / ((y1 - y0).max(1e-3) * unit)).clamp(ZOOM_MIN, ZOOM_MAX);
        // a target on the screen plane through the origin that projects to (cx, cy)
        ([cx * c, cy, cx * s], zoom)
    }

    /// Is the camera flying?
    #[must_use]
    pub fn flying(&self) -> bool {
        self.view.flight.is_some()
    }

    /// The yaw **actually displayed** this frame — the base `yaw` plus the injected
    /// -clock spin (`clock·spin_rate`) while spinning, else the raw `yaw`. Public so
    /// a box skin ([`crate::CodeVault`]) projects through the *same* camera the CPU
    /// view + GPU lane paint with (the shared-placement seam; L5 — no re-derive).
    pub fn render_yaw(&self) -> f32 {
        if self.view.spin {
            self.view.yaw + self.view.clock_s.map(|c| c as f32 * self.view.spin_rate).unwrap_or(0.0)
        } else {
            self.view.yaw
        }
    }

    /// Project **every** node into a `center`/`scale` pane at `yaw`, returning
    /// `(screen, depth)` per node (depth in `-1..1`), in node-index order. Reuses the
    /// exact orthographic projection the view + `gpu_scene` use, so a Code Vault box
    /// skin lays its boxes on the identical camera (L5). Pure (no egui).
    pub fn project_all(&self, center: Pos2, scale: f32, yaw: f32) -> Vec<(Pos2, f32)> {
        let (s, c) = yaw.sin_cos();
        let nodes = &self.nodes;
        let (f, scale) = (self.view.focus, scale * self.view.zoom);
        let project_pt = |p: [f32; 3], center: Pos2, scale: f32, s: f32, c: f32| project_pt([p[0] - f[0], p[1] - f[1], p[2] - f[2]], center, scale, s, c);
        // Small clouds: the serial map (fork-join dispatch would cost more than it
        // saves). Large clouds: fan the per-node projection across EVERY core via the
        // znippy gatling engine — `gatling_for_each` writes each slot from the single
        // worker that claimed that index, so the returned Vec is in node-index order
        // and BYTE-IDENTICAL to the serial `.map().collect()` (same kernel, no
        // accumulation). THIS is what makes rotating a 30k cloud multicore.
        if nodes.len() < PROJ_PARALLEL_MIN {
            nodes.iter().map(|n| project_pt(n.pos, center, scale, s, c)).collect()
        } else {
            let mut out = vec![(Pos2::ZERO, 0.0f32); nodes.len()];
            proj_pool().map_slice(nodes, &mut out, |n| project_pt(n.pos, center, scale, s, c));
            out
        }
    }

    /// **Project every node INTO a caller-owned buffer** — the same kernel as
    /// [`project_all`](Self::project_all), with the per-frame allocation removed.
    ///
    /// # Why this exists
    /// `project_all` returns a fresh `Vec`, so a spinning cloud allocates and drops one
    /// every frame: at 30 000 nodes that is 360 KB per frame, roughly 21 MB/s of churn at
    /// 60 fps, for a buffer whose size never changes. Measured wall time said nothing
    /// about it — 1.57 ms/frame at 30k is honest about time and silent about allocation,
    /// which is exactly the kind of unexamined claim this codebase keeps paying for.
    ///
    /// The caller keeps `out` across frames; this resizes it once (on the first frame, or
    /// after the node count changes) and writes in place thereafter. Same gatling
    /// fan-out, same byte-identical result in node-index order.
    ///
    /// `out` is left exactly `self.nodes.len()` long.
    pub fn project_all_into(&self, center: Pos2, scale: f32, yaw: f32, out: &mut Vec<(Pos2, f32)>) {
        let (s, c) = yaw.sin_cos();
        let nodes = &self.nodes;
        let (f, scale) = (self.view.focus, scale * self.view.zoom);
        let project_pt = |p: [f32; 3], center: Pos2, scale: f32, s: f32, c: f32| project_pt([p[0] - f[0], p[1] - f[1], p[2] - f[2]], center, scale, s, c);
        // `resize` is a no-op once the buffer is the right length, which is the steady
        // state — so a spinning cloud allocates ZERO after its first frame.
        if out.len() != nodes.len() {
            out.resize(nodes.len(), (Pos2::ZERO, 0.0));
        }
        if nodes.len() < PROJ_PARALLEL_MIN {
            for (o, n) in out.iter_mut().zip(nodes.iter()) {
                *o = project_pt(n.pos, center, scale, s, c);
            }
        } else {
            // Same engine as `project_all` (LAW 3 — gatling, never rayon), and the same
            // PARKED pool: `map_slice` writes each worker's contiguous range straight
            // into the caller's buffer, so this arm keeps the byte-identical guarantee
            // AND the zero-allocation promise the doc above makes — the old
            // `gatling_for_each` arm allocated an index-ordered Vec and copied it, which
            // quietly undid the reason this function exists.
            proj_pool().map_slice(nodes, out.as_mut_slice(), |n| project_pt(n.pos, center, scale, s, c));
        }
    }

    /// **Build the GPU-lane scene** (feature `wgpu`) — project every node at the
    /// current `yaw` into a `w×h` px pane and emit instanced emissive node discs +
    /// edge filaments for [`graph_gpu`]. Node/edge colours are scaled to linear
    /// radiance **above 1.0** (depth-brightened, so nearer nodes bloom hotter), so the
    /// additive-blend HDR pass + bloom catch them — the DATA the render-proof asserts.
    ///
    /// `emissive` scales the peak radiance (≈3.0 gives a strong bloom over a ~1.0
    /// bright-pass threshold); pass `1.0` for a calm look. This is a pure projection
    /// (no egui), so it is unit-testable headless.
    #[cfg(feature = "wgpu")]
    pub fn gpu_scene(&self, w: f32, h: f32, emissive: f32) -> graph_gpu::GraphScene {
        let yaw = self.view.yaw + self.view.clock_s.map(|c| c as f32 * self.view.spin_rate).unwrap_or(0.0);
        self.gpu_scene_at(w, h, emissive, yaw)
    }

    /// [`gpu_scene`](Self::gpu_scene) at an **explicit** `yaw` — so the LIVE view lane
    /// ([`draw_gpu_cloud`](Self::draw_gpu_cloud)) builds the instanced scene through the
    /// exact `render_yaw` it paints the labels/overlays with (drag/travel/spin included),
    /// not the re-derived spin yaw.
    #[cfg(feature = "wgpu")]
    pub fn gpu_scene_at(&self, w: f32, h: f32, emissive: f32, yaw: f32) -> graph_gpu::GraphScene {
        let center = Pos2::new(w * 0.5, h * 0.5);
        let scale = w.min(h) * 0.40;
        // Gatling-parallel projection (byte-identical to the serial map; fans at scale).
        let proj: Vec<(Pos2, f32)> = self.project_all(center, scale, yaw);
        // Linear radiance of a Color32 channel (sRGB-ish → linear via a square), scaled
        // by the emissive peak and a depth term so near nodes (higher z) glow brighter.
        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 = emissive * dbright * mul;
            [lin(c.r()) * k, lin(c.g()) * k, lin(c.b()) * k, 1.0]
        };
        // ── EDGE INSTANCES ────────────────────────────────────────────────────
        // The kernel, spelled once and shared by both lanes (LAW 5) so the parallel
        // build cannot drift from the serial one.
        // Bind only what the kernels READ. Capturing `self` would drag the pane's
        // ephemeral paint caches (`RefCell<Vec<Option<Pos2>>>`, `Cell<(usize, usize)>`)
        // into a `Sync` bound they cannot meet — and rightly so: those are per-frame
        // scratch, not something a worker may touch.
        let nodes_ref: &[Node3] = &self.nodes;
        let edge_inst = |a: usize, b: usize| {
            let depth = (proj[a].1 + proj[b].1) * 0.5;
            graph_gpu::EdgeInstance {
                a: [proj[a].0.x, proj[a].0.y],
                b: [proj[b].0.x, proj[b].0.y],
                width: 1.5,
                _pad: 0.0,
                // Edges glow ~0.6× the node peak — bright enough that crossings
                // accumulate past the bloom threshold.
                color: radiance(nodes_ref[a].color, depth, 0.6),
            }
        };
        // The filter drops edges naming a node the projection does not have. NOTE: it runs
        // PER EDGE, and that costs more than the branch — `.filter()` erases the size
        // hint, so the collect grows by doubling instead of allocating once. Measured at
        // 1M: 21.65 ms filtered vs 14.06 ms with `with_capacity` + extend, i.e. **7.6 ms
        // of realloc-and-copy**, and 2.7 ms (32 % of the frame) already at 300k.
        let edges: Vec<graph_gpu::EdgeInstance> = self
            .edges
            .iter()
            .filter(|&&(a, b)| a < proj.len() && b < proj.len())
            .map(|&(a, b)| edge_inst(a, b))
            .collect();
        // The disc radius follows the CROWD (see `NODE_INK_BUDGET`): exactly unchanged
        // while the cloud fits the pane, shrunk past that so a big scene draws as a
        // density texture instead of a saturated fill. The GPU lane draws EVERY node
        // (no stride), so `n` here is the whole cloud.
        let shrink = ink_shrink(self.nodes.len(), w, h, BASE_NODE_RADIUS);
        // ── NODE INSTANCES ────────────────────────────────────────────────────
        let node_inst = |i: usize| {
            let (p, z) = proj[i];
            graph_gpu::NodeInstance {
                center: [p.x, p.y],
                radius: (3.0 + (z + 1.0) * 2.5) * shrink,
                _pad: 0.0,
                color: radiance(nodes_ref[i].color, z, 1.0),
            }
        };
        // SERIAL ON PURPOSE, and measured — see `examples/scene_probe.rs`. Fanning this
        // across the parked pool was tried and is a LOSS at every size a host draws:
        // 30k 0.488 -> 4.269 ms, 100k 2.016 -> 4.239, 300k 10.825 -> 17.486, and even at
        // 1 000 000 it wins only 1.18x (55.7 -> 47.1). The build is WRITE-BANDWIDTH
        // bound, not compute bound — 96 MB of instances per frame at a million nodes —
        // so cores cannot help, and pre-sizing the output with `vec![zeroed(); n]`
        // doubles the write traffic the serial `.collect()` pays once.
        let nodes: Vec<graph_gpu::NodeInstance> = (0..nodes_ref.len()).map(node_inst).collect();
        graph_gpu::GraphScene { background: [0.01, 0.01, 0.02, 1.0], nodes, edges }
    }
}

impl Graph3D {
    /// **FC-9 render** — a **pure** function of `&self`: it projects + paints the
    /// depth-sorted node cloud (edges, glow orbs, shockwave, gyro labels) and
    /// *returns* the [`Msg`]s the interactions produced (orbit drag, live-spin tick,
    /// click-select). It MUST NOT mutate the [`Graph3DView`] Model; the
    /// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
    /// returned messages through [`update`](Self::update). The only `&self`-interior
    /// mutation is the ephemeral gyro `label_anchors` paint cache (FC-7), outside the
    /// Model.
    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        let mut msgs: Vec<Msg> = Vec::new();
        let th = theme(ui);
        let now = ui.input(|i| i.time);
        // The viewer's remembered 📌 Stay / 🎯 Focus (UI memory, every graph pane).
        if !self.view.nav_mode_set {
            msgs.push(Msg::SetNavMode(facett_core::fly::NavMode::remembered(ui.ctx())));
        }
        // A camera flight: advance it, repainting only while it flies.
        if self.view.flight.is_some() {
            msgs.push(Msg::FlyTick(now));
            ui.ctx().request_repaint();
        }
        // ── opt-in inline control row (default OFF) ──────────────────────────
        // The two toggles that were reachable only through `Msg`, given a mouse. It is
        // drawn BEFORE the canvas allocation on purpose: the canvas takes
        // `available_size()`, so the row costs the picture its own height and nothing
        // else. Checkbox → `Msg`, never a direct write — the FC-9 view stays pure and
        // the gesture reaches the identical transition a headless driver does.
        if self.view.controls {
            ui.horizontal(|ui| {
                let mut spin = self.view.spin;
                if ui
                    .checkbox(&mut spin, "3D rotating")
                    .on_hover_text(
                        "continuously orbit the camera about the vertical axis —                          projection-only, the layout stays put (a drag pauses it)",
                    )
                    .changed()
                {
                    msgs.push(Msg::SetSpin(spin));
                }
                let mut labels = self.view.show_labels;
                if ui
                    .checkbox(&mut labels, "labels")
                    .on_hover_text(
                        "billboarded, gyro-stabilised node labels — off by default above                          40 nodes, where the words cost more than they say",
                    )
                    .changed()
                {
                    msgs.push(Msg::SetShowLabels(labels));
                }
                if ui.button("⊙ fit").on_hover_text("fly back to the whole graph").clicked() {
                    msgs.push(Msg::FitFly(now));
                }
                if let Some(m) = facett_core::fly::NavMode::toggle(ui, self.view.nav_mode) {
                    msgs.push(Msg::SetNavMode(m));
                }
                ui.weak(format!("{} node(s) · {} edge(s)", self.nodes.len(), self.edges.len()));
            });
        }
        // TRAVELS (default OFF): when a tour is flying with camera-follow, it DRIVES the
        // camera (fly-to-ghost) and gets a live tour tick, overriding the auto-spin.
        // `None` (the no-tour case) leaves every line below byte-identical.
        let travel_yaw = self.travel_camera_yaw();
        if let Some(t) = self.travel.as_ref() && t.is_active() && t.is_playing() && self.view.clock_s.is_none() {
            // Advance ONLY the tour (not the graph clock), so the spin stays live.
            let dt = ui.input(|i| i.stable_dt).min(0.1);
            msgs.push(Msg::TravelTick(dt as f64));
            ui.ctx().request_repaint();
        }
        // FC-7: the spin yaw. With an injected clock it is a *pure function* of the
        // clock (`yaw_base + clock·spin_rate`) — no accumulation, so re-rendering the
        // same state+clock is bit-identical. Without a clock, EMIT an `AdvanceSpin(dt)`
        // (applied by `update`) so the live spin still accumulates the ambient
        // `stable_dt` — but through the FC-2 mutation path, never in the pure view.
        if self.view.spin && self.view.clock_s.is_none() && travel_yaw.is_none() {
            let dt = ui.input(|i| i.stable_dt).min(0.1);
            msgs.push(Msg::AdvanceSpin(dt));
            ui.ctx().request_repaint();
        }
        // The yaw actually projected this frame. A camera-following tour flies the yaw
        // to face the ghost (and persists it through the FC-2 path); else the base +
        // injected spin (unchanged).
        let render_yaw = if let Some(ty) = travel_yaw {
            msgs.push(Msg::SetYaw(ty));
            ty
        } else if self.view.spin {
            self.view.yaw + self.view.clock_s.map(|c| c as f32 * self.view.spin_rate).unwrap_or(0.0)
        } else {
            self.view.yaw
        };
        let (rect, resp) = ui.allocate_exact_size(facett_core::nav::canvas_size(ui), Sense::click_and_drag());
        // A host-placed cloud is framed on its first paint — fitted and centred in the
        // canvas (its coordinates can sit anywhere); ⊙ fit flies back to the same frame.
        let frame = self.frame_camera(rect.width(), rect.height());
        self.view_frame.set(Some(frame));
        if self.view.layout == Layout3D::Given && !self.view.framed && !self.nodes.is_empty() {
            msgs.push(Msg::FrameAt { focus: frame.0, zoom: frame.1 });
        }
        // ZOOM toward the cursor: the wheel, a trackpad pinch (egui's zoom delta) and
        // the keyboard + / − while the pointer is over the pane. The wheel is taken
        // (consumed), so a scroll area around the pane does not scroll it away instead.
        if resp.hovered() {
            let (wheel, pinch, plus, minus, pointer) = ui.input(|i| {
                (
                    i.smooth_scroll_delta.y,
                    i.zoom_delta(),
                    i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals),
                    i.key_pressed(egui::Key::Minus),
                    i.pointer.hover_pos(),
                )
            });
            let mut factor = pinch * (wheel * 0.0015).exp();
            if plus {
                factor *= 1.25;
            }
            if minus {
                factor /= 1.25;
            }
            if (factor - 1.0).abs() > 1e-6 {
                if wheel != 0.0 {
                    ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0);
                }
                let at = pointer.map_or(egui::Vec2::ZERO, |p| p - rect.center());
                msgs.push(Msg::ZoomAt { factor, at: [at.x, at.y], px_scale: rect.size().min_elem() * 0.40 });
            }
        }
        // Orbit drag → EMIT `DragYaw` (the pure view never mutates the yaw itself).
        if resp.dragged() {
            let dx = resp.drag_delta().x;
            if dx != 0.0 {
                msgs.push(Msg::DragYaw(dx));
            }
        }
        // FC-4: the drag-to-rotate canvas is a value-bearing, addressable element —
        // expose it as an AccessKit node carrying the current yaw (radians) so a
        // robot driver / screen reader can read the orientation, not just see it.
        let degs = render_yaw.to_degrees().rem_euclid(360.0);
        // The empty-graph state carries the stable UI error code facet-graph3d-1 (see
        // facett_core::errcode) so tests + consumers react to the CODE, not a string;
        // when empty the AccessKit atom also carries the Error severity + the code.
        let empty = self.nodes.is_empty();
        // The HOST-placement fallback (facet-graph3d-5): `Layout3D::Given` was asked
        // for, the placement did not cover the cloud, and the force layout drew it.
        // Raised HERE, on the real pane, so the code is `WIRED` and not merely
        // catalogued — a robot reads the atom, not a log line.
        let short_placement = !empty && !self.placement_is_complete();
        resp.widget_info(|| {
            let s = facett_core::a11y::Semantics::image(format!("3D graph — yaw {degs:.0}° · {} nodes", self.nodes.len()))
                .value(render_yaw as f64);
            let s = if empty {
                s.severity(facett_core::Severity::Error).error_code("facet-graph3d-1")
            } else if short_placement {
                s.severity(facett_core::Severity::Warning).error_code("facet-graph3d-5")
            } else {
                s
            };
            s.widget_info()
        });
        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
        if empty {
            painter.text(rect.center(), egui::Align2::CENTER_CENTER, "no nodes", egui::FontId::proportional(13.0), th.text_dim);
            // The stable error CODE in clear pink (debug) beneath the human hint.
            facett_core::errcode::paint_code(&painter, rect.center() + egui::vec2(0.0, 16.0), "facet-graph3d-1");
            // ── CPU render-lane emit (empty-graph exit) ───────────────────────
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit(
                "facett-graph3d::Graph3D::ui",
                "ui_render",
                true,
                &format!("nodes=0 edges={} drew=empty_hint", self.edges.len()),
            );
            return msgs;
        }
        let center = rect.center();
        let scale = rect.size().min_elem() * 0.40;
        // Gatling-parallel projection: the per-frame single-core wall the owner saw
        // rotating a 30k cloud. `project_all` fans across every core at scale and is
        // byte-identical to the serial map (FC-9 determinism intact), so the paint,
        // depth-sort, snapshots + AccessKit below are unchanged.
        let proj: Vec<(Pos2, f32)> = self.project_all(center, scale, render_yaw);

        // ── Decor gate ────────────────────────────────────────────────────────
        // Resolve the neon-decor level: an explicit `with_decor` override, else the
        // active theme's published `EffectsPolicy`. `None` ⇒ today's flat render
        // (byte-identical), so existing consumers/snapshots are unaffected.
        let fx = self.view.decor.resolve(facett_core::look::effects_policy(ui));
        let decor_on = fx != EffectsPolicy::None;
        let full = fx == EffectsPolicy::Full;
        // The decor phase: injected clock (deterministic) or the ambient clock (live),
        // mirroring the spin above. Live decor keeps repainting so it actually
        // breathes/flows; the injected-clock path stays a pure function of the clock.
        let phase = self.render_phase(ui.input(|i| i.time));
        if decor_on && self.view.clock_s.is_none() {
            ui.ctx().request_repaint();
        }
        // Full mode "breathes": a brightness sweep (pure in the render phase).
        let breathe = if full { 0.65 + 0.35 * decor::pulse(phase) } else { 1.0 };

        // ── GPU cloud lane (feature `wgpu`, host opted in via `with_gpu`) ───────
        // Draw the 30k node/edge cloud as GPU INSTANCES via an `egui_wgpu` callback
        // instead of tessellating 30k discs on ONE CPU core every frame (the rotate
        // -bench single-core paint wall). When it draws, the two heavy CPU paint loops
        // below are skipped; labels/shockwave/travels stay as thin CPU overlays on top.
        // Off by default ⇒ the CPU painter draws (every snapshot golden unchanged).
        #[cfg(feature = "wgpu")]
        let drew_on_gpu = self.draw_gpu_cloud(ui, rect, render_yaw);
        #[cfg(not(feature = "wgpu"))]
        let drew_on_gpu = false;

        // Click → focus the nearest node's subgraph; click on empty space clears it.
        // EMIT the resulting selection change (never mutate `self.view` here) — the
        // paint below reads the CURRENT (pre-update) selection, so the new focus lights
        // on the next frame, exactly like the peer canvas components.
        if decor_on && resp.clicked() && let Some(pp) = resp.interact_pointer_pos() {
            let hit = (0..proj.len())
                .map(|i| (i, proj[i].0.distance_sq(pp)))
                .filter(|&(_, d)| d < 24.0_f32.powi(2))
                .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
                .map(|(i, _)| i);
            match hit {
                Some(i) => {
                    // 📌 Stay: the camera never moves. 🎯 Focus: fly the node to the centre.
                    if self.view.nav_mode == facett_core::fly::NavMode::Focus {
                        msgs.push(Msg::FocusNode { i, now, viewport: (rect.width(), rect.height()) });
                    }
                    msgs.push(Msg::SelectNode(i));
                }
                None => msgs.push(Msg::ClearSelection),
            }
        }

        // Focus sets + per-node alpha/colour, read from the CURRENT selection (pure).
        let n = self.nodes.len();
        let sel = self.view.selected.filter(|&s| s < n);
        let neigh: Vec<usize> = sel.map(|s| self.neighbours(s)).unwrap_or_default();
        let alphas: Vec<f32> = (0..n)
            .map(|i| match sel {
                _ if !decor_on => 1.0,
                None => 1.0,
                Some(s) => if i == s || neigh.contains(&i) { 1.0 } else { DIM_ALPHA },
            })
            .collect();
        let colors: Vec<Color32> = (0..n)
            .map(|i| {
                if full {
                    let sc = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.5 };
                    neon::neon_color(sc)
                } else {
                    self.nodes[i].color
                }
            })
            .collect();

        // The 30k edge + node CPU tessellation — THE per-frame single-core paint wall
        // (rotate_bench). Skipped when the GPU instanced lane carried the cloud above.
        if !drew_on_gpu {
            // ── the CPU-painter BUDGET (see CPU_PAINT_MAX_NODES) ──────────────
            // No GPU renderer installed ⇒ every disc/orb/line is tessellated on one
            // core. Stride the two passes so the per-frame cost is bounded by the
            // budget rather than by the dataset, and the pane degrades to a readable
            // SAMPLE instead of freezing. Stride 1 (⇒ byte-identical) below budget.
            let node_stride = self.nodes.len().div_ceil(CPU_PAINT_MAX_NODES.max(1)).max(1);
            let edge_stride = self.edges.len().div_ceil(CPU_PAINT_MAX_EDGES.max(1)).max(1);
            self.last_cpu_stride.set((node_stride, edge_stride));
            // edges, faded by mean depth (× focus dim); Full mode flows particle tracers
            // along lit edges.
            for (_ei, &(a, b)) in self.edges.iter().enumerate().step_by(edge_stride) {
                // A RATED edge (feature `rate`) is drawn by the curved overlay below,
                // not as a straight strut under it. No rates set ⇒ nothing skipped,
                // so the plain render is byte-identical.
                #[cfg(feature = "rate")]
                if self.rates.as_ref().is_some_and(|r| r.edges.get(_ei).copied().flatten().is_some()) {
                    continue;
                }
                if a < proj.len() && b < proj.len() {
                    let depth = (proj[a].1 + proj[b].1) * 0.5;
                    let ea = alphas[a].min(alphas[b]);
                    let alpha = ((120.0 + depth * 100.0).clamp(20.0, 220.0) * ea) as u8;
                    let base = if full { colors[a] } else { th.edge };
                    let col = base.gamma_multiply(alpha as f32 / 255.0);
                    painter.line_segment([proj[a].0, proj[b].0], Stroke::new(if full { 1.0_f32 } else { 0.6_f32 }, col));
                    if full && ea > 0.5 {
                        neon::paint_edge_particles(&painter, proj[a].0, proj[b].0, colors[b], ea, phase);
                    }
                }
            }
            // nodes back-to-front: bigger + brighter when nearer (higher z), with glow+AO
            // orbs under each lit node (Reduced: glow only; Full: the breathing orb).
            let mut order: Vec<usize> = (0..self.nodes.len()).step_by(node_stride).collect();
            order.sort_by(|&i, &j| proj[i].1.partial_cmp(&proj[j].1).unwrap_or(std::cmp::Ordering::Equal));
            // The disc radius follows the CROWD (`NODE_INK_BUDGET`) — computed on the
            // count this lane actually DRAWS (the strided sample, not the whole cloud),
            // so the CPU painter and the GPU one each budget against their own reality
            // and every existing golden stays at a shrink of exactly 1.0.
            let shrink = ink_shrink(order.len(), rect.width(), rect.height(), BASE_NODE_RADIUS);
            for i in order {
                let (p, z) = proj[i];
                let r = (3.0 + (z + 1.0) * 2.5) * shrink;
                let a = alphas[i];
                let bright = (0.5 + (z + 1.0) * 0.25).clamp(0.3, 1.0) * if full { breathe } else { 1.0 };
                if decor_on && a > DIM_ALPHA {
                    let intensity = if full { 0.55 } else { 0.35 };
                    neon::paint_node_orb(&painter, p, r, colors[i], intensity, a);
                }
                painter.circle_filled(p, r, colors[i].gamma_multiply(bright * a));
            }
        }
        // Click shockwave: a radial ring expanding from the selected node (pure in
        // the clock phase) — the neon-dashboard "click ripple" over the lit subgraph.
        if decor_on && let Some(s) = sel {
            neon::paint_shockwave(&painter, proj[s].0, scale * 0.9, colors[s], phase.rem_euclid(1.0));
        }

        // ── EDGE-RATE overlay (feature `rate`, default OFF; nothing set ⇒ skipped) ──
        // Each RATED edge is a smooth 3D arc ([`rate::edge_curve`]) projected through
        // the SAME camera as the cloud, depth-shaded PER SEGMENT (the arc sits in the
        // scene), with PULSES gliding along the curve's ARC LENGTH — faster and
        // brighter for a hotter edge, nothing at all for `None`. The clock is the
        // injected FC-7 clock when the host drives one (deterministic), else the
        // ambient time with a live repaint request — the same dual the spin runs on.
        #[cfg(feature = "rate")]
        if let Some(rt) = self.rates.as_ref() {
            let maxr = self.max_rate();
            if maxr > 0.0 {
                let clock = self.view.clock_s.unwrap_or(ui.input(|i| i.time));
                if self.view.clock_s.is_none() {
                    ui.ctx().request_repaint();
                }
                for (ei, r) in rt.edges.iter().enumerate() {
                    let Some(rv) = *r else { continue };
                    let Some(arc) = self.rated_edge_projection(ei, center, scale, render_yaw) else { continue };
                    let (a, b) = self.edges[ei];
                    let ea = alphas[a].min(alphas[b]);
                    let heat = (rv / maxr).clamp(0.0, 1.0);
                    let base = if full { colors[a] } else { th.edge };
                    // The arc, segment by segment, each shaded by ITS depth.
                    for w in arc.windows(2) {
                        let depth = (w[0].1 + w[1].1) * 0.5;
                        let alpha = ((120.0 + depth * 100.0).clamp(20.0, 220.0) * ea) as u8;
                        let col = base.gamma_multiply(alpha as f32 / 255.0 * (0.55 + 0.45 * heat));
                        painter.line_segment([w[0].0, w[1].0], Stroke::new(0.8 + 1.2 * heat, col));
                    }
                    // The pulses: fractional phase from the clock, placed by ARC LENGTH
                    // (never the chord), projected and depth-brightened like the nodes.
                    let curve = rate::edge_curve(self.nodes[a].pos, self.nodes[b].pos, rate::CURVE_SEGMENTS);
                    let (cum, total) = rate::arc_lengths(&curve);
                    if total <= 0.0 {
                        continue;
                    }
                    let phase0 = rate::pulse_phase(rv, maxr, clock);
                    for k in 0..rate::PULSES_PER_EDGE {
                        let frac = (phase0 + k as f32 / rate::PULSES_PER_EDGE as f32).rem_euclid(1.0);
                        let p3 = rate::point_at(&curve, &cum, total, frac);
                        let (sp, z) = self.project(p3, center, scale, render_yaw);
                        let bright = (0.55 + (z + 1.0) * 0.35).clamp(0.4, 1.0);
                        let pr = 1.6 + 1.8 * heat + (z + 1.0) * 0.8;
                        painter.circle_filled(sp, pr, self.nodes[b].color.gamma_multiply((0.35 + 0.65 * heat) * bright * ea));
                    }
                }
            }
        }

        // ── TRAVELS: the glowing ghost tour overlay (default OFF) ──────────────
        // Draws the ghostly spline + stop glows + the pulsing marker, projected
        // through the SAME camera the cloud paints with (L5). Only runs when a route
        // is set, so the no-tour render above is byte-identical. Keeps repainting
        // while a live tour plays so the ghost glides.
        if let Some(t) = self.travel.as_ref() && t.is_active() {
            let drew = t.paint(&painter, |p| self.project(p, center, scale, render_yaw), phase);
            if t.is_playing() && self.view.clock_s.is_none() {
                ui.ctx().request_repaint();
            }
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit(
                "facett-graph3d::Graph3D::travel",
                "ghost_overlay",
                drew > 0,
                &format!("route={} stop={} progress={:.3} shapes={drew}", t.route().name, t.stop_index(), t.progress()),
            );
            let _ = drew;
        }

        // ── Gyro-stabilized billboarded labels (§19) ──────────────────────────
        // Each label's 3D anchor is projected, temporally smoothed toward its prior
        // screen position, then snapped to a sub-pixel grid so it follows rotation
        // without bobbing (GYRO-1/2). Drawn upright (fixed Align2), never rotated
        // with the scene. The anchor cache is the ephemeral `label_anchors` RefCell
        // (FC-7) — outside the Model, recorded through the `&self` borrow.
        if self.view.show_labels {
            let mut anchors = self.label_anchors.borrow_mut();
            if anchors.len() != self.nodes.len() {
                *anchors = vec![None; self.nodes.len()];
            }
            let font = egui::FontId::proportional(11.0);
            // FC-1 + FC-7: temporal smoothing toward the *prior* frame's anchor is
            // frame-history state — it makes the frame depend on render order. Under
            // an injected clock (deterministic mode) we drop the prior anchor so the
            // placement is a pure function of this frame's projection (no bobbing to
            // smooth out when the host steps the clock itself). Live mode keeps the
            // gyro smoothing for the silky real-time look.
            let deterministic = self.view.clock_s.is_some();
            #[allow(clippy::needless_range_loop)] // three parallel slices (proj / nodes / anchors) keyed by i
            for i in 0..self.nodes.len() {
                let (p, z) = proj[i];
                // Anchor just above the node, then stabilize.
                let raw = p + vec2(0.0, -(3.0 + (z + 1.0) * 2.5) - 2.0);
                let prior = if deterministic { None } else { anchors[i] };
                let placed = facett_core::labels3d::billboard_placement(raw, prior, 0.5, 0.5);
                anchors[i] = Some(placed);
                let bright = (0.5 + (z + 1.0) * 0.25).clamp(0.4, 1.0);
                painter.text(
                    placed,
                    egui::Align2::CENTER_BOTTOM,
                    &self.nodes[i].label,
                    font.clone(),
                    th.text.gamma_multiply(bright),
                );
            }
        }
        // ── CPU render-lane emit: the projected-node painter path RAN ──────────
        // Records the visible-node geometry + a finiteness invariant on the
        // projection (no NaN/Inf escaped into screen coords). Unique component so
        // the inventory test sees the 3D-graph painter lane.
        #[cfg(feature = "testmatrix")]
        {
            let center = rect.center();
            let scale = rect.size().min_elem() * 0.40;
            let all_finite = self
                .nodes
                .iter()
                .all(|n| {
                    let (p, z) = self.project(n.pos, center, scale, render_yaw);
                    p.x.is_finite() && p.y.is_finite() && z.is_finite()
                });
            facett_core::testmatrix::emit(
                "facett-graph3d::Graph3D::ui",
                "ui_render",
                !self.nodes.is_empty() && all_finite,
                &format!("nodes={} edges={} projection_finite={all_finite}", self.nodes.len(), self.edges.len()),
            );
        }
        msgs
    }
}

#[cfg(feature = "wgpu")]
impl Graph3D {
    /// **GPU cloud lane** — when the host opted in via [`with_gpu`](Self::with_gpu),
    /// push the instanced `egui_wgpu` paint callback for `rect` (built from
    /// [`gpu_scene_at`](Self::gpu_scene_at) at `render_yaw`) and return `true` so the
    /// [`view`](Self::view) SKIPS the single-core egui tessellation of the node/edge
    /// cloud. Returns `false` when GPU paint is off → the CPU painter draws (the
    /// unchanged golden path). The renderer must be installed once by the host
    /// ([`graph_gpu::cloud::install_cloud_renderer`]); if it isn't, the callback simply
    /// paints nothing (the host contract, mirroring `logo3d`'s GPU opt-in).
    fn draw_gpu_cloud(&self, ui: &mut egui::Ui, rect: egui::Rect, render_yaw: f32) -> bool {
        if self.nodes.is_empty() {
            return false;
        }
        // The opt-in check, the FAIL-SAFE install check (opting in without an installed
        // renderer must fall back to the CPU painter, never render black) and the
        // physical-px math all live in ONE place now — `graph_gpu::cloud::draw_cloud` —
        // so the 2-D graph panes cannot reintroduce the blank-pane shape by forgetting
        // one of them. The scene is built at the SAME yaw the labels/overlays use, with
        // a strong emissive so the additive HDR cloud reads bright over the dark pane.
        graph_gpu::cloud::draw_cloud(ui, rect, self.use_gpu, |px_w, px_h| {
            self.gpu_scene_at(px_w, px_h, 3.0, render_yaw)
        })
    }
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for Graph3D {
    type Model = Graph3DView;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &Graph3DView {
        &self.view
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        Graph3D::update(self, msg)
    }
    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        Graph3D::view(self, ui)
    }
}

// The bridge macro writes `impl Facet for Graph3D` from the `Elm` impl: `title`, the
// FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the overrides below.
// **Form 3** (`custom_state_json`) because the viewer publishes a RICHER `state_json`
// than a plain `serde(state())`: the neon-decor observables (palette / animation_phase
// / highlighted_neighbours / dimmed_alpha) and the geometry counts (nodes / edges) that
// facett-demo's mega_matrix + nornir viz read are DERIVED from the model + the node/edge
// cloud, not just the raw `Graph3DView` — so the default `state_json` is suppressed and
// this one (with keys preserved EXACTLY) supplied.
facett_core::impl_facet_via_elm!(Graph3D, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        // The neon-decor observable surface (mirrors pop_beauty): the view is asserted
        // as DATA, not pixels. `effects_policy` is the configured `DecorPolicy` (the
        // resolved theme policy is a render-time/ui value); selection lights the
        // neighbour subgraph and dims the rest to `dimmed_alpha`.
        let sel = self.view.selected.filter(|&s| s < self.nodes.len());
        let neigh = sel.map(|s| self.neighbours(s)).unwrap_or_default();
        let decor_on = self.view.decor != DecorPolicy::Off;
        let mut j = serde_json::json!({
            "nodes": self.nodes.len(),
            "edges": self.edges.len(),
            "layout": self.view.layout.label(),
            "yaw": self.view.yaw,
            "focus": self.view.focus,
            "zoom": self.view.zoom,
            "nav_mode": format!("{:?}", self.view.nav_mode),
            "flying": self.view.flight.is_some(),
            "spin": self.view.spin,
            "spin_rate": self.view.spin_rate,
            // FC-7: the injected clock (null = following ambient time).
            "clock_s": self.view.clock_s,
            "show_labels": self.view.show_labels,
            // Whether the inline control row (the "3D rotating" + "labels" checkboxes)
            // is drawn above the canvas — the toggles' own visibility, asserted as data
            // so a robot can prove the row is THERE and not merely reachable by Msg.
            "controls": self.view.controls,
            // Neon decor observables.
            "effects_policy": self.view.decor.label(),
            "palette": if decor_on { "neon" } else { "flat" },
            "animation_phase": self.effect_phase(),
            "selected_node": sel,
            "highlighted_neighbours": neigh,
            "dimmed_alpha": if sel.is_some() && decor_on { DIM_ALPHA } else { 1.0 },
            // The CPU-painter fallback budget (see CPU_PAINT_MAX_NODES): the strides
            // the last CPU-painted frame used. `[1, 1]` = the whole cloud was drawn
            // (or the instanced GPU lane carried it and the CPU pass was skipped).
            "cpu_paint_stride": [self.last_cpu_stride.get().0, self.last_cpu_stride.get().1],
            "cpu_paint_max_nodes": CPU_PAINT_MAX_NODES,
            // HOST PLACEMENT (Layout3D::Given): how many positions the host supplied,
            // and — when that does not cover the cloud — WHICH layout actually drew
            // it. `layout` is the CHOICE, `layout_fallback` is the truth on screen;
            // both are read off `placement_is_complete`, the one writer, so the report
            // can never describe a picture the canvas did not draw.
            "given_positions": self.given.len(),
            "layout_fallback": if self.placement_is_complete() {
                serde_json::Value::Null
            } else {
                serde_json::json!(Layout3D::Grid.label())
            },
            // The stable UI ERROR CODE for the empty-graph state (facett_core::errcode):
            // present so tests + consumers react to the CODE, not a matched string.
            // A short host placement is the second, milder one (facet-graph3d-5).
            "error_code": if self.nodes.is_empty() {
                serde_json::json!("facet-graph3d-1")
            } else if !self.placement_is_complete() {
                serde_json::json!("facet-graph3d-5")
            } else {
                serde_json::Value::Null
            },
        });
        // TRAVELS: merge the tour surface ONLY when a route is set — so a graph with no
        // tour reports the exact same object it always has (goldens byte-stable).
        if let Some(t) = self.travel.as_ref()
            && let (serde_json::Value::Object(m), serde_json::Value::Object(tj)) = (&mut j, t.state_json())
        {
            m.extend(tj);
        }
        // RATES (feature `rate`): merged ONLY when a consumer set them, so an
        // unrated graph reports the exact object it always has (goldens stable).
        #[cfg(feature = "rate")]
        if let Some(r) = self.rates.as_ref()
            && let serde_json::Value::Object(m) = &mut j
        {
            let rated = r.edges.iter().filter(|e| matches!(e, Some(v) if *v > 0.0)).count();
            m.insert("rated_edges".into(), serde_json::json!(rated));
            m.insert("rate_max".into(), serde_json::json!(self.max_rate()));
        }
        j
    }
    /// STRUCTURAL severity (the Robot-UI gate signal): RED when the graph has no
    /// nodes to render (the facet-graph3d-1 error state), the green Info floor otherwise.
    fn severity(&self) -> facett_core::Severity {
        if self.nodes.is_empty() {
            facett_core::Severity::Error
        } else if !self.placement_is_complete() {
            // A host placement that does not cover the cloud: the picture is honest
            // (the force fallback placed everything) but it is NOT the placement that
            // was asked for, so the pane says amber rather than passing silently.
            facett_core::Severity::Warning
        } else {
            facett_core::Severity::Info
        }
    }
    /// Background, edges, and the empty hint are painted from the active [`Theme`]
    /// (node colours are consumer-supplied, depth-shaded); the projection refits
    /// to the host's `available_size`.
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable()
    }
    /// **Opt into the typed downcast** ([`facett_core::Facet::as_any_mut`]) so a host
    /// that mounts this graph inside a [`FacetDeck`](facett_core::FacetDeck) — the
    /// warehouse deck's 3D-graph panes, say — can read back WHICH NODE the user
    /// clicked (`facet_mut::<Graph3D>(title).state().selected`).
    ///
    /// Without this the click is answered and then discarded: `view` emits
    /// [`Msg::SelectNode`], the bridge applies it to `self.view.selected`, the pane
    /// lights the neighbour subgraph — and the host, which is the only party that
    /// knows what the label MEANS (a symbol, a crate, a repo), never learns the
    /// index. `state_json` publishes `selected_node` but not the label list, and
    /// serializing a whole deck of panes once per frame to read one integer is not a
    /// pointer-event budget. This is the documented opt-in for exactly that case.
    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }
});

#[cfg(test)]
mod tests {
    use super::*;
    // `Facet` for `state_json`/`ui`; `state()`/`update`/`select` are inherent.
    use facett_core::Facet;

    fn sample(n: usize, layout: Layout3D) -> Graph3D {
        let nodes: Vec<(String, Color32)> = (0..n).map(|i| (format!("n{i}"), Color32::from_rgb(0, 200, 220))).collect();
        let edges: Vec<(usize, usize)> = (0..n).map(|i| (i, (i + 1) % n)).collect();
        Graph3D::new(nodes, edges, layout)
    }

    #[test]
    fn sphere_positions_on_unit_shell() {
        let g = sample(200, Layout3D::Sphere);
        for n in &g.nodes {
            let r = (n.pos[0].powi(2) + n.pos[1].powi(2) + n.pos[2].powi(2)).sqrt();
            assert!((r - 1.0).abs() < 0.05, "on the sphere: r={r}");
        }
        let j = g.state_json();
        assert_eq!(j["nodes"], 200);
        assert_eq!(j["layout"], "sphere");
    }

    #[test]
    fn force3d_produces_finite_unit_cube_positions() {
        let g = sample(60, Layout3D::Force);
        for n in &g.nodes {
            for c in 0..3 {
                assert!(n.pos[c].is_finite(), "finite");
                assert!(n.pos[c] >= -1.05 && n.pos[c] <= 1.05, "in unit cube: {}", n.pos[c]);
            }
        }
        assert_eq!(g.state_json()["layout"], "force");
    }

    #[test]
    fn helix_builds_and_state() {
        let g = sample(50, Layout3D::Helix);
        assert_eq!(g.state_json()["layout"], "helix");
        assert_eq!(g.nodes.len(), 50);
    }

    /// **Scale (RED-when-broken):** a 30 000-node 3-D force layout must place every
    /// node finitely inside the unit cube under a wall-clock budget. The old O(n²)
    /// all-pairs octree-free loop blows this; the Barnes–Hut octree meets it.
    #[test]
    fn force3d_scales_to_thirty_thousand_nodes() {
        let n = 30_000;
        assert!(n >= facett_core::barnes_hut::BH_THRESHOLD, "on the Barnes–Hut path");
        let nodes: Vec<(String, Color32)> = (0..n).map(|i| (format!("n{i}"), Color32::from_rgb(0, 200, 220))).collect();
        let mut edges: Vec<(usize, usize)> = (0..n).map(|i| (i, (i + 1) % n)).collect();
        for i in (0..n).step_by(11) {
            edges.push((i, (i + 313) % n));
        }
        let t0 = std::time::Instant::now();
        let g = Graph3D::new(nodes, edges, Layout3D::Force);
        let ms = t0.elapsed().as_secs_f64() * 1000.0;
        assert_eq!(g.nodes.len(), n);
        for nd in &g.nodes {
            for c in 0..3 {
                assert!(nd.pos[c].is_finite(), "finite at scale");
                assert!(nd.pos[c] >= -1.05 && nd.pos[c] <= 1.05, "in unit cube: {}", nd.pos[c]);
            }
        }
        assert!(ms < 30_000.0, "30k 3-D force must be sub-quadratic; took {ms:.0} ms");
    }

    /// **RED-when-broken (gatling projection == serial projection, bit-for-bit).**
    /// `project_all` fans the per-node projection across every core through znippy's
    /// gatling engine once the cloud crosses [`PROJ_PARALLEL_MIN`]. The whole point of
    /// the parallel lane is that it is a pure re-ordering of *independent* work, so its
    /// output MUST be **byte-identical** to the single-threaded reference (`f32` bit
    /// patterns equal, not just close) at EVERY size that exercises the parallel path —
    /// otherwise the FC-9 determinism goldens (neon / rabbit3d / System-Map) would drift
    /// silently under rotation. A worker-order data race or a non-index-order write
    /// turns this red. Runs the fan-out size 5× to smoke out any nondeterminism.
    #[test]
    fn gatling_projection_is_bit_identical_to_serial() {
        let center = Pos2::new(320.0, 240.0);
        let scale = 190.0;
        // Sizes straddling the serial↔parallel threshold, incl. sizes well inside the
        // fan-out where 12+ workers drain the counter concurrently.
        for &n in &[64usize, PROJ_PARALLEL_MIN - 1, PROJ_PARALLEL_MIN, 5_000, 30_000] {
            let g = sample(n, Layout3D::Sphere);
            // The single-threaded reference: the exact `.iter().map().collect()` the
            // parallel lane replaced, through the identical `project` kernel.
            for &yaw in &[0.0f32, 0.37, 1.2599, std::f32::consts::PI, 5.9] {
                let serial: Vec<(Pos2, f32)> = g.nodes.iter().map(|nd| g.project(nd.pos, center, scale, yaw)).collect();
                for _ in 0..5 {
                    let parallel = g.project_all(center, scale, yaw);
                    assert_eq!(parallel.len(), serial.len(), "n={n}");
                    for (i, (pp, sp)) in parallel.iter().zip(&serial).enumerate() {
                        assert_eq!(pp.0.x.to_bits(), sp.0.x.to_bits(), "n={n} yaw={yaw} i={i} x drifted");
                        assert_eq!(pp.0.y.to_bits(), sp.0.y.to_bits(), "n={n} yaw={yaw} i={i} y drifted");
                        assert_eq!(pp.1.to_bits(), sp.1.to_bits(), "n={n} yaw={yaw} i={i} depth drifted");
                    }
                }
            }
        }
    }

    /// Host-stamped PLAIN layout timings at n = 1k/10k/30k (2-D shared skin + 3-D
    /// octree). Ignored by default — run with `--release -- --ignored --nocapture` to
    /// print the sub-quadratic scaling numbers. The authoritative arm is in
    /// `bench/examples/nornir-bench.rs` (Loki bencher).
    #[test]
    #[ignore]
    fn bench_plain_layout_scaling() {
        let host = std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown".into());
        let cores = std::thread::available_parallelism().map(|x| x.get()).unwrap_or(1);
        eprintln!("# facett force-layout scaling — host={host} cores={cores}");
        let rect = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1024.0, 1024.0));
        for &n in &[1_000usize, 10_000, 30_000] {
            let mut scene = facett_core::Scene::new();
            for _ in 0..n {
                scene.node("n", facett_core::hash_color("n"));
            }
            for i in 0..n {
                scene.edge(i, (i + 1) % n);
                if i % 11 == 0 {
                    scene.edge(i, (i + 313) % n);
                }
            }
            let t = std::time::Instant::now();
            let _ = facett_core::layout_positions(facett_core::Layout::Force, &scene, rect);
            let ms2 = t.elapsed().as_secs_f64() * 1000.0;

            let nodes: Vec<(String, Color32)> = (0..n).map(|i| (format!("n{i}"), Color32::WHITE)).collect();
            let mut edges: Vec<(usize, usize)> = (0..n).map(|i| (i, (i + 1) % n)).collect();
            for i in (0..n).step_by(11) {
                edges.push((i, (i + 313) % n));
            }
            let t = std::time::Instant::now();
            let _ = Graph3D::new(nodes, edges, Layout3D::Force);
            let ms3 = t.elapsed().as_secs_f64() * 1000.0;
            eprintln!("n={n:>6}  2d={ms2:>8.1}ms  3d={ms3:>8.1}ms");
        }
    }

    /// **Absorb:** streaming nodes into a Force cloud keeps every existing position
    /// and places the new ones finitely — the incremental contract korp calls.
    #[test]
    fn absorb_keeps_existing_positions_and_places_new() {
        let mut g = sample(2_500, Layout3D::Force);
        let before: Vec<[f32; 3]> = g.nodes.iter().map(|n| n.pos).collect();
        let old_n = before.len();
        let new: Vec<(String, Color32)> = (0..100).map(|i| (format!("x{i}"), Color32::WHITE)).collect();
        let new_edges: Vec<(usize, usize)> = (0..100).map(|i| (old_n + i, (i * 7) % old_n)).collect();
        g.absorb(new, new_edges);
        assert_eq!(g.nodes.len(), old_n + 100, "grew by the absorbed nodes");
        for (i, b) in before.iter().enumerate() {
            assert_eq!(g.nodes[i].pos, *b, "existing node {i} stayed frozen");
        }
        for i in old_n..g.nodes.len() {
            assert!(g.nodes[i].pos.iter().all(|c| c.is_finite()), "new node {i} placed finitely");
        }
    }

    /// **MDS OOM guard:** above `MDS_MAX` the classical MDS refuses its dense n×n
    /// matrices and takes the landmark fallback — it must NOT allocate ~3.6 GB, and
    /// must still give every node a finite, spread 3-D position. (n kept modest so the
    /// test is fast; the guard threshold is what matters.)
    #[test]
    fn mds_large_graph_uses_landmark_fallback_without_oom() {
        let n = callgraph_layouts::mds_max_for_test() + 500; // just over the guard
        let edges: Vec<(usize, usize)> = (0..n).map(|i| (i, (i + 1) % n)).collect();
        let t0 = std::time::Instant::now();
        let pos = CallGraphLayout::Mds.layout(n, &edges);
        let ms = t0.elapsed().as_secs_f64() * 1000.0;
        assert_eq!(pos.len(), n);
        let (mut lo, mut hi) = (f32::MAX, f32::MIN);
        for p in &pos {
            assert!(p.iter().all(|c| c.is_finite()), "finite via landmark fallback");
            lo = lo.min(p[2]);
            hi = hi.max(p[2]);
        }
        assert!(hi - lo > 1.0, "landmark z has real spread: {lo}..{hi}");
        // THE BEHAVIOUR, not a clock: above the guard, z is the landmark embedding —
        // a linear function of BFS hop distance from the landmark (on this ring every
        // node has degree 2, so the landmark is node 0 and hop(i) = min(i, n - i)).
        // The layout's later normalisation is affine per axis, so linearity survives
        // it; the dense classical MDS below the guard does not give a z that is exactly
        // linear in one node's hop count, so an exact linear fit proves the O(n + e)
        // fallback was taken — which is what keeps this from OOMing. (It asserted
        // `< 10 s` wall-clock until 2026-09-29, which went red at 25 s on a box at load
        // 40 and green on an idle laptop: the time measured the box, not the fallback.)
        let maxd = (n / 2) as f32;
        let (z0, zm) = (pos[0][2], pos[n / 2][2]);
        assert!((zm - z0).abs() > 1.0, "the landmark and its antipode differ in z: {z0} vs {zm}");
        let tol = 1e-3 * (zm - z0).abs();
        for (i, p) in pos.iter().enumerate() {
            let hop = i.min(n - i) as f32;
            let want = z0 + (zm - z0) * hop / maxd;
            assert!((p[2] - want).abs() <= tol, "node {i}: z {} is not linear in its hop count {hop} (want {want})", p[2]);
        }
        eprintln!("mds landmark fallback at n = {n}: {ms:.0} ms (informational, box-dependent)");
    }

    #[test]
    fn small_graphs_show_stabilized_labels_big_ones_dont() {
        assert_eq!(sample(8, Layout3D::Sphere).state_json()["show_labels"], true);
        assert_eq!(sample(200, Layout3D::Sphere).state_json()["show_labels"], false);
    }

    #[test]
    fn live_decor_phase_follows_ambient_time_injected_clock_pins_it() {
        // No injected clock: the *reported* (state) phase is 0, but the *render*
        // phase follows the ambient clock so the neon breathes live — the same dual
        // behaviour as the spin (which accumulates ambient dt).
        let g = sample(6, Layout3D::Sphere);
        assert!(g.view.clock_s.is_none());
        assert_eq!(g.effect_phase(), 0.0, "state phase is clock-only (0 without a clock)");
        assert!(g.render_phase(2.0) > 0.0, "live render phase advances with ambient time");
        assert_ne!(g.render_phase(2.0), g.render_phase(3.5), "different ambient times → different phase");

        // Injected clock: the render phase is a pure function of the clock and
        // ignores ambient time entirely (determinism LAW) — and equals the state phase.
        let g2 = sample(6, Layout3D::Sphere).with_clock(5.0);
        assert_eq!(g2.render_phase(2.0), g2.render_phase(99.0), "injected clock pins the phase");
        assert_eq!(g2.render_phase(0.0), g2.effect_phase(), "injected: render phase == state phase");
    }

    #[test]
    fn selection_lights_the_neighbour_subgraph_and_dims_the_rest() {
        // Star-ish topology: node 2 is wired to 0, 1, 3 (chain 0-1-2-3-4 plus 0-2).
        let nodes: Vec<(String, Color32)> =
            (0..5).map(|i| (format!("n{i}"), Color32::from_rgb(0, 200, 220))).collect();
        let edges = vec![(0, 1), (1, 2), (2, 3), (3, 4), (0, 2)];
        let mut g = Graph3D::new(nodes, edges, Layout3D::Force).with_decor(DecorPolicy::Full);

        // No selection: nothing dimmed, no highlights — the calm default.
        let j = g.state_json();
        assert_eq!(j["effects_policy"], "full");
        assert_eq!(j["palette"], "neon");
        assert!(j["selected_node"].is_null(), "nothing selected yet");
        assert_eq!(j["highlighted_neighbours"], serde_json::json!([]));
        assert_eq!(j["dimmed_alpha"], 1.0, "no dim without a selection");

        // Select node 2 → its neighbours {0,1,3} light, node 4 (unrelated) dims.
        g.select(Some(2));
        let j = g.state_json();
        assert_eq!(j["selected_node"], 2);
        assert_eq!(j["highlighted_neighbours"], serde_json::json!([0, 1, 3]), "exactly 2's neighbours light");
        assert_eq!(j["dimmed_alpha"], DIM_ALPHA as f64, "the rest dims to ~0.1");
        // The dim is real: the unrelated node 4 is NOT in the lit subgraph.
        let lit: Vec<u64> = j["highlighted_neighbours"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
        assert!(!lit.contains(&4) && j["selected_node"] != 4, "the unrelated node stays dimmed");

        // `Off` decor never dims, even with a selection (today's flat behaviour).
        let g2 = Graph3D::new(
            (0..5).map(|i| (format!("n{i}"), Color32::WHITE)).collect(),
            vec![(0, 1)],
            Layout3D::Sphere,
        )
        .with_decor(DecorPolicy::Off)
        .with_selected(0);
        assert_eq!(g2.state_json()["dimmed_alpha"], 1.0, "Off decor = no dim");
        assert_eq!(g2.state_json()["palette"], "flat");
    }

    #[test]
    fn label_anchors_do_not_bob_across_rotation_frames() {
        // Render the same graph at a sequence of yaw angles (a rotating scene) and
        // confirm a node's stabilized label y-anchor stays put within a pixel — the
        // gyro fix (§19). Without stabilization the projected y would wobble.
        use facett_core::look::Theme as LookTheme;
        let mut g = sample(6, Layout3D::Sphere);
        g.view.spin = false; // drive yaw manually for determinism
        let ctx = egui::Context::default();
        LookTheme::windows_dark().apply(&ctx);
        let mut ys = Vec::new();
        for k in 0..12 {
            g.view.yaw = 0.6 + k as f32 * 0.02; // small rotation per frame
            let input = egui::RawInput {
                screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 400.0))),
                ..Default::default()
            };
            ctx.run_ui(input, |ui| {
                egui::CentralPanel::default().show(ui, |ui| g.ui(ui));
            }).textures_delta.clear();
            ys.push(g.label_anchors.borrow()[0].map(|p| p.y).unwrap_or(f32::NAN));
        }
        // The stabilized anchor tracks rotation but each frame's snap is stable:
        // consecutive frames differ by at most ~1px (no per-frame sub-pixel bob).
        for w in ys.windows(2) {
            assert!((w[0] - w[1]).abs() <= 2.0, "label y must not jitter frame-to-frame: {ys:?}");
        }
    }

    // ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
    //    harness and assert the resulting `Graph3DView` control state — no GPU. ──

    #[test]
    fn harness_snapshot_drives_camera_layout_and_selection() {
        use facett_core::harness;
        let mut g = sample(6, Layout3D::Sphere);

        // Orbit drag accumulates yaw and pauses the spin; both observable headlessly.
        let base = g.state().yaw;
        let snap = harness::snapshot(&mut g, [Msg::DragYaw(10.0), Msg::DragYaw(5.0)]);
        assert!((snap.yaw - (base + 15.0 * 0.01)).abs() < 1e-6, "drag deltas accumulate into yaw");
        assert!(!snap.spin, "an orbit drag pauses the auto-spin");
        assert_eq!(&snap, g.state(), "the snapshot is a clone of the live state");

        // SetYaw is absolute; AdvanceSpin adds dt·spin_rate; Fit resets both.
        let snap = harness::snapshot(&mut g, [Msg::SetYaw(1.0), Msg::AdvanceSpin(2.0)]);
        assert!((snap.yaw - (1.0 + 2.0 * snap.spin_rate)).abs() < 1e-6, "AdvanceSpin adds dt·spin_rate");
        let snap = harness::snapshot(&mut g, [Msg::Fit]);
        assert_eq!(snap.yaw, 0.6, "Fit resets yaw to the base");
        assert!(snap.spin, "Fit re-enables the spin");

        // Selection keys on the stable index (FC-5) and survives a relayout.
        let snap = harness::snapshot(&mut g, [Msg::SelectNode(2), Msg::SetLayout(Layout3D::Force)]);
        assert_eq!(snap.selected, Some(2), "selection survives a layout switch (stable index)");
        assert_eq!(snap.layout, Layout3D::Force, "SetLayout switched the layout");
        let snap = harness::snapshot(&mut g, [Msg::ClearSelection]);
        assert_eq!(snap.selected, None, "ClearSelection empties the selection");

        // Injected clock (FC-7): SetClock pins it, Tick advances it.
        let snap = harness::snapshot(&mut g, [Msg::SetClock(3.0), Msg::Tick(1.5)]);
        assert_eq!(snap.clock_s, Some(4.5), "SetClock then Tick → 3.0 + 1.5");

        // Out-of-range SelectNode is ignored (FC-5 guard).
        let snap = harness::snapshot(&mut g, [Msg::SelectNode(999)]);
        assert_eq!(snap.selected, None, "an out-of-range index is not selected");
    }

    #[test]
    fn drive_reports_no_effects_and_model_round_trips() {
        use facett_core::harness;
        let mut g = sample(6, Layout3D::Sphere);
        // FC-8: the viewer does no I/O, so the effect stream is always empty.
        let effects = harness::drive(&mut g, [Msg::DragYaw(3.0), Msg::SelectNode(1), Msg::SetShowLabels(false)]);
        assert!(effects.is_empty(), "FC-8: graph3d emits no Effects");
        // FC-3: the observable Model (the Graph3DView control state) round-trips serde.
        let json = serde_json::to_value(g.state()).unwrap();
        let back: Graph3DView = serde_json::from_value(json).unwrap();
        assert_eq!(&back, g.state(), "serde(state) -> state round-trips");
        assert!(!back.show_labels, "SetShowLabels(false) is observable in the Model");
    }

    #[test]
    fn public_setters_route_through_update() {
        // The public affordances are thin wrappers over the FC-2 mutation path.
        let mut g = sample(6, Layout3D::Sphere);
        g.select(Some(3));
        assert_eq!(g.state().selected, Some(3), "select routes through update");
        g.set_clock(2.0);
        assert_eq!(g.state().clock_s, Some(2.0), "set_clock routes through update");
        g.tick(0.5);
        assert_eq!(g.state().clock_s, Some(2.5), "tick routes through update");
        g.select(None);
        assert_eq!(g.state().selected, None, "select(None) clears via update");
    }

    #[test]
    fn headless_render_of_the_elm_bridge_draws_and_snapshots_state() {
        // The macro-generated `Facet::ui` runs `for m in view(ui) { update(m) }`; a
        // headless render tessellates real geometry AND reads back the state.
        use facett_core::harness;
        let mut g = sample(20, Layout3D::Force).with_clock(1.0);
        let r = harness::headless_render(&mut g);
        assert!(r.drew(), "the Elm bridge tessellated the projected cloud ({} verts)", r.vertices);
        assert_eq!(r.state["nodes"], 20);
        assert_eq!(r.state["clock_s"], 1.0, "state_json keys preserved through the bridge");
    }
}

/// **The click a deck used to swallow.** `Graph3D` inside a
/// [`FacetDeck`](facett_core::FacetDeck) resolves a pointer press to a node index and
/// applies it — but until the [`as_any_mut`](facett_core::Facet::as_any_mut) opt-in
/// above, the host holding the deck had no typed way back to that index, so every
/// warehouse-deck graph pane answered the click and then dropped the answer.
///
/// RED-WHEN-BROKEN: flip that override's body to `None` (the trait default it used to
/// inherit) and `facet_mut` cannot downcast; the first `expect` fires with "the deck
/// cannot hand back a typed Graph3D". Measured red before this was written.
#[cfg(test)]
mod deck_downcast_tests {
    use super::*;
    use facett_core::{Facet, FacetDeck};

    #[test]
    fn a_deck_hands_back_the_clicked_node_index() {
        let g = Graph3D::new(
            vec![
                ("alpha".to_string(), Color32::from_rgb(200, 80, 80)),
                ("beta".to_string(), Color32::from_rgb(80, 200, 80)),
                ("gamma".to_string(), Color32::from_rgb(80, 80, 200)),
            ],
            vec![(0, 1), (1, 2)],
            Layout3D::Force,
        )
        .with_title("call graph");
        let mut deck = FacetDeck::new(vec![Box::new(g) as Box<dyn Facet>]);

        // Nothing clicked yet — the honest third state, not "node 0".
        {
            let pane: &mut Graph3D = deck
                .facet_mut::<Graph3D>("call graph")
                .expect("the deck cannot hand back a typed Graph3D");
            assert_eq!(pane.state().selected, None, "a fresh pane has no selection");
            // The same gesture `view` emits when a pointer lands on a node's disc.
            let _ = pane.update(Msg::SelectNode(2));
        }
        // …and the host reads the answer back THROUGH the deck, which is the whole point.
        let idx = deck
            .facet_mut::<Graph3D>("call graph")
            .expect("the deck cannot hand back a typed Graph3D")
            .state()
            .selected;
        assert_eq!(idx, Some(2), "the deck must surface WHICH node the click resolved to");

        // A wrong title must not silently answer with some other pane's selection.
        assert!(
            deck.facet_mut::<Graph3D>("no such pane").is_none(),
            "an unknown pane title must resolve to nothing, never to a neighbour's state"
        );
    }
}