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

nordisk/facett

.nornir/systemmap-fluid-reasoning.md

System Map — real liquid in the glass pipes — reasoning

Class: reasoning. The why, the survey, and every approach considered and rejected. The shape actually built is in systemmap-fluid-design.md, which by rule names only the chosen approach. If you want to know "why not SPH?" — it is here.

f0 — the ask, and what it actually demands

"system map with glass pipes is super cool, can you have real liquid pouring in it? You found that rust library fluid-sim? So when rotating it, the fluid tries to get downwards, if supported, but not pouring outside the pipes of course. Well, cool to later."

Three requirements hide in there, and only one of them is "a fluid solver":

  1. falls — the flow direction must be derived from gravity, not authored;
  2. re-aims as the view rotates — gravity is fixed in the room, the map moves;
  3. never leaves the pipes — a hard containment guarantee, named explicitly.

Requirement 3 is the one that decides the architecture. It is not a rendering preference; it is a statement that the boundary is inviolable. That points hard away from any method where the boundary is enforced by collision response and towards one where the boundary is the coordinate system.

And "cool to later" sets the deliverable: a working prototype behind an opt-in flag plus an honest write-up — not a rewrite of a pane that already works.

f1 — the Rust library survey (what actually exists)

Rickard remembered "a rust library fluid-sim". There is no fluid-sim crate on crates.io. The name most likely came from one of the GitHub repos below — all of them personal learning projects, none a library. What genuinely exists, checked July 2026:

Crate / repo Latest Licence Maintenance Method Verdict for this pane
salva3d / salva2d 0.9.0, Feb 2024 Apache-2.0 repo alive (push Jun 2026), release 2½ yr stale, 687★ SPH + PBF (position-based fluids), optional two-way rigid coupling The only real library here. Rejected — see f2. Also: its parallel feature is rayon, which ROOT LAW #0 forbids, so it would have to run single-threaded anyway.
spherro 0.2.1, Jul 2019 MIT abandoned (7 yr), 4.5k downloads SPH, wasm demo Rejected: unmaintained, 2018-edition, a demo not a library.
stroemung 0.1.2, Mar 2025 MIT one author, low activity 2-D Eulerian CFD on a regular grid Rejected: a grid, not a graph of tubes. Wrong topology (f3).
flow2d_rs 0.1.0, Nov 2023 MIT ~940 LOC, single release 2-D CFD Rejected: same as above, and smaller than what we need to write ourselves.
bevy_eulerian_fluid 0.4.0, Apr 2026 MIT/Apache-2.0 actively released Eulerian grid on wgpu compute Rejected: a Bevy plugin — it is an ECS Plugin, ~2 400 lines of WGSL, and would drag Bevy into an egui pane.
water-simulation 0.1.2, Jun 2023 MIT 201 LOC, one file 2-D toy Rejected: a CLI toy.
epanet-sys / epanet 2.3.0, Mar 2026 MIT actively released The EPA's steady-state pipe-network hydraulic solver, via C FFI The closest domain match and still wrong: it solves steady-state pressurised full-pipe demand-driven flow. No free surface, no re-orientable gravity, no per-frame animation, and a C dependency that is not wasm-clean.
Wumpf/blub — — research WIP, no crate GPU PIC/FLIP/APIC on wgpu, voxelised hull boundaries Not a library. Also the exact heavyweight shape f2 rejects.
vsinha/fluid_sim, gabyx/RsFluid, levmckinney/rust-fluid-sim, AkinAguda/fluid-simulation-rust — mixed/none learning projects; fluid_sim last pushed 2019, 1★ Stam-style Navier–Stokes on a grid Not libraries. The likely source of the "fluid-sim" memory.
rustyflow 0.1.1, Jul 2025 MIT — — Not a fluid crate at all — an AI-agent workflow framework. Name collision; noted so nobody else chases it.

Conclusion of the survey: nothing here is usable. The one credible library (salva3d) is the wrong method for this problem (f2), and everything else is either a learning project, a grid solver, a Bevy plugin, or a steady-state hydraulics package. A small purpose-built solver is the honest answer, and it is smaller than the glue that adopting salva3d would need.

f2 — rejected: a real 3-D free-surface solver (SPH / PBF / PIC-FLIP)

The heavyweight option, and the one the phrase "fluid-sim library" implies: particles with real 3-D dynamics, splashing inside pipe geometry.

Rejected on containment first, cost second.

The honest summary: a 3-D free-surface solver is the right tool for a container of unknown shape. A pipe network is a container of exactly known shape. Using SPH here is solving a harder problem than the one we have, worse.

