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, ¢ers));
}
/// 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"
);
}
}