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