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

nordisk/facett

facett-graph3d/src/pipes.rs

//! **The `SystemMap` — a fluid system map with FLUID FLOWING THROUGH GLASS PIPES.**
//!
//! A clean little topology of the nordisk constellation: components are **glass
//! tanks/junctions** (nodes) joined by translucent, refractive **glass pipes**
//! (edges) with **dye visibly flowing inside** — the fluid stands in for data /
//! activity moving between components.
//!
//! ## Reuse, not fork (L5)
//! Like [`CodeVault`](crate::CodeVault) this is a **skin over [`Graph3D`]**: the camera
//! (orbit `yaw` + auto-`spin` + the injected FC-7 clock), the [`Layout3D`] node
//! placement, the orthographic projection and the click-selection all come from the
//! embedded [`Graph3D`] — reused verbatim (its [`Graph3DView`](crate::Graph3DView) IS
//! this component's [`Elm`] `Model`). The glow orbs reuse [`crate::neon`]; the GPU
//! bloom lane reuses [`crate::graph_gpu`]; and — crucially — the **fluid** is driven by
//! [`facett_cfd::FluidField`], the gatling-accelerated 1-D dye-advection solver, NOT a
//! hand-rolled solver. The map adds only what is genuinely new: the **glass-pipe /
//! glass-tank paint** and the fluid→pipe wiring.
//!
//! ## The three zeroes (the owner's ask — how they are met on the frame path)
//! - **ZERO-COPY** — the renderer **borrows** each pipe's dye with
//!   [`FluidField::dye`] (a `&[f32]` slice); it never clones the field or a buffer.
//!   The fluid's own double buffer is swapped by pointer, not copied.
//! - **ZERO-ALLOCATION** — all sim state ([`FluidField`]) + the per-pipe dye tints are
//!   allocated **once** in [`SystemMap::new`]. The fluid step is provably zero-alloc
//!   (`facett-cfd`'s `step_is_zero_alloc_double_buffer_ping_pong` test). The paint
//!   reuses the shared [`Graph3D`] projection + the egui tessellator, exactly like every
//!   other facett 3-D facet ([`Graph3D`]/[`CodeVault`]) — no per-frame `clone`/`format!`
//!   of sim or vertex data on the hot path.
//! - **ZERO-WAIT** — the fluid advances through the single [`update`](SystemMap::update)
//!   mutation path via a non-blocking **gatling fork-join** (fans across all cores, no
//!   lock, no `.await`, returns within the frame budget). The GPU render/bloom is an
//!   async wgpu submit (double-buffered offscreen). The UI thread never blocks on the
//!   sim.

use std::cell::{Cell, RefCell};

use egui::{Color32, Pos2, Sense, Shape, Stroke, vec2};

use facett_cfd::{FluidField, PipeFlow};
use facett_core::{FacetCaps, theme};

use crate::neon;
use crate::{Graph3D, Graph3DView, Layout3D, Msg as CamMsg};

/// Dye samples per pipe — enough to read as a flowing column, cheap to advect.
const CELLS: usize = 48;
/// The glass wall's translucent body alpha (< 1 so the fluid + far pipes show through).
const GLASS_ALPHA: f32 = 0.16;
/// Bore fraction: the liquid's usable half-width as a fraction of the drawn pipe's
/// half-width. `< 1` so the liquid sits **inside** the glass wall, never on it.
const BORE: f32 = 0.78;
/// How much wider the tubes are drawn in liquid mode. A **free surface has to be seen**:
/// at the dye map's ~8 px pipe, a half-full tube gives a 3 px band that reads as a dim
/// line rather than as liquid lying in a trough. Dye mode is untouched (this multiplier
/// is only applied when liquid is on), so no existing golden moves.
const LIQUID_PIPE_WIDEN: f32 = 2.1;
/// The seed for the opt-in liquid mode's initial charge (deterministic — no clock).
const LIQUID_SEED: u64 = 0x5F57_4D41_5000_0001;
/// Initial fill level of every pipe/tank in liquid mode.
const LIQUID_LEVEL: f32 = 0.42;

/// **Gravity in the map's own (model) frame**, derived from the camera.
///
/// Gravity is fixed in the *room*: straight down the screen. The map has an
/// orientation — the orbit `yaw` about the up axis plus a `tilt` (a roll about the view
/// axis, i.e. tipping the whole map). This expresses "screen-down" back in model
/// coordinates, which is what each pipe dots its own direction against.
///
/// Sanity worth saying out loud: at `tilt = 0` this is `(0, −1, 0)` — straight down
/// world-Y — for **every** yaw. That is physically right: spinning a thing about the
/// vertical axis does not make water move. It is the **tilt** that pours it, and once
/// tilted, orbiting the yaw swings which pipes are the downhill ones.
#[must_use]
pub fn gravity_in_model_frame(yaw: f32, tilt: f32) -> [f32; 3] {
    let (st, ct) = tilt.sin_cos();
    let (sy, cy) = yaw.sin_cos();
    [st * cy, -ct, st * sy]
}

/// A robot-/CLI-addressable message (FC-2). Camera/layout/selection reuse the embedded
/// [`Graph3D`]'s [`Msg`](crate::Msg) (wrapped in [`Camera`](SystemMapMsg::Camera)); the
/// only net-new input is [`StepFluid`](SystemMapMsg::StepFluid), the single path the
/// fluid advances through (so the sim mutation stays inside `update`, FC-2).
#[derive(Clone, Debug, PartialEq)]
pub enum SystemMapMsg {
    /// A camera/layout/selection/clock message routed to the embedded [`Graph3D`].
    Camera(CamMsg),
    /// Advance the fluid by `dt` seconds (the gatling step). Emitted by `view` each
    /// frame from the ambient clock; applied by `update` — the UI never steps the sim.
    StepFluid(f32),
    /// **Liquid mode only** — tip the whole map by `d` radians about the view axis (a
    /// vertical drag). Gravity stays pinned to screen-down, so tipping the map is what
    /// pours the liquid. A no-op unless built [`with_liquid`](SystemMap::with_liquid).
    Tilt(f32),
    /// **Liquid mode only** — set the tip angle directly (programmatic / robot drive).
    SetTilt(f32),
}

/// Side work as data (FC-8) — the map does no I/O, so this is uninhabited.
#[derive(Clone, Debug, PartialEq)]
pub enum SystemMapEffect {}

/// **The system-map viewer.** A glass-pipe skin over an embedded [`Graph3D`] (camera +
/// layout + projection + selection, reused — L5) with fluid flowing through the pipes,
/// driven by [`facett_cfd::FluidField`].
pub struct SystemMap {
    pub title: String,
    /// The reused camera + geometry + selection engine. Its [`Graph3DView`] IS this
    /// component's [`Elm`] `Model`.
    graph: Graph3D,
    /// One fluid pipe per edge (gatling-advected dye). Preallocated once; borrowed for
    /// paint, stepped through `update` — never cloned.
    fluid: FluidField,
    /// Per-pipe fluid tint (categorical, from the source component's colour). Allocated
    /// once in `new`, read-only thereafter.
    dye_tint: Vec<Color32>,
    /// Ephemeral ambient-clock anchor for the per-frame `dt` (outside the Model, like
    /// the label cache) — so the live fluid advances in real time without a host clock.
    last_time: Cell<f64>,
    /// Ephemeral billboard-label anchor cache (outside the Model), like [`Graph3D`]'s.
    label_anchors: RefCell<Vec<Option<Pos2>>>,
    /// **OPT-IN, default `None`** — the real-liquid engine ([`PipeFlow`]): gravity-driven,
    /// volume-conserving, containment-by-construction liquid that pours downhill as the
    /// map is tipped. `None` (the default) leaves the dye-flow above **completely
    /// untouched**, byte-for-byte, so every existing golden still matches.
    liquid: Option<PipeFlow>,
    /// The map's tip angle (radians) about the view axis — the thing that pours the
    /// liquid. Always `0.0` unless liquid mode is on, and at `0.0` the paint path skips
    /// the rotation entirely, so the dye-mode render is bit-identical.
    tilt: f32,
}