f3 — rejected: an Eulerian grid (Stam / shallow-water)

A 2-D or 3-D velocity+density grid over the pane, with the pipes as obstacles.

Rejected because the topology is wrong. A pipe network is one-dimensional almost everywhere: it is a graph, and the interesting quantity is how much liquid is at distance s along tube p. A grid discretises the enormous empty space between the pipes at the same resolution as the pipes themselves, then needs a mask to forbid ~95 % of its own cells. Cost scales with the pane area; containment becomes "the mask was right". Shallow-water has the additional problem that it assumes a single height field over a plane — a pipe that runs upward has no representation in it at all.

f4 — rejected: keeping the existing dye advection and just re-signing the velocity

The cheapest possible answer: keep facett_cfd::FluidField and set each pipe's velocity sign from gravity. Two lines.

Rejected because dye is not liquid. FluidField advects a concentration with diffusion, decay and a pulsing source. It has

It is a good flow indicator and it stays exactly as it is (that is why the new engine is a sibling module and the flag defaults off). It is not the thing Rickard asked for.

f5 — rejected: putting the physics in world space and letting yaw do the pouring

The first instinct is "rotate the camera, the fluid sloshes". It does not, and it should not: Graph3D's camera is a yaw about the up axis, and rotating a thing about the gravity vector cannot move liquid. Wiring yaw straight into the flow would have looked lively and been physically wrong — a lie of exactly the kind the pane is otherwise careful not to tell.

So the map got a tilt (a roll about the view axis, a vertical drag) as the pouring gesture, and gravity is expressed in the map's own frame as g = (sin t·cos ψ, −cos t, sin t·sin ψ). At t = 0 that is straight down for every yaw — honest. Once tipped, orbiting swings which pipes are downhill — which is the effect Rickard was picturing, now for a real reason.

The rejected alternative was adding a pitch to Graph3D itself. Rejected for this prototype because Graph3D is shared by four panes and a camera change would touch all of them; a screen-space roll is exactly equivalent under an orthographic projection and is contained entirely inside SystemMap.

f6 — rejected: fanning the solver across cores

ROOT LAW #0 says CPU fan-out goes through znippy gatling, never rayon. It does not say we must fan out — and the bench says loudly that we must not.

gatling_run is std::thread::scope + one spawn per core per call. That is the right shape for a batch job and the wrong shape for a 60 Hz tick: on oden (32 cores) it carries a roughly fixed 0.5–1.3 ms of thread churn no matter how little work there is, against 1.8 µs for the whole serial solve at the shipped size. It only starts winning past ~250 000 cells, by which point either lane is already eating a fifth of the frame (design f6 has the table).

So the shipped step is serial by measurement, with the bit-identical step_gatling lane kept behind PARALLEL_MIN_CELLS for a hypothetical enormous network. The threshold sits deliberately above the measured crossover, because the serial cost is predictable while the gatling cost swings by an order of magnitude with machine load — at load average 111 the same arm that reads 1.5 ms reads 13.5 ms. Choosing the lane that degrades gracefully is worth giving up a 2× win at a size nothing will ever run.

This is not a violation of the law; it is the law applied after measuring, which is what the law is for. The law says CPU fan-out goes through gatling and never rayon. It does not say fan out when the dispatch costs more than the work.

The same finding applies to the existing dye path. FluidField::step calls gatling_run unconditionally, every frame, for 8 pipes — so the shipped System Map is currently paying that thread-spawn storm on the UI thread for work that takes single-digit microseconds. It is deliberately left alone here (this task is additive to the dye flow, and the pane still holds 60 fps), but it is the highest-value one-line follow-up in the area, and it is provably output-identical because facett-cfd already asserts step == step_serial bit-for-bit.

f7 — rejected test strategies

f8 — a note on how the render bugs were found

Two defects in this work were invisible to the property tests and obvious in a PNG:

  1. The tilt swung half the map off-screen. Rolling about the pane centre is wrong for a layout that is not centred on the pane; rolling about the cloud's own centroid is what tipping an object means.
  2. A bright streak clear across the pane. egui derives a filled path's vertex normals from its edges. A nearly-empty (or exactly full) tank collapsed its liquid polygon onto a single point, every edge went to zero length, the normals went NaN, and the tessellator drew liquid thousands of pixels outside its tank — the one thing the whole design promises cannot happen. Containment held perfectly in the model; it was the render that leaked.

That is the argument for keeping a picture in the loop even when the proof is a property: the property tests answer "is the solver right?", and these were both "is the picture right?". Both are now assertions — the second as a "no zero-length edge in any polygon handed to the tessellator" check — so they are found by CI next time rather than by squinting.