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 insystemmap-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":
- falls — the flow direction must be derived from gravity, not authored;
- re-aims as the view rotates — gravity is fixed in the room, the map moves;
- 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.
- Containment becomes a tolerance instead of an invariant. In SPH/PBF the wall is a constraint solved iteratively. Push the solver hard — nearly full pipes, a fast tumble, a thin tube — and particles tunnel. The correct statement then becomes "no particle was observed outside the pipe in this test run", which is not what Rickard asked for. In the shipped approach the containment claim is
f ≤ 1and it is an algebraic consequence of how the flux is limited (design f3), provable in one line and checked at the geometry level every frame. - We would be paying a solver to rediscover a boundary we already know exactly. The map's geometry is a graph of straight tubes with known endpoints and radii. A 3-D solver spends most of its budget on neighbour search and boundary handling — i.e. on re-deriving, approximately, information we have exactly and for free.
- The pipes are thin. To make a splash read inside a tube ~10 px wide you need several particles across the bore; at 8 pipes × a useful axial resolution that is ~10⁴ particles, with neighbour search, on a Radeon 890M iGPU shared with the rest of the deck. The 1-D solver does the same visual job in 384 floats.
salva3d's parallel lane is rayon, forbidden by ROOT LAW #0. We would run it single-threaded, which removes its main performance argument.- It cannot be made deterministic cheaply. Robot-testability and golden images need bit-reproducibility; iterative constraint solvers with neighbour lists are the hardest thing to keep bit-stable across thread counts.
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
- no volume — dye is created at an inlet and decays away, so nothing is conserved and "the tank filled up because the pipe above it drained" cannot happen;
- no capacity — concentration has no upper bound tied to the tube's cross-section, so there is nothing for "outside the pipe" to mean;
- no free surface — it renders as a stripe down the middle of the tube, which reads as a glowing wire, not as liquid lying in a trough.
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
- A golden PNG as the containment proof. Rejected as the proof: a screenshot confirms one camera at one instant, and the failure mode ("liquid escapes when the map is nearly full and tumbling fast") is exactly the one a fixed golden never visits. The shipped proof tumbles the map through 360 orientations at 97 % charge and checks every painted vertex against its own tube. A golden is still worth having as a look check; it is not the assertion.
- Asserting containment only on the model (
max_fill ≤ 1). Rejected as insufficient: it proves the solver, not the picture. A painter bug — drawing the band from the wrong wall, or scaling by the outer radius instead of the bore — would leave the model spotless and the pixels wrong. The oracle therefore runs on the painted geometry, from the same kernel the painter calls. - Testing the drag coefficient. Considered and dropped: with
DRAG = 0every property still holds (the velocity saturates, the Courant cap still limits the transfer, a packed pipe still stops), so any test "covering" drag would be asserting an arbitrary number rather than a property. Design f7 says so out loud instead. - Clamping
finside the painter "just in case". Rejected, and actively removed: a defensive clamp inliquid_bandwould make a broken solver produce a correct-looking picture, which is precisely the bug the test exists to catch. Verified by mutation — with the clamp in place, deleting the solver's capacity limit left the containment test GREEN; with it removed, the test fails naming the pipe, the cell and the frame. - Charging the containment test to the same level the demo ships. Rejected once measured: at the demo's 42 % fill there is so much headroom that deleting the solver's capacity limit does not over-fill anything, and the test passes a broken solver. It now charges to 97 % and asserts every frame, which is what turned that mutation red.
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:
- 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.
- 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.