impl SystemMap {
    /// Build a system map from `(label, colour)` component nodes + `(from, to)` pipe
    /// edges, laid out by `layout` (`Force` reads most like a plumbing diagram). Every
    /// pipe gets a downstream flow + a steady inlet, so the map flows immediately.
    pub fn new(nodes: Vec<(String, Color32)>, edges: Vec<(usize, usize)>, layout: Layout3D) -> Self {
        let n = nodes.len();
        // Per-pipe dye tint = the source component's colour, brightened so the fluid
        // reads as *that component's* data moving out along the pipe.
        let dye_tint: Vec<Color32> = edges
            .iter()
            .map(|&(a, _b)| brighten(nodes.get(a).map(|(_, c)| *c).unwrap_or(Color32::from_rgb(120, 200, 255))))
            .collect();
        let mut fluid = FluidField::new(edges.len(), CELLS);
        // Vary the flow + inlet a little per pipe so the map reads as live, uneven
        // traffic (some pipes gush, some trickle) rather than a uniform pump.
        for (i, _e) in edges.iter().enumerate() {
            fluid.set_flow(i, 4.0 + (i % 5) as f32 * 1.6);
            fluid.set_inject(i, 0.55 + (i % 3) as f32 * 0.18);
        }
        let graph = Graph3D::new(nodes, edges, layout).with_title("System Map");
        Self {
            title: "System Map".into(),
            graph,
            fluid,
            dye_tint,
            last_time: Cell::new(-1.0),
            label_anchors: RefCell::new(vec![None; n]),
            liquid: None,
            tilt: 0.0,
        }
    }

    pub fn with_title(mut self, t: impl Into<String>) -> Self {
        self.title = t.into();
        self
    }

    /// **Opt in to REAL LIQUID** (default OFF). Swaps the decorative dye column for
    /// [`PipeFlow`]: liquid with volume that falls toward gravity as the map is tipped
    /// ([`Tilt`](SystemMapMsg::Tilt)) and orbited, pools in the glass tanks, and cannot
    /// leave the bore. Charged from a **fixed seed** — no clock, no RNG — so a robot run
    /// is reproducible and golden-imageable.
    ///
    /// Leaving it off is the byte-identical status quo: nothing on the dye path, the
    /// paint path, the interaction surface or `state_json` changes.
    #[must_use]
    pub fn with_liquid(mut self, on: bool) -> Self {
        self.liquid = on.then(|| {
            let pos: Vec<[f32; 3]> = self.graph.nodes.iter().map(|n| n.pos).collect();
            let mut f = PipeFlow::new(pos, &self.graph.edges, CELLS);
            f.seed_fill(LIQUID_SEED, LIQUID_LEVEL);
            f.set_gravity(gravity_in_model_frame(self.graph.render_yaw(), 0.0));
            f
        });
        self
    }

    /// The real-liquid engine, if this map was built [`with_liquid`](Self::with_liquid).
    #[must_use]
    pub fn liquid(&self) -> Option<&PipeFlow> {
        self.liquid.as_ref()
    }

    /// **Re-charge the network** to `level` (0..=1) of every tube and tank from `seed`.
    /// The host's "fill it up" control, and the way the containment test gets the map
    /// into its *hardest* state — nearly brim-full, where any missing capacity limit
    /// immediately pushes liquid through a wall. No-op without liquid.
    pub fn charge_liquid(&mut self, seed: u64, level: f32) {
        if let Some(l) = self.liquid.as_mut() {
            l.seed_fill(seed, level);
        }
    }

    /// The map's tip angle (radians) about the view axis. Always `0.0` in dye mode.
    #[must_use]
    pub fn tilt(&self) -> f32 {
        self.tilt
    }

    /// Gravity as the liquid currently sees it, in the map's own model frame.
    #[must_use]
    pub fn gravity(&self) -> [f32; 3] {
        gravity_in_model_frame(self.graph.render_yaw(), self.tilt)
    }

    /// Read the reused camera/selection Model.
    pub fn state(&self) -> &Graph3DView {
        self.graph.state()
    }

    /// Read the fluid field (for a host / a headless proof).
    pub fn fluid(&self) -> &FluidField {
        &self.fluid
    }

    /// Number of pipes (edges).
    pub fn pipes(&self) -> usize {
        self.graph.edges.len()
    }

    /// **Deterministic headless tick** — advance the fluid AND the camera spin by `dt`
    /// seconds without egui, for snapshot/robot tests that need reproducible motion.
    /// Routes the fluid through the same [`update`](Self::update) path the live frame
    /// uses.
    pub fn tick(&mut self, dt: f32) {
        let _ = self.update(SystemMapMsg::StepFluid(dt));
        let _ = self.graph.update(CamMsg::AdvanceSpin(dt));
    }

    /// **Deterministic headless tick with the map held still** — advance only the fluid
    /// / liquid by `dt`, no camera spin. The arm a liquid property test drives, so the
    /// only thing changing between frames is the physics.
    pub fn tick_fluid(&mut self, dt: f32) {
        let _ = self.update(SystemMapMsg::StepFluid(dt));
    }

    /// **FC-2 / FC-8** — the single mutation path. Camera messages route to the reused
    /// [`Graph3D`]; [`StepFluid`](SystemMapMsg::StepFluid) advances the gatling fluid.
    pub fn update(&mut self, msg: SystemMapMsg) -> Vec<SystemMapEffect> {
        match msg {
            SystemMapMsg::Camera(m) => {
                // A relayout moves the pipes, so the liquid's geometry — and therefore
                // which pipes are downhill — has to be re-derived.
                let relayout = matches!(m, CamMsg::SetLayout(_));
                let _ = self.graph.update(m);
                if relayout && let Some(l) = self.liquid.as_mut() {
                    for (i, n) in self.graph.nodes.iter().enumerate() {
                        l.set_node_pos(i, n.pos);
                    }
                    l.refresh_geometry();
                }
                self.reaim_gravity();
            }
            SystemMapMsg::StepFluid(dt) => {
                let dt = dt.clamp(0.0, 0.1);
                match self.liquid.as_mut() {
                    // REAL LIQUID: re-aim gravity from the *current* camera (a pure
                    // function of the Model), then take the gatling step.
                    Some(l) => {
                        l.set_gravity(gravity_in_model_frame(self.graph.render_yaw(), self.tilt));
                        l.step(dt);
                    }
                    // The unchanged dye path (zero-alloc / zero-copy / zero-wait).
                    None => self.fluid.step(dt),
                }
            }
            SystemMapMsg::Tilt(d) => {
                if self.liquid.is_some() {
                    self.tilt = (self.tilt + d).clamp(-std::f32::consts::PI, std::f32::consts::PI);
                    self.reaim_gravity();
                }
            }
            SystemMapMsg::SetTilt(a) => {
                if self.liquid.is_some() {
                    self.tilt = a.clamp(-std::f32::consts::PI, std::f32::consts::PI);
                    self.reaim_gravity();
                }
            }
        }
        Vec::new()
    }

    /// Push the camera-derived gravity into the liquid, so `SystemMap::gravity()`, the
    /// published `state_json` and what the solver actually integrates can never disagree
    /// — including *between* steps, which is what makes an observable like
    /// [`PipeFlow::fall_moment`] comparable across a camera move.
    fn reaim_gravity(&mut self) {
        let g = gravity_in_model_frame(self.graph.render_yaw(), self.tilt);
        if let Some(l) = self.liquid.as_mut() {
            l.set_gravity(g);
        }
    }

    /// **The containment oracle's geometry** — the *screen-space* bore of pipe `pi` and
    /// the liquid band of each of its cells, in a pane of the given `rect`. This is the
    /// **same** pair of kernels [`view`](Self::view) paints from, so the containment test
    /// asserts on the geometry that is actually drawn, not on a re-derivation of it that
    /// could quietly drift.
    ///
    /// `None` when there is no liquid, `pi` is out of range, or the pipe is degenerate.
    #[must_use]
    pub fn bore_and_bands(&self, rect: egui::Rect, pi: usize) -> Option<(PipeQuad, Vec<PipeQuad>)> {
        let l = self.liquid.as_ref()?;
        let &(a, b) = self.graph.edges.get(pi)?;
        let center = rect.center();
        let scale = rect.size().min_elem() * 0.40;
        let proj = self.projected(center, scale);
        let (&(pa, za), &(pb, zb)) = (proj.get(a)?, proj.get(b)?);
        let half_w = self.wall_half_width((za + zb) * 0.5) * BORE;
        let bore = pipe_wall_quad(pa, pb, half_w)?;
        let fill = l.fill(pi);
        let bands = (0..fill.len()).filter_map(|i| liquid_band(pa, pb, half_w, i, fill.len(), fill[i])).collect();
        Some((bore, bands))
    }

    /// **The tank half of the containment oracle** — every junction's `(centre, radius,
    /// liquid polygon)` in a pane of the given `rect`, from the same kernels
    /// [`view`](Self::view) paints from. `None` without liquid.
    #[must_use]
    pub fn tank_geometry(&self, rect: egui::Rect) -> Option<Vec<(Pos2, f32, Vec<Pos2>)>> {
        let l = self.liquid.as_ref()?;
        let center = rect.center();
        let scale = rect.size().min_elem() * 0.40;
        Some(
            self.projected(center, scale)
                .iter()
                .enumerate()
                .map(|(i, &(p, z))| {
                    let r = (7.0 + (z + 1.0) * 4.0) * 0.9;
                    (p, r, tank_liquid_poly(p, r, l.tank_level(i)))
                })
                .collect(),
        )
    }

    /// The drawn half-width of a pipe at projected `depth` for **this** map: the shared
    /// depth cue, widened in liquid mode so a free surface is actually legible. One
    /// place, so the painter and the containment oracle cannot disagree about how wide
    /// the tube is.
    fn wall_half_width(&self, depth: f32) -> f32 {
        let w = pipe_half_width(depth);
        if self.liquid.is_some() { w * LIQUID_PIPE_WIDEN } else { w }
    }

    /// Project every node into a `center`/`scale` pane at the current camera, applying
    /// the map's **tip** as a roll of the projected plane. Under the orthographic
    /// projection `Graph3D` uses, a screen roll IS a rotation of the object about the
    /// view axis — so the paint and [`gravity_in_model_frame`] describe the same rigid
    /// motion, which is exactly why the liquid falls where it looks like it should. At
    /// `tilt == 0` (always, in dye mode) the rotation is skipped entirely, so the
    /// existing render is bit-identical.
    fn projected(&self, center: Pos2, scale: f32) -> Vec<(Pos2, f32)> {
        let mut proj = self.graph.project_all(center, scale, self.graph.render_yaw());
        if self.tilt != 0.0 && !proj.is_empty() {
            // Tip the map about the CLOUD's own centroid, not the pane's centre. A
            // layout is rarely centred on the pane, and rolling an off-centre cloud
            // about the pane centre swings half the map out of view. Rotating about
            // the centroid is the same rotation plus a translation — gravity depends
            // only on the rotation part, so the physics is untouched.
            let n = proj.len() as f32;
            let cx = proj.iter().map(|(p, _)| p.x).sum::<f32>() / n;
            let cy = proj.iter().map(|(p, _)| p.y).sum::<f32>() / n;
            let pivot = Pos2::new(cx, cy);
            let (s, c) = self.tilt.sin_cos();
            for (p, _) in proj.iter_mut() {
                let d = *p - pivot;
                *p = pivot + vec2(d.x * c - d.y * s, d.x * s + d.y * c);
            }
        }
        proj
    }

    /// **FC-9 render** — a **pure** paint that projects the components, draws each pipe
    /// as a translucent glass tube with the fluid flowing inside, each node as a glass
    /// tank, and *returns* the [`SystemMapMsg`]s the interactions produced (orbit drag,
    /// auto-spin tick, click-to-focus, the per-frame fluid step). It never mutates the
    /// Model — the returned messages are applied by the Elm bridge.
    pub fn view(&self, ui: &mut egui::Ui) -> Vec<SystemMapMsg> {
        let mut msgs: Vec<SystemMapMsg> = Vec::new();
        // Consume the active palette (records the theme probe — the mega-matrix palette
        // axis) and paint from it, so the map re-themes with the deck.
        let th = theme(ui);
        let cam = self.graph.state();

        // Auto-spin tick (emit through the reused camera, exactly like Graph3D/CodeVault).
        if cam.spin && cam.clock_s.is_none() {
            let dt = ui.input(|i| i.stable_dt).min(0.1);
            msgs.push(SystemMapMsg::Camera(CamMsg::AdvanceSpin(dt)));
        }
        // Advance the fluid from the ambient clock (live). Emitted as a Msg so the sim
        // mutation happens in `update`, never in this pure view. Keep repainting so the
        // fluid actually flows between input events.
        let now = ui.input(|i| i.time);
        let last = self.last_time.get();
        let dt = if last < 0.0 { 0.0 } else { (now - last).clamp(0.0, 0.1) as f32 };
        self.last_time.set(now);
        if dt > 0.0 {
            msgs.push(SystemMapMsg::StepFluid(dt));
        }
        ui.ctx().request_repaint();

        let render_yaw = self.graph.render_yaw();
        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::click_and_drag());
        if resp.dragged() {
            let dx = resp.drag_delta().x;
            if dx != 0.0 {
                msgs.push(SystemMapMsg::Camera(CamMsg::DragYaw(dx)));
            }
            // Liquid mode only: a VERTICAL drag tips the whole map, which is what
            // actually pours the liquid. In dye mode this arm does not exist, so the
            // interaction surface is unchanged.
            if self.liquid.is_some() {
                let dy = resp.drag_delta().y;
                if dy != 0.0 {
                    msgs.push(SystemMapMsg::Tilt(dy * 0.01));
                }
            }
        }
        // The empty system-map state carries the stable UI error code facet-systemmap-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.graph.nodes.is_empty();
        resp.widget_info(|| {
            let s = facett_core::a11y::Semantics::image(format!(
                "System Map — {} components · {} glass pipes · yaw {:.0}°",
                self.graph.nodes.len(),
                self.pipes(),
                render_yaw.to_degrees().rem_euclid(360.0),
            ))
            .value(render_yaw as f64);
            let s = if empty {
                s.severity(facett_core::Severity::Error).error_code("facet-systemmap-1")
            } else {
                s
            };
            s.widget_info()
        });

        let painter = ui.painter_at(rect);
        // A deep, calm ground so the translucent glass + luminous fluid read.
        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
        if empty {
            painter.text(rect.center(), egui::Align2::CENTER_CENTER, "empty system map", 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-systemmap-1");
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit("facett-graph3d::SystemMap::ui", "ui_render", true, "nodes=0 drew=empty_hint");
            return msgs;
        }

        let center = rect.center();
        let scale = rect.size().min_elem() * 0.40;
        let proj = self.projected(center, scale);

        // Left-click → focus the nearest component (reuse the graph's selection); empty → clear.
        if 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 < 28.0_f32.powi(2))
                .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
                .map(|(i, _)| i);
            msgs.push(SystemMapMsg::Camera(match hit {
                Some(i) => CamMsg::SelectNode(i),
                None => CamMsg::ClearSelection,
            }));
        }
        let sel = cam.selected.filter(|&s| s < self.graph.nodes.len());

        // ── glass pipes with the fluid inside — drawn first, under the tanks ───────────
        // Back-to-front by mean depth so nearer glass overlays farther glass correctly.
        let mut pipe_order: Vec<usize> = (0..self.graph.edges.len()).collect();
        pipe_order.sort_by(|&i, &j| {
            let (a0, b0) = self.graph.edges[i];
            let (a1, b1) = self.graph.edges[j];
            let di = if a0 < proj.len() && b0 < proj.len() { (proj[a0].1 + proj[b0].1) * 0.5 } else { -2.0 };
            let dj = if a1 < proj.len() && b1 < proj.len() { (proj[a1].1 + proj[b1].1) * 0.5 } else { -2.0 };
            di.partial_cmp(&dj).unwrap_or(std::cmp::Ordering::Equal)
        });
        for pi in pipe_order {
            let (a, b) = self.graph.edges[pi];
            if a >= proj.len() || b >= proj.len() {
                continue;
            }
            let (pa, za) = proj[a];
            let (pb, zb) = proj[b];
            let depth = (za + zb) * 0.5;
            // Pipe radius + brightness grow with nearness (depth cue).
            let half_w = self.wall_half_width(depth);
            let dim = match sel {
                Some(s) if s != a && s != b => 0.35,
                _ => 1.0,
            };
            paint_glass_pipe(&painter, pa, pb, half_w, th.edge, th.text, depth, dim);
            match self.liquid.as_ref() {
                // REAL LIQUID: a free surface lying on the tube's downhill wall.
                Some(l) => paint_liquid(&painter, pa, pb, half_w * BORE, l.fill(pi), self.dye_tint[pi], dim),
                // The dye column, unchanged: sample the borrowed dye row (zero-copy).
                None => paint_fluid(&painter, pa, pb, half_w * 0.62, self.fluid.dye(pi), self.dye_tint[pi], self.fluid.flow(pi), dim),
            }
        }

        // ── glass tanks (components), back-to-front ────────────────────────────────────
        let mut order: Vec<usize> = (0..proj.len()).collect();
        order.sort_by(|&i, &j| proj[i].1.partial_cmp(&proj[j].1).unwrap_or(std::cmp::Ordering::Equal));
        for i in order {
            let (p, z) = proj[i];
            let r = 7.0 + (z + 1.0) * 4.0;
            let dim = match sel {
                Some(s) if s != i => 0.4,
                _ => 1.0,
            };
            let base = self.graph.nodes[i].color;
            // Emissive glow behind the tank (bloom-fed on the GPU lane).
            neon::paint_node_orb(&painter, p, r * 1.1, base, 0.45, dim);
            // REAL LIQUID: the junction tank has a LEVEL, drawn under the glass so it
            // reads as liquid standing behind the wall.
            if let Some(l) = self.liquid.as_ref() {
                paint_tank_liquid(&painter, p, r * 0.9, l.tank_level(i), brighten(base), dim);
            }
            paint_glass_node(&painter, p, r, base, dim);
        }

        // ── billboard labels (reuse facett_core::labels3d) ─────────────────────────────
        if cam.show_labels {
            let mut anchors = self.label_anchors.borrow_mut();
            if anchors.len() != self.graph.nodes.len() {
                *anchors = vec![None; self.graph.nodes.len()];
            }
            let font = egui::FontId::proportional(11.0);
            let deterministic = cam.clock_s.is_some();
            for i in 0..proj.len() {
                let (p, z) = proj[i];
                let r = 7.0 + (z + 1.0) * 4.0;
                let raw = p + vec2(0.0, -r - 3.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.graph.nodes[i].label, font.clone(), th.text.gamma_multiply(bright));
            }
        }

        #[cfg(feature = "testmatrix")]
        {
            let finite = proj.iter().all(|(p, z)| p.x.is_finite() && p.y.is_finite() && z.is_finite());
            facett_core::testmatrix::emit(
                "facett-graph3d::SystemMap::ui",
                "ui_render",
                !self.graph.nodes.is_empty() && finite && self.fluid.total_dye() >= 0.0,
                &format!("components={} pipes={} total_dye={:.3} projection_finite={finite}", self.graph.nodes.len(), self.pipes(), self.fluid.total_dye()),
            );
        }
        msgs
    }

    /// **Build the GPU bloom-lane scene** (feature `wgpu`) — reuse [`crate::graph_gpu`]
    /// (additive HDR + bright-pass + gaussian blur + composite) with tank centres as
    /// emissive discs and each pipe carrying radiance scaled by its **live dye load**, so
    /// the same bloom that glows the graph makes a gushing pipe glow hotter. Pure
    /// projection (no egui) → unit-testable headless.
    #[cfg(feature = "wgpu")]
    pub fn gpu_scene(&self, w: f32, h: f32) -> crate::graph_gpu::GraphScene {
        let center = Pos2::new(w * 0.5, h * 0.5);
        let scale = w.min(h) * 0.40;
        let proj = self.graph.project_all(center, scale, self.graph.render_yaw());
        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 = 3.0 * dbright * mul;
            [lin(c.r()) * k, lin(c.g()) * k, lin(c.b()) * k, 1.0]
        };
        let nodes = proj
            .iter()
            .enumerate()
            .map(|(i, &(p, z))| crate::graph_gpu::NodeInstance {
                center: [p.x, p.y],
                radius: 5.0 + (z + 1.0) * 3.0,
                _pad: 0.0,
                color: radiance(self.graph.nodes[i].color, z, 1.0),
            })
            .collect();
        let edges = self
            .graph
            .edges
            .iter()
            .enumerate()
            .filter(|&(_, &(a, b))| a < proj.len() && b < proj.len())
            .map(|(pi, &(a, b))| {
                let depth = (proj[a].1 + proj[b].1) * 0.5;
                // A gushing pipe (more dye) blooms brighter.
                let load = (self.fluid.dye(pi).iter().copied().sum::<f32>() / CELLS as f32).clamp(0.0, 1.0);
                crate::graph_gpu::EdgeInstance {
                    a: [proj[a].0.x, proj[a].0.y],
                    b: [proj[b].0.x, proj[b].0.y],
                    width: 2.0,
                    _pad: 0.0,
                    color: radiance(self.dye_tint[pi], depth, 0.5 + 1.2 * load),
                }
            })
            .collect();
        crate::graph_gpu::GraphScene { background: [0.01, 0.012, 0.02, 1.0], nodes, edges }
    }
}

/// Paint one **glass pipe** — a translucent, refractive-feeling tube from `a` to `b`
/// of half-width `half_w`. A faint translucent body (you see far pipes + the fluid
/// through it), a bright **fresnel rim** down both silhouette edges, and a thin
/// **specular streak** — the cheap glass look, all on the CPU painter (wasm-clean).
#[allow(clippy::too_many_arguments)] // painter helper: endpoints, width, glass + rim style
fn paint_glass_pipe(painter: &egui::Painter, a: Pos2, b: Pos2, half_w: f32, glass: Color32, rim: Color32, depth: f32, dim: f32) {
    let d = b - a;
    let len = d.length();
    if len < 1e-3 {
        return;
    }
    let dir = d / len;
    let nrm = vec2(-dir.y, dir.x); // unit perpendicular
    let body_a = (GLASS_ALPHA * (0.7 + (depth + 1.0) * 0.3) * dim).clamp(0.0, 1.0);
    // Translucent wall (a quad the width of the pipe).
    let quad = vec![a + nrm * half_w, b + nrm * half_w, b - nrm * half_w, a - nrm * half_w];
    painter.add(Shape::convex_polygon(quad, glass.gamma_multiply(body_a), Stroke::NONE));
    // Fresnel rim: brighter along the two silhouette edges (glass edges catch light).
    let rim_col = rim.gamma_multiply((0.5 * dim).clamp(0.0, 1.0));
    painter.line_segment([a + nrm * half_w, b + nrm * half_w], Stroke::new(1.0_f32, rim_col));
    painter.line_segment([a - nrm * half_w, b - nrm * half_w], Stroke::new(1.0_f32, rim_col));
    // Specular streak: a thin bright highlight offset toward the "light" (upper edge).
    let spec = Color32::from_white_alpha((70.0 * dim) as u8);
    painter.line_segment([a + nrm * half_w * 0.45, b + nrm * half_w * 0.45], Stroke::new(1.0_f32, spec));
}

/// Paint the **fluid inside a pipe**: sample the borrowed dye row along the tube and
/// draw it as a luminous column whose per-segment alpha follows the local dye — so the
/// pulsing inlet packets read as bright slugs marching in the flow direction. The dye
/// slice is **borrowed** (zero-copy); nothing is allocated per cell here beyond the
/// tessellator's own work.
#[allow(clippy::too_many_arguments)] // painter helper: endpoints, width, dye field, style
fn paint_fluid(painter: &egui::Painter, a: Pos2, b: Pos2, inner_w: f32, dye: &[f32], tint: Color32, flow: f32, dim: f32) {
    let n = dye.len();
    if n < 2 {
        return;
    }
    // Draw downstream→ so the caps overlap in the flow direction (smooth column).
    let _ = flow; // direction is already baked into the dye field's inlet end
    for (i, &d) in dye.iter().enumerate().take(n - 1) {
        let t0 = i as f32 / (n - 1) as f32;
        let t1 = (i + 1) as f32 / (n - 1) as f32;
        let p0 = a + (b - a) * t0;
        let p1 = a + (b - a) * t1;
        if d <= 0.02 {
            continue;
        }
        // Concentration → alpha; a bright core sits on top of a softer body.
        let alpha = (d / 1.2 * dim).clamp(0.0, 1.0);
        painter.line_segment([p0, p1], Stroke::new(inner_w, tint.gamma_multiply(alpha)));
        if d > 0.7 {
            // A hot packet: a bright specular core down the middle.
            let core = Color32::from_white_alpha((120.0 * dim).min(200.0) as u8);
            painter.line_segment([p0, p1], Stroke::new(inner_w * 0.4, core.gamma_multiply(alpha)));
        }
    }
}

/// Paint one **glass tank/junction** at `c` with radius `r`: a translucent tinted body,
/// a frosted core, a bright **fresnel rim** ring, and a **specular highlight** dot — a
/// glass sphere the pipes plug into.
fn paint_glass_node(painter: &egui::Painter, c: Pos2, r: f32, base: Color32, dim: f32) {
    // Translucent tinted body (you can see pipes pass behind it).
    painter.circle_filled(c, r, base.gamma_multiply((0.28 * dim).clamp(0.0, 1.0)));
    // Frost — a faint white fill for the glass milkiness.
    painter.circle_filled(c, r * 0.92, Color32::from_white_alpha((22.0 * dim) as u8));
    // Fresnel rim ring (brightest at the silhouette).
    painter.circle_stroke(c, r, Stroke::new(1.6_f32, base.gamma_multiply((0.9 * dim).clamp(0.0, 1.0))));
    // Specular highlight (upper-left), the tell-tale glossy dot.
    let hp = c + vec2(-r * 0.34, -r * 0.34);
    painter.circle_filled(hp, r * 0.22, Color32::from_white_alpha((150.0 * dim) as u8));
}

// ── REAL-LIQUID geometry (shared by the painter AND the containment oracle) ──────────
//
// Pure kernels. `view` paints from them and the containment test asserts on them, so
// "no liquid outside the pipe" is checked against the geometry that is actually drawn.

/// A convex screen-space quad — a pipe bore, or one cell's liquid band.
pub type PipeQuad = [Pos2; 4];

/// The drawn half-width of a pipe at projected `depth` (the depth cue). ONE place, so
/// the painter and the oracle can never disagree about how wide the tube is.
#[inline]
#[must_use]
pub fn pipe_half_width(depth: f32) -> f32 {
    3.0 + (depth + 1.0) * 2.4
}

/// The pipe's rectangle from `a` to `b` at half-width `half_w`. `None` if degenerate.
#[must_use]
pub fn pipe_wall_quad(a: Pos2, b: Pos2, half_w: f32) -> Option<PipeQuad> {
    let d = b - a;
    let len = d.length();
    if len < 1e-3 {
        return None;
    }
    let n = vec2(-d.y / len, d.x / len);
    Some([a + n * half_w, b + n * half_w, b - n * half_w, a - n * half_w])
}

/// **The liquid band of cell `i`** — the free-surface quad drawn for a cell holding
/// fill fraction `f`.
///
/// The band spans that cell's own slice of the pipe (`i/n .. (i+1)/n`) and, across the
/// bore, runs from the tube's **downhill wall** inward by `2·half_w·f`. Because
/// `f ∈ [0, 1]` (the solver's invariant), the inward edge sits at
/// `half_w·(1 − 2f) ∈ [−half_w, +half_w]` — i.e. **still between the two walls**. That
/// is the containment argument in one line, and it is why a half-full horizontal pipe
/// shows liquid lying on its floor with air above it, instead of a stripe down the
/// middle.
///
/// **`f` is mapped faithfully and is deliberately NOT clamped.** A defensive clamp here
/// would silently hide a broken solver behind a correct-looking picture; leaving the map
/// honest is what lets the containment oracle
/// ([`SystemMap::bore_and_bands`]) be a real end-to-end check of
/// *invariant → geometry → pixels* rather than a check of this function's clamp.
#[must_use]
pub fn liquid_band(a: Pos2, b: Pos2, half_w: f32, i: usize, n: usize, f: f32) -> Option<PipeQuad> {
    if n == 0 || i >= n || f <= 0.0 {
        return None;
    }
    let d = b - a;
    let len = d.length();
    if len < 1e-3 {
        return None;
    }
    let nrm = vec2(-d.y / len, d.x / len);
    // The perpendicular that points DOWN the screen — gravity is screen-down, so that
    // is the wall the liquid lies on.
    let dn = if nrm.y >= 0.0 { nrm } else { -nrm };
    let (t0, t1) = (i as f32 / n as f32, (i + 1) as f32 / n as f32);
    let (p0, p1) = (a + d * t0, a + d * t1);
    let (lo, hi) = (dn * half_w, dn * (half_w * (1.0 - 2.0 * f)));
    Some([p0 + lo, p1 + lo, p1 + hi, p0 + hi])
}

/// The **circular segment** of liquid standing at `level` (0..=1) inside a tank of
/// radius `r` at `c`: a flat surface with the glass sphere's wall below it. Empty when
/// dry. Every vertex lies **on** the circle, so the tank contains its liquid for the
/// same reason a pipe does.
///
/// Note the degenerate ends: a **full** tank's surface is a zero-length chord and an
/// **empty** one has no surface at all. [`tank_surface`] is the guard that keeps a
/// caller from stroking either — a zero-length `line_segment` has no well-defined
/// normal, and egui tessellates it into a long spurious streak across the pane.
#[must_use]
pub fn tank_liquid_poly(c: Pos2, r: f32, level: f32) -> Vec<Pos2> {
    let level = level.clamp(0.0, 1.0);
    if level <= 0.0 || r <= 0.0 {
        return Vec::new();
    }
    const ARC: usize = 14;
    // Screen y grows downward, so the surface sits `level` of the way up from the bottom.
    let dy = (r - 2.0 * r * level).clamp(-r, r);
    let theta0 = (dy / r).clamp(-1.0, 1.0).asin();
    let (t0, span) = if level >= 1.0 {
        // A brim-full tank is the whole disc. Swept as a plain circle with DISTINCT
        // angles — reusing the segment sweep here would put the first and last vertex on
        // top of each other (see the dedupe below for why that matters).
        (0.0, std::f32::consts::TAU * (ARC as f32 / (ARC + 1) as f32))
    } else {
        (theta0, std::f32::consts::PI - 2.0 * theta0)
    };
    let mut poly: Vec<Pos2> = Vec::with_capacity(ARC + 1);
    for k in 0..=ARC {
        let t = t0 + span * (k as f32 / ARC as f32);
        let (s, co) = t.sin_cos();
        let p = Pos2::new(c.x + r * co, c.y + r * s);
        // DEDUPE, and it is load-bearing. As the level approaches empty the arc span
        // collapses and every sampled point lands on the same pixel; egui derives a
        // filled path's vertex normals from its edges, so a zero-length edge yields a
        // NaN normal and the tessellator throws a bright streak clear across the pane.
        // A polygon whose vertices are all distinct cannot do that.
        if poly.last().is_some_and(|q| (p - *q).length() < 0.05) {
            continue;
        }
        poly.push(p);
    }
    if poly.len() < 3 { Vec::new() } else { poly }
}

/// Paint the **real liquid** in a pipe: each cell's free-surface band lying on the
/// tube's downhill wall, with a bright meniscus along the surface so it reads as a
/// *surface* and not a gradient. Geometry comes from [`liquid_band`] — the same kernel
/// the containment test asserts on.
fn paint_liquid(painter: &egui::Painter, a: Pos2, b: Pos2, half_w: f32, fill: &[f32], tint: Color32, dim: f32) {
    let n = fill.len();
    if n == 0 || half_w <= 0.0 {
        return;
    }
    let body = tint.gamma_multiply((0.80 * dim).clamp(0.0, 1.0));
    let meniscus = Stroke::new(1.0_f32, Color32::from_white_alpha((110.0 * dim) as u8));
    // The free surface is drawn as ONE continuous polyline over the run of wet cells,
    // including the step between neighbours — a per-cell segment leaves a comb of
    // disconnected dashes, which reads as hatching rather than as a water line.
    let mut surface: Vec<Pos2> = Vec::new();
    let flush = |s: &mut Vec<Pos2>| {
        if s.len() >= 2 {
            painter.add(Shape::line(std::mem::take(s), meniscus));
        } else {
            s.clear();
        }
    };
    for (i, &f) in fill.iter().enumerate() {
        if f <= 0.01 {
            flush(&mut surface);
            continue;
        }
        let Some(q) = liquid_band(a, b, half_w, i, n, f) else {
            flush(&mut surface);
            continue;
        };
        painter.add(Shape::convex_polygon(q.to_vec(), body, Stroke::NONE));
        // q[3] → q[2] is this cell's liquid/air edge; chaining them across cells draws
        // the steps between neighbouring levels too.
        surface.push(q[3]);
        surface.push(q[2]);
    }
    flush(&mut surface);
}

/// The tank's **liquid/air chord** — the flat surface line of `poly`, or `None` when
/// there is no surface to draw: an empty tank has none, and a **full** one's chord has
/// collapsed to zero length. Stroking a zero-length segment is not a cosmetic detail —
/// it has no well-defined normal, and egui expands it into a long spurious streak
/// straight across the pane (which is exactly how this was found).
#[must_use]
pub fn tank_surface(poly: &[Pos2]) -> Option<[Pos2; 2]> {
    let (a, b) = (*poly.first()?, *poly.last()?);
    ((b - a).length() > 0.5).then_some([a, b])
}

/// Paint a junction tank's liquid **level** — the circular segment standing in the glass
/// sphere, with a bright flat surface. Drawn under the glass so it reads as contained.
fn paint_tank_liquid(painter: &egui::Painter, c: Pos2, r: f32, level: f32, tint: Color32, dim: f32) {
    let poly = tank_liquid_poly(c, r, level);
    if poly.len() < 3 {
        return;
    }
    let surface = tank_surface(&poly);
    painter.add(Shape::convex_polygon(poly, tint.gamma_multiply((0.55 * dim).clamp(0.0, 1.0)), Stroke::NONE));
    if let Some(s) = surface {
        painter.line_segment(s, Stroke::new(1.2_f32, Color32::from_white_alpha((130.0 * dim) as u8)));
    }
}

/// Brighten a colour toward white so fluid dye reads as *luminous* over the glass.
fn brighten(c: Color32) -> Color32 {
    let f = |x: u8| ((x as f32) * 0.55 + 255.0 * 0.45).round().clamp(0.0, 255.0) as u8;
    Color32::from_rgb(f(c.r()), f(c.g()), f(c.b()))
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split (Model reused from Graph3D) ──
impl facett_core::Elm for SystemMap {
    type Model = Graph3DView;
    type Msg = SystemMapMsg;
    type Effect = SystemMapEffect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &Graph3DView {
        self.graph.state()
    }
    fn update(&mut self, msg: SystemMapMsg) -> Vec<SystemMapEffect> {
        SystemMap::update(self, msg)
    }
    fn view(&self, ui: &mut egui::Ui) -> Vec<SystemMapMsg> {
        SystemMap::view(self, ui)
    }
}

// A RICHER `state_json` than the raw camera Model — the topology + the fluid
// observables (per-pipe flow + dye, total dye) a robot driver reads to assert the
// fluid is present and MOVING as data — so form 3 (`custom_state_json`) supplies it.
facett_core::impl_facet_via_elm!(SystemMap, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        let cam = self.graph.state();
        let mut j = serde_json::json!({
            "components": self.graph.nodes.len(),
            "pipes": self.graph.edges.len(),
            "layout": cam.layout.label(),
            "yaw": cam.yaw,
            "spin": cam.spin,
            "clock_s": cam.clock_s,
            "show_labels": cam.show_labels,
            "selected_node": cam.selected.filter(|&s| s < self.graph.nodes.len()),
            // The glass material is intrinsic (translucent < 1) — surfaced as data.
            "glass_alpha": GLASS_ALPHA,
            // The fluid — the whole point: sizes, per-pipe flow, and the dye load a
            // driver asserts is > 0 (present) and time-varying (moving).
            "fluid": self.fluid.state_json(),
            // The stable UI ERROR CODE for the empty system-map state (facett_core::errcode):
            // present so tests + consumers react to the CODE, not a matched string.
            "error_code": if self.graph.nodes.is_empty() {
                serde_json::json!("facet-systemmap-1")
            } else {
                serde_json::Value::Null
            },
        });
        // OPT-IN: the real-liquid observables are added ONLY in liquid mode, so the
        // default map's `state_json` is byte-identical to what it always was.
        if let Some(l) = self.liquid.as_ref() && let Some(o) = j.as_object_mut() {
            o.insert("tilt".into(), serde_json::json!(self.tilt));
            o.insert("gravity".into(), serde_json::json!(self.gravity()));
            o.insert("liquid".into(), l.state_json());
        }
        j
    }
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable().selectable()
    }
    /// STRUCTURAL severity (the Robot-UI gate signal): RED when the map has no
    /// components/pipes to draw (the facet-systemmap-1 error state), Info otherwise.
    fn severity(&self) -> facett_core::Severity {
        if self.graph.nodes.is_empty() {
            facett_core::Severity::Error
        } else {
            facett_core::Severity::Info
        }
    }
    fn kind(&self) -> &'static str {
        "systemmap"
    }
});

#[cfg(test)]
mod tests {
    use super::*;
    use facett_core::Facet;
    use facett_core::harness;

    /// `(label, colour)` nodes + index edges, the shape `Graph3D::new` takes.
    type NodesEdges = (Vec<(String, Color32)>, Vec<(usize, usize)>);

    /// A small nordisk-constellation fixture: holger → nornir → dwarves → korp, plus
    /// facett / skade / knut wired in — 7 components, a handful of pipes.
    fn constellation() -> NodesEdges {
        let c = |r, g, b| Color32::from_rgb(r, g, b);
        let nodes = vec![
            ("holger".to_string(), c(200, 150, 240)),  // 0
            ("nornir".to_string(), c(90, 170, 250)),   // 1
            ("dwarves".to_string(), c(240, 170, 70)),  // 2
            ("korp".to_string(), c(230, 120, 150)),    // 3
            ("facett".to_string(), c(120, 210, 140)),  // 4
            ("skade".to_string(), c(80, 200, 220)),    // 5
            ("knut".to_string(), c(250, 210, 90)),     // 6
        ];
        let edges = vec![(0, 1), (1, 2), (2, 3), (1, 4), (4, 3), (5, 1), (6, 1), (2, 6)];
        (nodes, edges)
    }

    fn sysmap() -> SystemMap {
        let (n, e) = constellation();
        SystemMap::new(n, e, Layout3D::Force)
    }

    #[test]
    fn builds_the_topology_and_a_pipe_per_edge() {
        let m = sysmap();
        let j = m.state_json();
        assert_eq!(j["components"], 7);
        assert_eq!(j["pipes"], 8);
        assert_eq!(m.title(), "System Map");
        assert_eq!(j["fluid"]["pipes"], 8, "one fluid pipe per edge");
        assert!(j["glass_alpha"].as_f64().unwrap() < 1.0, "the pipes are translucent glass");
        // state_json must be a non-empty object (deck_no_error_atoms gate).
        assert!(j.as_object().map(|o| !o.is_empty()).unwrap_or(false));
    }

    // ── RED-when-broken: the fluid actually MOVES ───────────────────────────────────
    #[test]
    fn fluid_flows_and_a_later_frame_differs() {
        let mut m = sysmap();
        // Warm the pipes, snapshot pipe 0's dye, run more, and assert it changed +
        // there is dye present. If the fluid step is removed/frozen, these go RED.
        for _ in 0..20 {
            m.tick(0.02);
        }
        let a: Vec<f32> = m.fluid().dye(0).to_vec();
        let dye_a: f64 = m.state_json()["fluid"]["total_dye"].as_f64().unwrap();
        assert!(dye_a > 0.0, "fluid is flowing through the pipes");
        for _ in 0..30 {
            m.tick(0.02);
        }
        let b = m.fluid().dye(0);
        assert_ne!(a.as_slice(), b, "a later frame's fluid differs (it is moving, not frozen)");
    }

    #[test]
    fn empty_map_is_honest_not_a_crash() {
        let mut m = SystemMap::new(Vec::new(), Vec::new(), Layout3D::Sphere);
        let j = m.state_json();
        assert_eq!(j["components"], 0);
        assert_eq!(j["pipes"], 0);
        m.tick(0.02); // no panic with zero pipes
        let r = harness::headless_render(&mut m);
        assert!(r.drew(), "the empty-map hint still paints");
    }

    #[test]
    fn camera_messages_reuse_the_graph3d_model_headlessly() {
        let mut m = sysmap();
        let base = m.state().yaw;
        let snap = harness::snapshot(&mut m, [SystemMapMsg::Camera(CamMsg::DragYaw(10.0))]);
        assert!((snap.yaw - (base + 10.0 * 0.01)).abs() < 1e-6, "drag orbits the reused camera");
        assert!(!snap.spin, "an orbit drag pauses the auto-spin");
        let snap = harness::snapshot(&mut m, [SystemMapMsg::Camera(CamMsg::SelectNode(2)), SystemMapMsg::Camera(CamMsg::SetLayout(Layout3D::Sphere))]);
        assert_eq!(snap.selected, Some(2), "the picked component survives relayout (stable index)");
        assert_eq!(snap.layout, Layout3D::Sphere);
    }

    // ═══════════════════════════════════════════════════════════════════════════════
    //  REAL LIQUID (opt-in) — tested as PROPERTIES, not as a picture.
    // ═══════════════════════════════════════════════════════════════════════════════

    fn wet() -> SystemMap {
        let (n, e) = constellation();
        SystemMap::new(n, e, Layout3D::Force).with_liquid(true)
    }

    /// The pane rect the geometry oracle works in (the harness's default headless size).
    fn pane() -> egui::Rect {
        egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))
    }

    /// Is `p` inside the convex quad `q` (which is a rotated rectangle), within `eps` px?
    /// Signed-area test against all four edges; a rectangle's winding is consistent, so
    /// "inside" is "same side of every edge".
    fn inside_quad(q: &PipeQuad, p: Pos2, eps: f32) -> bool {
        let mut neg = false;
        let mut pos = false;
        for k in 0..4 {
            let (a, b) = (q[k], q[(k + 1) % 4]);
            let cross = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
            // Normalise by the edge length so `eps` is in pixels, not area units.
            let len = (b - a).length().max(1e-6);
            let d = cross / len;
            if d < -eps {
                neg = true;
            }
            if d > eps {
                pos = true;
            }
        }
        !(neg && pos)
    }

    // ── the opt-in really is opt-in: OFF changes NOTHING ────────────────────────────
    #[test]
    fn liquid_is_off_by_default_and_leaves_the_dye_path_byte_identical() {
        let mut dry = sysmap();
        assert!(dry.liquid().is_none(), "default map has no liquid engine");
        assert_eq!(dry.tilt(), 0.0, "default map is never tipped");
        let j = dry.state_json();
        for k in ["liquid", "tilt", "gravity"] {
            assert!(j.get(k).is_none(), "dye-mode state_json must not grow a `{k}` key");
        }
        // Tilt messages are inert without liquid — the interaction surface is unchanged.
        let _ = dry.update(SystemMapMsg::Tilt(0.9));
        let _ = dry.update(SystemMapMsg::SetTilt(1.4));
        assert_eq!(dry.tilt(), 0.0, "Tilt is a no-op in dye mode");
        // And the dye still flows exactly as before.
        for _ in 0..20 {
            dry.tick(0.02);
        }
        assert!(dry.state_json()["fluid"]["total_dye"].as_f64().unwrap() > 0.0);
    }

    // ── ★ CONTAINMENT — "no fluid outside the pipes". The RED-when-broken one. ──────
    #[test]
    fn liquid_never_paints_outside_a_pipes_interior_through_a_full_tumble() {
        let mut m = wet();
        let rect = pane();
        let _ = m.update(SystemMapMsg::Camera(CamMsg::SetSpin(false)));
        // Charge it to the BRIM: with pipes at 97% and the tanks full there is almost no
        // headroom anywhere, so a solver that ever forgets the destination's free space
        // pushes liquid straight through a wall on the next frame.
        m.charge_liquid(0xB01D_B01D, 0.97);
        let mut checked = 0usize;
        // Tumble the map right round (tilt) while orbiting it (yaw) — every pipe passes
        // through every orientation, so every flow direction is exercised.
        for k in 0..360 {
            let a = k as f32 * std::f32::consts::TAU / 360.0;
            let _ = m.update(SystemMapMsg::SetTilt(a - std::f32::consts::PI));
            let _ = m.update(SystemMapMsg::Camera(CamMsg::SetYaw(a * 2.0)));
            m.tick_fluid(1.0 / 60.0);
            // (1) GEOMETRY: every painted liquid vertex lies inside that pipe's bore.
            for pi in 0..m.pipes() {
                let Some((bore, bands)) = m.bore_and_bands(rect, pi) else { continue };
                for (i, band) in bands.iter().enumerate() {
                    for v in band {
                        assert!(
                            inside_quad(&bore, *v, 1e-3),
                            "liquid escaped pipe {pi} cell {i} at frame {k}: vertex {v:?} is outside the bore {bore:?}"
                        );
                        checked += 1;
                    }
                }
            }
            // (2) MODEL: the same fact upstream — no cell holds more than its tube's
            // cross-section, no cell holds negative liquid, no tank overflows.
            let l = m.liquid().unwrap();
            assert!(l.max_fill() <= 1.0 + 1e-6, "cell over-fills its tube at frame {k} (max_fill={})", l.max_fill());
            assert!(l.min_fill() >= -1e-6, "negative liquid at frame {k} (min_fill={})", l.min_fill());
            for n in 0..7 {
                assert!(l.tank_level(n) <= 1.0 + 1e-6, "tank {n} overflows at frame {k}");
            }
        }
        assert!(checked > 100_000, "the oracle actually looked at liquid ({checked} vertices)");
    }

    /// **Containment for the TANKS** — the junctions hold liquid too, so the same claim
    /// has to hold there: every vertex of a tank's liquid lies inside that tank's glass
    /// sphere, and the surface line is never a degenerate stroke.
    ///
    /// That last clause is not paranoia. A **full** tank's liquid/air chord collapses to
    /// zero length, and a zero-length `line_segment` has no well-defined normal — egui
    /// expanded it into a bright streak running clear across the pane, far outside the
    /// tank. It was found by looking at a render; this is the assertion that means
    /// nobody has to look again.
    #[test]
    fn tank_liquid_stays_inside_its_glass_sphere_and_never_strokes_a_degenerate_surface() {
        let mut m = wet();
        let rect = pane();
        let _ = m.update(SystemMapMsg::Camera(CamMsg::SetSpin(false)));
        m.charge_liquid(0x7A_4E_00, 0.9);
        let mut saw_full = false;
        let mut checked = 0usize;
        for k in 0..240 {
            let a = k as f32 * std::f32::consts::TAU / 240.0;
            let _ = m.update(SystemMapMsg::SetTilt(a - std::f32::consts::PI));
            m.tick_fluid(1.0 / 60.0);
            for (i, (c, r, poly)) in m.tank_geometry(rect).unwrap().into_iter().enumerate() {
                for v in &poly {
                    assert!(
                        (*v - c).length() <= r + 1e-2,
                        "tank {i} liquid escaped its sphere at frame {k}: {v:?} is {:.3} from the centre, r={r:.3}",
                        (*v - c).length()
                    );
                    checked += 1;
                }
                // NO DEGENERATE EDGE. egui derives a filled path's vertex normals from
                // its edges; a zero-length edge yields a NaN normal and the tessellator
                // throws a bright streak clear across the pane — which is precisely the
                // bug this arm was written for.
                for w in poly.windows(2) {
                    assert!(
                        (w[1] - w[0]).length() > 1e-3,
                        "tank {i} liquid polygon has a zero-length edge at frame {k} ({:?} == {:?}) — egui will NaN a normal and streak",
                        w[0],
                        w[1]
                    );
                }
                if let Some(s) = tank_surface(&poly) {
                    assert!((s[1] - s[0]).length() > 0.5, "a stroked tank surface always has real length");
                    // A real chord is inside the sphere at both ends.
                    for e in s {
                        assert!((e - c).length() <= r + 1e-2, "tank {i} surface end inside the sphere");
                    }
                }
                saw_full |= m.liquid().unwrap().tank_level(i) >= 0.999;
            }
        }
        assert!(checked > 10_000, "the oracle actually looked at tank liquid ({checked} vertices)");
        assert!(saw_full, "the tumble drove at least one tank to FULL — the degenerate-chord case");
    }

    /// The pane-level half of containment: nothing the liquid paints escapes the pane
    /// rect either (the shared `ink_outside_rect` scissor oracle, on the real bands).
    #[test]
    fn liquid_ink_never_escapes_the_pane_rect() {
        let mut m = wet();
        let rect = pane();
        for k in 0..90 {
            let _ = m.update(SystemMapMsg::SetTilt(k as f32 * 0.07));
            m.tick_fluid(1.0 / 60.0);
        }
        let mut tris: Vec<[Pos2; 3]> = Vec::new();
        for pi in 0..m.pipes() {
            let Some((_, bands)) = m.bore_and_bands(rect, pi) else { continue };
            for q in bands {
                tris.push([q[0], q[1], q[2]]);
                tris.push([q[0], q[2], q[3]]);
            }
        }
        assert!(!tris.is_empty(), "there is liquid geometry to scissor");
        assert_eq!(
            facett_core::render::cpu::ink_outside_rect(&tris, rect),
            0,
            "no liquid ink lands outside the pane rect"
        );
    }

    // ── CONSERVATION — the sealed system neither gains nor loses liquid ─────────────
    #[test]
    fn liquid_volume_is_conserved_while_the_map_tumbles() {
        let mut m = wet();
        let v0 = m.liquid().unwrap().total_volume();
        assert!(v0 > 1.0, "the seeded charge is real liquid (v0={v0:.3})");
        for k in 0..900 {
            let _ = m.update(SystemMapMsg::SetTilt((k as f32 * 0.013).sin() * 2.0));
            let _ = m.update(SystemMapMsg::Camera(CamMsg::DragYaw(3.0)));
            m.tick_fluid(1.0 / 60.0);
            let v = m.liquid().unwrap().total_volume();
            assert!(
                (v - v0).abs() / v0 < 1e-3,
                "volume conserved at frame {k}: {v:.5} vs {v0:.5} (drift {:.3e})",
                (v - v0).abs() / v0
            );
        }
    }

    // ── GRAVITY — the flow re-aims as the camera moves ─────────────────────────────
    #[test]
    fn flow_direction_agrees_with_gravity_projected_on_each_pipe() {
        let mut m = wet();
        let _ = m.update(SystemMapMsg::Camera(CamMsg::SetSpin(false)));
        for tilt in [0.4f32, 1.1, -0.9, 2.4] {
            let _ = m.update(SystemMapMsg::SetTilt(tilt));
            // Let the velocity integrator reach the sign of the forcing.
            for _ in 0..30 {
                m.tick_fluid(1.0 / 60.0);
            }
            let l = m.liquid().unwrap();
            let mut agreed = 0usize;
            let mut looked = 0usize;
            for p in 0..m.pipes() {
                let a = l.axial_gravity(p);
                if a.abs() < 0.5 {
                    continue; // a nearly level pipe has no opinion
                }
                looked += 1;
                if l.velocity(p).signum() == a.signum() {
                    agreed += 1;
                }
            }
            assert!(looked > 0, "some pipe is sloped at tilt {tilt}");
            assert_eq!(agreed, looked, "every sloped pipe flows the way gravity points (tilt {tilt})");
        }
    }

    /// Tipping the map moves the liquid: the same camera, two tilts, different fields.
    /// RED if `Tilt` stops reaching the solver or gravity stops being re-aimed.
    #[test]
    fn tipping_the_map_actually_pours_the_liquid() {
        let settle = |tilt: f32| {
            let mut m = wet();
            let _ = m.update(SystemMapMsg::Camera(CamMsg::SetSpin(false)));
            let _ = m.update(SystemMapMsg::SetTilt(tilt));
            for _ in 0..600 {
                m.tick_fluid(1.0 / 60.0);
            }
            let l = m.liquid().unwrap();
            ((0..m.pipes()).map(|p| l.fill(p).to_vec()).collect::<Vec<_>>(), l.fall_moment())
        };
        let (up, m_up) = settle(0.0);
        let (over, m_over) = settle(2.2);
        assert_ne!(up, over, "tipping the map redistributes the liquid");
        assert!(m_up.is_finite() && m_over.is_finite());
        // Held at one tilt long enough, the liquid ends up LOWER than it started.
        let mut m = wet();
        let _ = m.update(SystemMapMsg::Camera(CamMsg::SetSpin(false)));
        let _ = m.update(SystemMapMsg::SetTilt(1.3));
        let before = m.liquid().unwrap().fall_moment();
        for _ in 0..900 {
            m.tick_fluid(1.0 / 60.0);
        }
        let after = m.liquid().unwrap().fall_moment();
        assert!(after > before, "the liquid descended (fall moment {before:.3} → {after:.3})");
    }

    /// Same seed + same host-supplied `dt` ⇒ the same field. No wall clock is read on
    /// the liquid path, so a robot run and a golden image are reproducible.
    #[test]
    fn liquid_run_is_deterministic() {
        let run = || {
            let mut m = wet();
            let _ = m.update(SystemMapMsg::Camera(CamMsg::SetSpin(false)));
            for k in 0..300 {
                let _ = m.update(SystemMapMsg::SetTilt((k as f32 * 0.02).sin()));
                m.tick_fluid(1.0 / 60.0);
            }
            m.state_json()["liquid"].clone()
        };
        assert_eq!(run(), run(), "the liquid run is reproducible frame-for-frame");
    }

    #[test]
    fn liquid_map_renders_and_publishes_its_invariants() {
        let mut m = wet();
        let _ = m.update(SystemMapMsg::SetTilt(0.8));
        for _ in 0..120 {
            m.tick_fluid(1.0 / 60.0);
        }
        let r = harness::headless_render(&mut m);
        assert!(r.drew(), "the liquid map tessellated ({} verts)", r.vertices);
        let j = &r.state["liquid"];
        assert_eq!(j["pipes"], 8);
        assert_eq!(j["nodes"], 7);
        assert!(j["total_volume"].as_f64().unwrap() > 0.0, "there is liquid");
        assert!(j["max_fill"].as_f64().unwrap() <= 1.0, "containment is published as data");
        assert!((r.state["tilt"].as_f64().unwrap() - 0.8).abs() < 1e-6);
        assert_eq!(r.state["gravity"].as_array().unwrap().len(), 3);
    }

    /// At tilt 0 gravity is straight down world-Y for **every** yaw (spinning a thing
    /// about the vertical axis does not pour it); tipping it is what re-aims gravity.
    #[test]
    fn gravity_in_the_model_frame_is_the_camera_inverted() {
        for yaw in [0.0f32, 0.6, 2.5, -1.9] {
            let g = gravity_in_model_frame(yaw, 0.0);
            assert!(g[0].abs() < 1e-6 && (g[1] + 1.0).abs() < 1e-6 && g[2].abs() < 1e-6, "yaw alone never tips gravity (yaw={yaw})");
        }
        // Tipped a quarter turn, gravity lies in the horizontal plane and swings with yaw.
        let q = std::f32::consts::FRAC_PI_2;
        let a = gravity_in_model_frame(0.0, q);
        let b = gravity_in_model_frame(q, q);
        assert!(a[1].abs() < 1e-6 && b[1].abs() < 1e-6, "tipped 90°, gravity is horizontal in model space");
        assert!((a[0] - 1.0).abs() < 1e-6 && (b[2] - 1.0).abs() < 1e-6, "orbiting swings which axis is downhill");
        // It is always a unit vector.
        for (y, t) in [(0.3f32, 0.7f32), (-2.0, 1.4), (1.1, -0.3)] {
            let g = gravity_in_model_frame(y, t);
            assert!((g[0] * g[0] + g[1] * g[1] + g[2] * g[2] - 1.0).abs() < 1e-5);
        }
    }

    /// A relayout moves the pipes, so the liquid must re-derive which ones are downhill.
    #[test]
    fn relayout_re_aims_the_liquid_geometry() {
        let mut m = wet();
        let _ = m.update(SystemMapMsg::SetTilt(1.0));
        let before: Vec<f32> = (0..m.pipes()).map(|p| m.liquid().unwrap().axial_gravity(p)).collect();
        let _ = m.update(SystemMapMsg::Camera(CamMsg::SetLayout(Layout3D::Helix)));
        let after: Vec<f32> = (0..m.pipes()).map(|p| m.liquid().unwrap().axial_gravity(p)).collect();
        assert_ne!(before, after, "relayout re-derives every pipe's axial gravity");
        // Volume survives the relayout (it moves the tubes, not the liquid).
        assert!(m.liquid().unwrap().total_volume() > 1.0);
    }

    #[test]
    fn headless_render_draws_pipes_and_reads_fluid_state() {
        let mut m = sysmap();
        for _ in 0..15 {
            m.tick(0.02);
        }
        let r = harness::headless_render(&mut m);
        assert!(r.drew(), "the Elm bridge tessellated the glass pipes + tanks ({} verts)", r.vertices);
        assert_eq!(r.state["components"], 7);
        assert!(r.state["fluid"]["total_dye"].as_f64().unwrap() > 0.0);
    }
}