OTOM CFD is a high-performance computational fluid dynamics solver running entirely in the browser via WebGPU. It is heavily inspired by the methodologies popularized by the WaterLily.jl Julia package, specifically bringing Cartesian-grid, boundary-data immersion methods to massive parallel compute shaders.
The software supports both 2D and 3D incompressible Navier-Stokes simulations, a dedicated 2D compressible Euler engine (HLLC Riemann solver) for shock-bearing high-speed flows, multi-phase flows, and high-fidelity volumetric raymarching—all orchestrated through an interactive GUI and an integrated LLM-powered AI assistant.
The simulation is driven by a uniform buffer passed to the GPU every frame. Parameters are split between physical and visual properties.
| Parameter | Domain | Description / Physical Meaning |
|---|---|---|
| Engine Solver | 2D | Incompressible (projection Navier-Stokes, low speed) or Compressible (Euler/HLLC, shocks & high speed). See §4. |
| Grid Resolution | 2D / 3D | Number of computational cells (e.g., 910x512 in 2D, 64³ to 256³ in 3D). Dictates spatial frequency limits. |
| Flow Mode | 2D / 3D | Velocity enforces a constant inlet speed. Pressure drives flow via boundary pressure gradients (\(\Delta P\)). |
| Flow Speed (\(U_\infty\)) | 2D / 3D | Inlet velocity in grid cells per second (or m/s if SI units enabled). |
| Lattice dt (\(\Delta t\)) | 2D / 3D | Time advanced per sub-step. Governs the CFL condition (\(CFL = U \frac{\Delta t}{\Delta x}\)). |
| Physics Steps | 2D / 3D | Number of compute passes executed per visual render frame. Increases stability for high-speed flows. |
| Jacobi Itr | 2D / 3D | Iterations of the Poisson pressure solver. Higher values ensure stricter incompressibility (\(\nabla \cdot \mathbf{u} = 0\)). |
| Viscosity (\(\nu\)) | 2D / 3D | Kinematic viscosity. Defines fluid thickness. Air ~0.015, Water ~0.001. |
| Visual Parameter | Domain | Description |
|---|---|---|
| View Mode | 2D / 3D | Selects the Eulerian field to display: Vorticity, Velocity, Pressure, Phase, Schlieren (Pressure Gradient), Numerical Schlieren, or Dye Streams. |
| Dye Streams | 2D (Incompressible) | 2–16 separately coloured passive tracer streams, so you can follow where each one goes around and behind the body. Release: Inlet Streams (continuous lanes from every inflow edge, or the painted inlet in Control Volume mode), Initial Bands (the domain starts striped), or Both. Stream Thickness is the dyed fraction of each lane (1 = bands touch). Where streams mix, their colours average. Changing a setting or pressing Restart Dye re-seeds the dye without resetting the flow. Tip: turn off Liquid Mode for the cleanest view. |
| Dye Streams (3D) | 3D (Incompressible) | Same idea in 3D (Field → Dye Streams). Shape: Tubes (a rake of round coloured filaments across the −X inlet face, like a smoke rake in a wind tunnel) or Sheets (horizontal coloured layers). Rendered by the normal volume / isosurface / slice views; Threshold (~0.1–0.3) sets how faint dye still shows. A closed domain (no inlet flow) starts filled instead. Same Release / Thickness / Restart Dye controls as 2D. |
| Gain / VScale | 2D / 3D | Multiplicative factor applied before colormapping. Adjusts the sensitivity of the visualizer. |
| Theme | 2D / 3D | Colormap applied to the main viewport field: Seismic, Smoky Seismic, Jet, Hot, Viridis, Turbo, Magma, Inferno, Plasma. See below. |
| Isoline Density | 2D / 3D | Frequency of contour lines superimposed on the field to visualize gradients. |
| Arrow Density | 2D / 3D | Spacing of the vector field arrows. In 3D, this samples a specific 2D slice defined by Slice Depth. |
Jet and Hot are visually similar names but genuinely different palettes: Jet is the classic MATLAB-style rainbow spectrum — dark blue → blue → cyan → green → yellow → red → dark red, spanning the field's full normalized range — while Hot is a black/red/yellow ramp driven by the signed field value rather than a spatial position. Both live in the same shared colormap() WGSL function, so any 2D or 3D view (main field, Force Display overlay, particle color-by-field) can use either. Force Theme (Aerodynamics tab, 2D and 3D) is a second, independent colormap selector for the pressure/force overlay — it can be set differently from the main Theme, e.g. Seismic for the flow field with Jet reserved for the Force Display, so the two visual channels never fight for the same color language.
Colormap Min/Max defines the window mapped onto the colormap's two ends — field values at Min land on the cold end, values at Max on the hot end. The historical fixed mapping is just the default window (\(-1\), \(1\)); many fields don't naturally sit in that range (an incompressible pressure field, for instance, floats at an arbitrary additive gauge offset with no fixed natural range at all), so a mismatched window can make a field with real structure look like a single flat colour.
Auto Range removes the guesswork: while enabled, the app periodically measures the field actually on screen (its 1st/99th percentile, robust against a single outlier cell) and fits Colormap Min/Max — and, in 3D, the render Threshold — to it automatically. The read-only Measured line shows the fitted percentiles alongside the field's true min/max/mean — useful on its own for telling a field that "looks flat" (genuinely tiny values) apart from one that's merely mis-scaled (a large mean with real but small variation riding on top of it, the exact signature of the pressure gauge-offset case above).
Colour Scale (Linear or Log) is a separate, complementary control: it doesn't change the window, it redistributes contrast within it. Log applies a signed, sign-preserving logarithmic curve after the Min/Max window is applied, so weak structure near the middle of the range becomes visible without clipping the strong values at the ends — the window's two ends still map to exactly \(\pm 1\) either way. Fit the window with Auto Range first, then switch to Log if fine detail is still hard to see.
Both 2D and 3D pressure sampling honour this pipeline identically, and in 3D compressible mode the window/scale apply to the same decoded velocity/pressure textures the shared volume renderer already uses — Auto Range and Log work in the Compressible engine exactly as they do in Incompressible.
By default, every number in the sidebar is a lattice unit — self-consistent relative to the grid, not inherently meters or seconds. Flipping SI Units Mode (2D: "Physical Calibration" folder; 3D: its own "Physical Calibration" folder, right below Simulation — the two are independent, so turning it on in one tab never silently recalibrates the other) reinterprets Flow speed/Viscosity as real physical quantities (m/s, m²/s) and converts them into the lattice units the solver actually runs on:
where Domain Width (m) and Real-world Time Step (s) are the two calibration inputs you provide, and Lattice dt is fixed to 1.0 internally once SI mode is on (the real timestep is already folded into the conversion above). In 3D, Domain Width (m) is calibrated for Length (X) = 1.0 (the domain's default ±1 world extent) and automatically rescales if you change the Length (X) slider, so resizing the non-cubic control volume doesn't silently invalidate your calibration. Height (Y)/Depth (Z) can still differ from Length(X) (the domain doesn't have to be a cube) — each axis then has a physically different cell size, which the solver's diffusion/gradient stencils don't individually correct for (they operate uniformly in normalized grid-cell space); this only matters if your domain is significantly non-cubic and you need diffusion to be quantitatively exact along the stretched axis, not just qualitatively correct.
Sanity-checking your inputs: both the 2D and 3D Physical Calibration folders show three read-only numbers — Cell Size (mm) (the physical size of one grid cell), Effective CFL (the Courant number of the actual lattice velocity that reaches the GPU, in either units mode — named "Effective" rather than "CFL Number" to avoid confusion with the Compressible engine's own CFL Number parameter, which is a different thing: a user-set target for the acoustic timestep, not a live diagnostic), and Est. Reynolds (computed from Flow speed, Viscosity, and a characteristic length derived from Domain Width — named "Est." rather than "Reynolds (Re)" to avoid confusion with the Aerodynamics folder's own Reynolds display, which only updates while "Live Analysis" is on; this one is always live). If Effective CFL climbs too high, a warning banner appears above the canvas — lower Flow speed, raise Domain Width (m), or shrink Real-world Time Step. A CFL warning means the fluid is moving more than about one grid cell per step, which produces visibly wrong (oscillating, exploding, or NaN) results — this is the first thing to check before trusting any result once SI units are on.
Turning SI Units Mode on auto-corrects Viscosity for the selected Fluid Material (unless it's set to "Custom"): the lattice-tuned viscosity values (e.g. Air's default 0.015) were chosen to look right as abstract lattice numbers, not as real physical properties — reinterpreting that same number as literal m²/s the instant SI mode turns on would imply an object roughly 1,000× more viscous than real air. To prevent a jarring change in flow behaviour, the app substitutes the real 20°C kinematic viscosity for the current material (Air ≈ 1.48×10⁻⁵ m²/s, Water ≈ 1.0×10⁻⁶, Engine Oil ≈ 1.2×10⁻⁴, Glycerin ≈ 1.18×10⁻³ m²/s) and widens the Viscosity slider's range/precision to represent these much smaller numbers usefully. Toggling SI Units Mode back off restores the original lattice-tuned value. Switching Fluid Material while SI is on picks from the real-value table instead of the lattice one.
Why the flow can still look "strange" right after enabling SI units even with this correction: real physical parameters at a coarse simulation grid very easily imply a Reynolds number in the hundreds of thousands or millions — e.g. real air, a modest few-m/s breeze, and a few-meter domain is already Re in the millions, which no 64³–256³ grid can resolve without looking chaotic and rapidly-changing, LES turbulence modelling or not. This is not a bug: it's the same "under-resolved turbulence" limitation every CFD tool has when asked to simulate a too-high-Re flow on too-coarse a grid. The Est. Reynolds readout crossing roughly 5,000 while SI Units Mode is on triggers a "HIGH REYNOLDS" banner explaining this and suggesting concrete fixes — raise Viscosity (a physically thicker fluid, or accept a coarser numerical/eddy viscosity), lower Flow speed, or shrink the domain (Domain Width and Real-world Time Step alone won't fix this if changed proportionally to each other, since that leaves the implied physical scenario — and therefore the Reynolds number — unchanged).
The Ground Truth tab (§10) remains the recommended way to validate results once your CFL is healthy and Reynolds number is in a resolvable range: it compares measured Cd/Cl against textbook correlations for canonical shapes at the current Reynolds regime.
The core fluid solver uses a staggered Cartesian grid with a fractional-step (Projection) method.
1. Advection: The velocity field is self-advected using a semi-Lagrangian back-tracing scheme. To reduce numerical dissipation, we utilize high-order filtering.
2. Divergence: The divergence of the intermediate velocity field (\(\mathbf{u}^*\)) is calculated.
3. Pressure Poisson: A Jacobi iterative solver resolves the pressure field needed to counteract the divergence.
4. Gradient Subtraction: The velocity is corrected: \(\mathbf{u}^{n+1} = \mathbf{u}^* - \Delta t \nabla p\).
Instead of conforming a mesh to an obstacle, OTOM CFD uses Brinkman Penalization. Solid objects are defined by exact analytical Signed Distance Fields (SDFs). The velocity inside the SDF (\(d \le 0\)) is forced to match the object's velocity, allowing for arbitrary, moving, and overlapping geometries at near-zero computational cost.
Alongside the default incompressible projection solver, OTOM CFD provides a dedicated compressible engine (set Engine Solver = Compressible) for high-speed, transonic, and supersonic flows where density variation and shock waves are physically significant. Instead of enforcing incompressibility, it solves the 2D compressible Euler equations in conservative finite-volume form, so it naturally captures shocks, expansion fans, and contact discontinuities.
The conserved state stored in every cell is \(\mathbf{Q} = [\rho,\ \rho u,\ \rho v,\ E]^T\) (density, x/y momentum, total energy), evolved by the hyperbolic conservation law:
The system is closed by the ideal-gas equation of state, where \(\gamma\) is the ratio of specific heats and \(a\) the local speed of sound:
Each cell is advanced by the net flux across its four faces. The flux at every interface is obtained by solving a local Riemann problem with the HLLC (Harten–Lax–van Leer–Contact) approximate solver, which restores the central contact/shear wave that the simpler HLL solver smears out—essential for clean slip lines and wakes:
The full stencil—four face fluxes plus the conservative update—is fused into a single WebGPU compute pass for robustness and throughput. The scheme is first-order in space and time; a positivity guard floors density and internal energy each step to guarantee \(\rho>0\) and \(p>0\), preventing the NaN blow-up that otherwise occurs across strong shocks.
The explicit time step is bounded by the acoustic CFL condition \(\Delta t < \Delta x / (|u| + a)\). If a strong shock destabilizes the field, lower Lattice dt or raise Physics Steps. The engine exposes four parameters (active only in Compressible mode):
| Parameter | Range | Physical Meaning |
|---|---|---|
| Gamma (\(\gamma\)) | 1.1 – 2.0 | Ratio of specific heats (1.4 for diatomic air). |
| Inlet Mach | 0 – 5.0 | Free-stream Mach number \(M = u/a\). Values > 1 are supersonic. |
| Inlet Density | 0.1 – 5.0 | Free-stream density \(\rho_\infty\). |
| Inlet Pressure | 0.1 – 5.0 | Free-stream static pressure \(p_\infty\). |
Recommended view modes: Schlieren (density gradient \(|\nabla \rho|\), the classic shock-visualization technique), Velocity (rendered as Mach number), or Pressure. Lagrangian particles and streamlines are advected directly by the reconstructed velocity \(\mathbf{u} = (\rho u,\ \rho v)/\rho\), so they stream through and bend around shocks just like the field itself.
The Smagorinsky model calculates sub-grid scale (SGS) stresses. It derives an "eddy viscosity" \(\nu_t\) from the resolved strain rate tensor \(\bar{S}_{ij}\), dissipating energy at the smallest resolvable scales.
To counteract the inherent diffusion of the semi-Lagrangian advection, Vorticity Confinement applies a body force that pushes fluid towards local vorticity maxima, keeping vortices sharp and persistent.
Off by default in 3D. Confinement is an artificial force, not physics: it re-energises swirl the grid has smeared. Measured on the drone demo it doubled the kinetic energy of the flow. Use it for looks; leave it off when the numbers matter. When it is on it runs before the pressure projection, so it can no longer break mass conservation.
OTOM CFD supports multi-phase simulations (e.g., air and water interaction) by advecting a scalar phase field \(\phi \in [0,1]\).
Inlet (Green) and Outlet (Blue) cells inside the domain. In Pressure Mode, the solver treats these as internal pumps and sinks, allowing for the simulation of complex ducting and internal flows.The 3D solver extends the 2D logic into a texture_3d environment. Rendering is achieved via a single-pass Raymarching fragment shader.
The camera casts rays through the domain bounding box. The shader performs two primary operations per ray:
get_sdf3d function. If hit, it calculates surface normals and applies PBR lighting (Metallic/Roughness/Ambient).Every 3D obstacle — the primary analytical shape (Sphere/Box/Cylinder/Wing/Extrude 2D), the STL mesh, and each of up to 4 secondary "Extra Obstacles" — can spin continuously about all 3 axes, the 3D analogue of the 2D Torque slider. Spin X/Y/Z (deg/frame) is a rotation rate: each rendered frame, the obstacle's current absolute orientation angle (Rot X/Y/Z, directly editable — set it to pose a fixed starting tilt with spin left at zero, e.g. pitching a blade before it starts turning) advances by the rate on each axis independently (wrapped to \([-180°,180°]\)), then that angle is fed into the SDF evaluation. Setting a rate to 0 simply holds the current orientation. Spin About Local Axes (per obstacle) applies the three rates in the obstacle's own rotated frame instead of world axes, so a tilted shape keeps spinning about its own axis instead of the components mixing across world axes as it tilts.
Mechanically, the rotation is applied as the exact inverse of the STL transform's own rotation (stl_inv_rotate, \(R^{-1} = R_x(-r_x)R_y(-r_y)R_z(-r_z)\)) to the query point before the primitive's local-space SDF runs, and the resulting distance is otherwise unaffected — so spin composes correctly with the Wing's own Angle-of-Attack slider (AoA still tilts the chord inside the wing's own frame, after the outer spin has already oriented the whole obstacle in world space). Because get_sdf3d/get_sdf_extra are the single shared functions consumed by rendering, particle/streamline collision, and the aerodynamic force integral, a spinning obstacle is mechanically consistent everywhere at once — no separate plumbing was needed for each consumer.
Show Local Axes (a checkbox per obstacle — one for the primary obstacle, one for STL if selected, one for each Extra Obstacle) draws that obstacle's local X (red) / Y (green) / Z (blue) axis as a short line from its centre, rotated by the same spin, so the user can always see which way "local X" currently points as the shape spins.
Compressible engine caveat: spin for the primary obstacle and Extra Obstacles is only applied when Engine Solver is Incompressible. The Compressible (HLLC) solver's own wall boundary condition uses a separate, unrotated copy of the SDF for its compute kernels; rotating the shared render-path SDF without also rotating that copy would make the rendered obstacle visually disagree with what the solver treats as solid, so rotation is forced back to identity while Compressible is active. STL spin is unaffected by this caveat and works in both engines, since the STL transform was already fully mirrored into the compressible solver's SDF copy before this feature existed.
A spinning obstacle's solid cells carry the rigid-body velocity \(\boldsymbol{\omega} \times \mathbf{r}\) (about the obstacle's own centre) instead of the historical zero — a genuine moving no-slip wall, exactly like the physical surface of a real spinning object. This is what lets a rotating shape actually drag the surrounding fluid, shed vorticity, and pump flow, rather than presenting fluid with a stationary wall that merely changes orientation between frames. The same mechanism drives the swirl/downwash behind a spinning box, sphere, or cylinder, and the suction/pressure-side split across a genuinely pitched blade (see below).
Blade pitch matters for thrust. A symmetric shape spinning about an axis through its own centre — a Box, a Sphere, an unpitched flat plate — mostly produces swirl and a weak centrifugal low-pressure core along the spin axis, not a net thrust-generating pressure differential between its faces. Real thrust needs the blade angled relative to its own rotation plane, exactly like a real propeller's twist: the Wing shape's AoA provides this directly, or an STL mesh with real blade pitch built into its geometry. Measure it with Torque (Aerodynamics tab, below) alongside the existing Drag/Lift: Drag/Lift give the thrust a spinning, pitched blade produces; Torque gives what it cost to produce it (shaft power is Torque × angular speed) — together they're what let a user read a spinning blade's aerodynamic efficiency off the simulation.
Beyond the uniform Scale slider, the primary Box and Cylinder shapes have independent per-axis controls, shown only when that shape is selected: Box Size X/Y/Z (ratios, default 1.0 each, multiplying the historical fixed 0.25 half-extent per axis) let a Box be stretched into a slab or a beam rather than staying a cube; Cylinder Radius / Cylinder Height (ratios, default 1.0, multiplying the historical fixed \(r=0.2\), \(h=0.4\)) do the same for a Cylinder. Both compose with Scale, which still resizes the whole result uniformly afterward.
Extrude 2D takes its footprint from whatever is currently painted on the flat 2D canvas (Draw Mode) — it is not a parametric shape, and the 2D canvas is the actual UI for its outline. Its Extrusion Length slider controls the extruded half-length along Z (ratio, default 1.0 = the historical fixed 0.85 half-span), independent of Scale (which would otherwise also zoom the 2D footprint along with the length).
The "Extra Obstacles (3D)" folder lets a scene hold up to 4 secondary obstacles in addition to the primary one, each with an independent Shape, position, scale, spin, and local-axis toggle (added/removed via + Add Obstacle / Remove). The Shape choices are Sphere, Box, Cylinder, Wing, and STL.
STL as an Extra Obstacle is an instance, not a second upload. There is only one voxelized STL SDF texture in the whole scene — the mesh loaded via the primary "Obstacle" folder's Upload STL button. Choosing STL for an Extra Obstacle samples that same voxel data at this slot's own independent position, scale, and rotation, exactly the same technique the primary obstacle itself uses to place the mesh. This is useful for repeating one part at several locations (e.g. an array of identical pillars, or a duplicate of a car body at a different offset) without the cost of a second voxelization pass or a second GPU texture. If no STL file has been uploaded yet, an STL-typed Extra Obstacle simply renders as nothing until one is.
The previous fifth shape option, a built-in simplified car body (the standard automotive benchmark shape with a slanted rear window), has been replaced by STL instancing: any imported car or vehicle mesh can now be used, and repeated, instead.
The 3D domain is no longer constrained to a unit cube. Three independent half-extent sliders — Length (X), Height (Y), and Depth (Z) — let users reshape the control volume to a rectangular cuboid. Default value 1.0 corresponds to the original ±1 extent in each axis. All internal coordinate transformations (world-to-UV, raymarching box, particle seeding, streamline reset bounds) scale with these parameters via three uniform floats (domainX, domainY, domainZ) pushed to the GPU every frame.
| Slider | Range | Effect |
|---|---|---|
| Length (X) | 0.5 – 4.0 | Stretches / compresses the streamwise (flow) direction. |
| Height (Y) | 0.5 – 4.0 | Stretches / compresses the vertical extent. |
| Depth (Z) | 0.5 – 4.0 | Stretches / compresses the spanwise direction. |
Changing any dimension triggers a full simulation re-initialisation (initSim()) so that the velocity field and obstacle mask are consistent with the new geometry.
In View Mode = Velocity, the 3D volume renderer displays the signed perturbation from free-stream rather than raw speed. The formula is:
where \(G\) is the user's Gain (VScale) slider × 10. The undisturbed free-stream maps to zero (transparent), regions faster than free-stream (e.g. flow accelerating around the sides) appear red, and the stagnation / wake (slower) regions appear blue. A bilateral threshold on \(|v_{display}|\) means both acceleration and deceleration contribute to opacity. To suppress numerical checkerboard noise (a typical artifact of collocated-grid pressure solvers) the velocity is sampled at the corner between 8 neighbouring cells so the GPU's trilinear filter averages them to zero for any ±δ alternating pattern — at no extra texture read cost.
Each 3D substep runs in the standard fractional-step order: transport and body forces (advection, fans, buoyancy, rotating frame, inlet) → optional vorticity confinement → viscosity / LES → pressure projection. The projection comes last because it is the only stage that makes the velocity divergence-free, and its result is what the renderer, the particles, the force integrals and the next substep all read. (Earlier versions projected before confinement and viscosity, so every consumer saw a field those two stages had pushed off mass conservation again.)
Domain walls are impermeable. On this collocated grid the velocity on a face is the mean of the two cells either side, so the ghost value beyond a wall mirrors the normal velocity (\(u_{ghost} = -u_n\)) and the wall face carries no flow; tangential velocity keeps a zero-gradient ghost (free-slip walls). The +X face is a wall when Open Outlet is off, and a zero-gradient outflow with \(p = 0\) when it is on. Without the mirrored ghost a jet aimed at the floor drained through it, and a sealed box lost about 20% of its circulation through the floor every step.
Measured, before → after these fixes:
| Test | Before | After |
|---|---|---|
| Sealed box, fan blowing down: net flux through interior planes (must be 0) | −0.22 (20% of the mean speed) | ≈ 0.003 |
| Wind tunnel with sphere: mass flux at the outlet (inlet = 30) | 28.3 (−6%) | 30.0 |
| Sphere drag, Re 100 (Schiller–Naumann 1.09) | 0.84, of which pressure 0.03 | 1.11 (+2%) |
| Sphere drag, Re 300 (Schiller–Naumann 0.68) | — | 0.78 (+14%) |
| Actuator disk: far-wake speed vs momentum theory \(2v_i = 2\sqrt{T/2\rho A}\) | — | 10.24 vs 10.75 (−5%) |
The sphere cases used a 16-cell diameter on a 128³ grid (1% blockage). Accuracy drops on coarser grids and at high Reynolds numbers, where the boundary layer is far thinner than a cell and no wall model is present.
A spinning blade is a moving wall in the solver. That works while the blade tip moves less than about one cell per step. Faster, the blade jumps over cells each step and flings its wall speed into the air: on the drone demo the props reached 7 cells per step, produced almost no thrust, and caused most of the jitter around the rotors. The standard model at this resolution is the actuator disk: a Ventilation Fan Disk on each rotor adds the momentum, and the blades are only drawn.
Every obstacle (the primary and each Extra Obstacle) has a Solid in Simulation checkbox. Unticked, the body is visual only: still drawn and spinning, but not part of the flow, the force integrals or the particles' collisions. Measured on the drone demo (thrust from the four disks): the downwash under the rotors became 3the downwash under the rotors became 3–10× steadier (frame-to-frame spread ±0.08–0.14 instead of ±0.4–1.0)ndash;10the downwash under each rotor became about 10× steadier (frame-to-frame spread ±0.1 instead of ±1.0)times; steadier (frame-to-frame spread the downwash under each rotor became about 10× steadier (frame-to-frame spread ±0.1 instead of ±1.0)plusmn;0.08the downwash under each rotor became about 10× steadier (frame-to-frame spread ±0.1 instead of ±1.0)ndash;0.14 instead of the downwash under each rotor became about 10× steadier (frame-to-frame spread ±0.1 instead of ±1.0)plusmn;0.4the downwash under each rotor became about 10× steadier (frame-to-frame spread ±0.1 instead of ±1.0)ndash;1.0), the weakest rotor's jet went from slightly upward (wrong) to clearly downward, and the strongest jets got 35and the strongest jets got 35–50% strongerndash;50% stronger — at the same frame rate.
Keep bodies solid when the flow around them is the point (a wing, a car, a slowly turning stirrer): there the solid wall is the physics. Make them visual only when a fan disk already supplies their effect.
Both 2D and 3D modes support massless Lagrangian particles advected by the velocity field.
line-strip primitives, creating streaklines.At high flow speeds a particle's displacement in one frame can exceed the obstacle diameter, causing it to pass through solid geometry without detection (tunneling). The 3D advection kernel solves this by splitting each full step into 4 sub-steps of \(\tfrac{1}{4} \Delta t_{vis}\) each. The SDF is tested after every sub-step; the particle is guaranteed to be caught within one cell-diameter of travel.
When a particle's sub-step would carry it inside an obstacle (SDF < 0.01), two strategies are applied in sequence rather than teleporting the particle back to the inlet:
Toggle Color by Field (in the Streamlines/Particles or 3D Particles folder) to color each particle and trail segment by the local flow field value at its current position, using the active colorbar theme.
When disabled, particles render as white (dark theme) or black (light theme). The field value is sampled in the GPU vertex shader at zero extra texture-fetch cost, so performance impact is negligible.
An optional Temperature (Buoyancy) folder (above Performance in the 3D sidebar) adds Boussinesq natural-convection physics to the 3D incompressible solver — designed for greenhouse ventilation, stack-effect studies, and any scenario where a temperature difference between zones drives airflow.
The solver remains fully incompressible. Density is assumed uniform everywhere except inside one vertical body-force term. The acceleration added each timestep is:
where \(T\) is the local temperature deviation from ambient (ambient = 0), \(\beta \approx 1/T_{abs} \approx 0.0034\,\mathrm{K}^{-1}\) for air, and \(g\beta\) is the single Buoyancy slider. This force is applied before the pressure-projection step, so the pressure Poisson solve produces the inside/outside pressure difference that drives flow through windows and vents — the physics is not prescribed but emerges from the equations, making the model self-consistent with any wind-driven inlet flow.
A 3D scalar field \(T(\mathbf{x},t)\) is advected and diffused by the velocity field each substep (semi-Lagrangian advection + explicit Laplacian diffusion). Inlet planes always supply ambient air (\(T=0\)). A user-defined box zone injects heat in one of two modes:
Negative values make a cooling zone (chilled wall, evaporative cooling) — the resulting cold air sinks.
Zone Temp is a normalized overheat above ambient, not a Celsius value. The user defines the mapping, for example "Zone Temp 1.0 = 20 °C overheat". The buoyancy force at each cell is Buoyancy × T_local (pointwise), so the slider acts on whatever temperature gradient the field has developed.
The Buoyancy slider encodes \(g\beta\) in simulation units. Its relationship to the physical Richardson number \(Ri = g\beta\Delta T\,H / U^2\) (the ratio of stack-effect strength to wind strength) is approximately:
Practical guidance for a greenhouse (height \(\approx 3\,\text{m}\), 10 °C overheat, Zone Temp = 0.6):
The temperature field is a GPU volume that persists across frames. Setting Zone Temp or Flux Rate to 0 stops injecting new heat but does not remove heat already in the domain — residual warmth floating in recirculation zones continues to drive buoyancy. This is physically correct behaviour (real greenhouse air does not instantly return to ambient when a heater is turned off). Press Clear Heat (back to ambient) to instantly zero the entire field. The field also resets automatically on sim re-init.
Natural convection with residual heat is inherently unsteady — thermal plumes flicker and meander (like smoke above a candle). Only the time-averaged field is steady. Use the Steady Average toggle in the Visualization folder to observe the converged mean plume, exactly as a CFD engineer would report time-averaged natural-convection results. Setting Buoyancy = 0, or pressing Clear Heat while sources are off, produces a truly neutral (isothermal, no-buoyancy) flow.
Select Temperature in the Field dropdown (Visualization folder) to render the thermal plume directly. Ambient air (T = 0) is transparent; warmer regions render red, cooler regions render blue. The ghost box (Show Zone) draws the source zone as a translucent orange (heating) or blue (cooling) box — occluded by the STL obstacle — to help with positioning.
An optional Ventilation Fan folder (directly below Temperature (Buoyancy) in the 3D sidebar) lets the user place up to 4 independent fan zones — for cross-ventilation studies, forced-convection cooling, or modelling a spinning rotor's downwash without resolving actual blade geometry. Each fan has a Shape: Box (a straight-through vent) or Disk (an actuator-disk rotor/propeller).
Box is a hard velocity clamp — the standard simplified-CFD treatment for a duct/desk/extractor fan: inside the zone, velocity is hard-set every step to a fixed directional jet,
where \(P\) is the fan's Power (Jet Speed) and \(\hat{d}\) is its unit direction vector — a Dirichlet velocity boundary condition, the same technique the domain inlet itself uses. This behaves like a section of duct: the flow inside simply is that speed, which suits a straight-through vent but cannot, by itself, distinguish an upstream (suction) side from a downstream (discharge) side. Incompressible engine only.
Disk instead adds momentum to whatever air passes through it, every step:
This single change — an increment rather than a replacement — is what makes a Disk fan behave like an actual rotor: because the zone no longer forces a fixed value, the pressure/continuity solve is free to draw replacement air in from behind the disk (suction) and build pressure up just ahead of it (discharge), a genuine upstream/downstream pressure difference — which is aerodynamic thrust — instead of a section of duct. Disk works in both engine modes: the compressible solver applies the equivalent source term directly to its conservative momentum and energy variables at the end of each Runge-Kutta stage (adding the matching kinetic-energy delta alongside momentum, so the derived pressure doesn't silently jump). Box has no clean equivalent as a source term in a conservative finite-volume scheme, so it remains incompressible-only.
Where two Box zones overlap, the higher-numbered fan's velocity wins (a simple sequential override). Where two Disk zones overlap, their momentum contributions sum — the physically correct combination for multiple momentum sources acting on the same air.
Both shapes' zones are built in a local frame derived directly from the direction vector \(\hat{d}\) (one axis along \(\hat{d}\), the other two spanning the plane perpendicular to it) — so tilting Direction Yaw/Pitch rotates the zone's own shape bodily, not just the velocity direction inside a fixed footprint. For a Box this means its own "outlet face" always faces wherever it's aimed, exactly like an oriented duct segment; for a Disk it means the disk's flat face is always perpendicular to its thrust axis, exactly like a real rotor plane. At the default direction (\(0°,0°\), blowing toward \(+X\)) this local frame reduces exactly to world \(X\)/\(Y\)/\(Z\), so an un-rotated fan looks identical to the original world-axis-aligned box.
Zone Size X/Y/Z follow that same local frame rather than world axes: Size X is always the half-extent along the thrust direction (thickness), and Size Y/Size Z are the two extents across it. For Disk, Size Y is the radius and Size Z is unused. Zone Center X/Y/Z and all sizes are normalized (\(-1\) to \(+1\) for center, \(0.02\) to \(1\) for size) and scaled by the domain half-extents, the same convention as the Temperature zone.
Fans are added and removed independently via + Add Fan / Remove buttons, each opening its own sub-folder ("Fan 1", "Fan 2", ...) with its own shape, position, size, direction, and power — the same add/remove pattern as the Extra Obstacles (3D) folder. The Enable Fans toggle is a single master switch for all fans at once: turning it off zeroes every fan's effect on the next step without deleting any of their configurations, so settings are preserved if re-enabled.
Show Zone + Arrow draws, for every active fan, a translucent teal ghost (a different colour from the Temperature zone's orange/blue, so the two are never confused) marking its true oriented extent — an oriented box for Box fans, an actual tilted cylinder (not a bounding box) for Disk fans, both computed with a proper ray intersection in the zone's own local frame so the ghost always matches what the solver is really doing — plus a shaft-and-arrowhead glyph spanning the zone and pointing exactly along the direction it blows. The glyph reuses the same rendering technique as the Aerodynamics tab's Force Vectors arrows.
Unlike the Temperature model, a fan has no memory: it is re-applied fresh every step, with nothing to advect or diffuse. There is therefore no "Clear Fan" button — toggling Enable Fans off removes the effect immediately on the next frame.
A Disk fan is the recommended way to model a spinning rotor's induced flow: real rotor RPM is far higher than this explicit-timestep solver could resolve for actual rotating blade geometry without an impractically small CFL-limited timestep and a finer grid than the 3D solver's 256³ cap — an actuator disk sidesteps this entirely by imposing the induced velocity directly, the same simplification real rotor-aerodynamics tools use. The alternative — a literally spinning, blade-pitched STL mesh (see Obstacle Spin below) — gives real blade-tip vortices and a measurable Torque, but is only realistic at low RPM.
OTOM CFD includes a built-in CFDChatbot that allows users to alter the simulation using natural language. The architecture works as follows:
The agent communicates with the Gemini API using strict JSON schemas (Function Calling). When a user asks "Make the fluid thicker" or "Add a large spinning wing," the LLM maps intent to two primary functions:
update_settings: Maps semantic concepts to internal variables (e.g., "thicker" \(\rightarrow\) viscosity: 0.05, "slower" \(\rightarrow\) inletVel: 5.0).set_obstacles / modify_existing_obstacles: Translates geometry requests into SDF parameters (e.g., obsType: 'Engineering', engShape: 'NACA 0012', angle: 15, scale: 2.0).The Chatbot class intercepts these tool calls and updates the Lil-GUI controllers, which in turn flush the updated data to the WebGPU uniform and storage buffers.
Array-valued 3D features without a dedicated action — the Ventilation Fan's fans3D list and the Extra Obstacles (3D) list — are set through the same update_settings call, passing the whole array as one of the "changes". Because update_settings replaces whichever keys it touches rather than merging into them, asking the agent to add one more fan requires it to resend every existing fan alongside the new one; it has no live read-back of the current scene and relies on the conversation history to know what is already there.
House tab action. A fourth action, house, drives the House Ventilation tab (§15): it opens the tab, loads the example or a template, switches between imported plans, sets the wind speed, direction and \(C_p\) values, sets each window or door to Inlet / Outlet / Closed / Auto (interior doors Open / Closed), changes room heights and resolution, undoes or resets plan edits, and starts or stops Rank Each Opening — e.g. { "action": "house", "changes": { "windFrom": 270, "openings": { "W1": "Inlet", "D2": "Closed" } } }. While the tab is on screen, the scene snapshot the agent receives lists every opening with its role and live flow, plus the measured ACH and the last ranking, so it reports measured numbers instead of guessing. Walls are edited by the user on the map; the agent cannot move or draw them. Claude Code reaches the same action through the cfd_house MCP tool.
The software calculates Drag (\(C_d\)) and Lift (\(C_l\)) coefficients by integrating pressure and viscous shear forces over the surface of the obstacles.
The Reynolds number is calculated dynamically based on the largest obstacle's chord length \(L\):
Analytics can be exported as structured text or baked into PDF reports with canvas screenshots using the GUI tools.
The 3D surface integral sums \(-p\,\mathbf{n}\,dA\) and the wall shear over the cells within one cell of the body surface. Three details make it quantitative: the projection stores \(p\,\Delta t\), so the pressure is divided by the time step; each band cell is weighted by a smoothed delta function \(\tfrac12\big(1+\cos(\pi\,d/\Delta x)\big)\) of its signed distance \(d\), so the band adds up to exactly one surface rather than two; and the wall shear uses each cell's real distance from the wall. \(C_d\) and \(C_l\) are divided by \(\tfrac12 \rho U^2 A_{ref}\), with \(A_{ref}\) the body's measured frontal area (shown as Ref. Area (frontal)), and the Reynolds number uses the matching equivalent diameter \(\sqrt{4A_{ref}/\pi}\). Earlier versions used a fixed reference area and a grid-based length, integrated the raw \(p\,\Delta t\), and counted the surface twice: the sphere's pressure drag came out about 18× too small.
When the 3D obstacle is spinning (see Obstacle Spin above), the same surface integral that produces Drag/Lift also accumulates Torque about whichever spin axis is currently active — the shaft torque a motor would need to supply to hold that rotation rate:
where \(\mathbf{r}\) is measured from the obstacle's own centre and \(\hat{\boldsymbol{\omega}}\) is its current spin axis — exactly the same axis/centre solid_vel3d uses for the wall-velocity boundary condition, so the reading is about the same rotation actually driving the flow. Torque reads exactly 0 whenever the obstacle isn't spinning (there is no single shaft axis to report a torque about). Combined with Drag/Lift, this is what lets Cd/Cl (thrust) and Torque (cost) together describe a spinning blade's aerodynamic efficiency — shaft power is Torque × angular speed, though the app displays the raw torque rather than computing that product.
The 3D surface integral now resolves the viscous component of the wall force alongside the pressure component. Wall shear stress is evaluated as
sampling the fluid velocity one cell along the outward normal and differencing it against the wall's own velocity — the separation is exactly one cell and velocity is stored in cells/step, so the finite-difference denominator is unity. Using \(\mathbf{u}_{\text{wall}}\) rather than zero is what keeps this correct for a spinning obstacle: the shear a rotating blade feels is set by the fluid's velocity relative to the moving surface, which for a well-driven rotor is far smaller than the absolute velocity and would otherwise be grossly overestimated.
The Surface Friction sub-folder reports Cd — Pressure, Cd — Friction (which sum to the headline total \(C_d\)) and the area-weighted Mean Wall Shear. The ratio is a useful shape diagnostic: pressure drag dominates for bluff bodies, friction drag for slender bodies aligned with the flow. Friction also contributes to the reported Torque, which matters for rotors — omitting it would systematically under-report required shaft power.
The Propulsion (SI) sub-folder converts the lattice-unit force and torque integrals into Newtons and Watts. The conversion is derived from the same relation the solver already uses for velocity in SI Units Mode (\(u_{\text{lat}} = u_{SI}\,\Delta t / \Delta x\)), so one lattice velocity unit is \(\Delta x/\Delta t\) m/s. Because the projection step computes \(\mathbf{u} \mathrel{-}= \nabla p\) with velocity in cells/step, the solver's pressure is a kinematic pressure \(p/\rho\) carrying units of velocity squared — so converting it to Pascals requires \(\rho\,(\Delta x/\Delta t)^2\) with no extra length factor:
where \(m_{\text{world}}\) is metres per world unit. RPM follows from the spin sliders (degrees per frame, converted through Physics Steps and Real-world Time Step), thrust is the force projected onto the spin axis when rotating (streamwise \(+X\) otherwise), and shaft power is \(P = |\tau|\,\omega\). Thrust / Power (N/W) is reported rather than a "figure of merit" because it needs no disk-area assumption and is the metric rotor designers actually compare.
These fields require SI Units Mode (and Live Analysis). Lattice numbers carry no physical scale whatsoever, so rather than showing a fabricated Newton value the fields state "needs SI Units Mode". The physical scale is set entirely by Domain Width (m) and Real-world Time Step, so those must describe your actual case for the output to mean anything.
The Wind Noise folder (directly below Ventilation Fan) estimates flow-generated noise from the rate of change of surface pressure on the obstacle. This is the source term of Curle's acoustic analogy: at low Mach number the dominant noise mechanism is not the turbulence itself (a weak quadrupole, scaling as \(M^8\)) but the fluctuating pressure on solid surfaces, radiating as a dipole that scales as \(M^6\) — which is why a body in a flow is far louder than the same flow without it.
The integrand is accumulated once per solver substep — not on the ~2 Hz analysis timer, which is far too slow to resolve the unsteadiness that actually radiates. Noise Index (dB rel.) is a relative scale with an arbitrary zero point, always available and the right number for A/B comparisons. Est. SPL is an absolute estimate re \(20\,\mu\text{Pa}\) at the chosen Observer Dist, and requires SI Units Mode; it takes the surface correlation area \(A_c\) as one cell face, the standard resolved-scale assumption.
What this is not. There is no acoustic wave equation, no propagation delay, no directivity, no reflection or diffraction, and turbulence finer than the grid radiates nothing at all. The number is a source-strength indicator for ranking designs — genuinely useful for answering "is this shape quieter than that one?" — and must not be presented as a certifiable or measured sound-pressure level. Enabling the panel also costs one extra full-grid pass per substep plus a \(\text{res}_{3D}^3\) history texture pair, so it is off by default.
The Ground Truth tab (fact_check icon in the left sidebar) cross-checks the live simulation against textbook and empirical correlations for the active canonical shape. It auto-detects the current geometry — 2D Circle/Square/Rectangle/Triangle/Hexagon/Pentagon and Engineering shapes (NACA airfoils, Flat Plate, Ellipse, Tear Drop, Wedge), or 3D Sphere/Cube/Cylinder/Wing — and presents a Measured | Reference | Δ% table (colour-coded by error), a reference plot with the current operating point marked, and an engineering assessment.
Reference correlations include the circular cylinder fit \(C_d \approx 1 + 10\,Re^{-2/3}\) (White) with Strouhal \(St \approx 0.20\); the sphere Schiller–Naumann/Morrison drag law; the laminar flat plate \(C_d = 1.328/\sqrt{Re}\) (Blasius, parallel) or \(C_d \approx 1.98\) (normal); a generic sharp-edged bluff-body fit for prisms (e.g. square \(\approx 2.05\) face-on, \(\approx 1.55\) corner-on; triangle \(\approx 1.55\) apex-upstream, \(\approx 2.0\) base-upstream); and thin-airfoil theory \(C_l = 2\pi\sin\alpha\) closed by a parabolic drag polar \(C_d = C_{d0} + k\,C_l^2\). The reference plot is \(C_d\) vs \(Re\) (log axis) for bluff bodies, or the \(C_l\) vs \(\alpha\) lift line for airfoils.
Honesty of the measured column: in 3D the “Measured” values are a genuine GPU surface-force integral. In 2D they are presently an illustrative placeholder (marked with an asterisk) pending a dedicated 2D force kernel; only the Reference column is authoritative in 2D. Hand-drawn or imported (STL) geometry has no closed-form solution, and the panel states this rather than fabricating a number.
Below the table, a scene-aware assessment translates the live observables into plain-language guidance with colour-coded markers (✓ good, ! caution, • info): the solver-vs-theory agreement, the Reynolds flow regime and expected wake behaviour (creeping → steady recirculation → laminar von Kármán street → subcritical → drag crisis), vortex-shedding side-force and vortex-induced-vibration warnings, airfoil stall (incidence beyond \(\sim\!12\text{--}15^\circ\)) and lift-to-drag ratio, and the compressibility regime (incompressible/subsonic/transonic/supersonic, including the Mach angle \(\mu = \arcsin(1/M)\)).
The Ground Truth tab includes an optional “Explain this scene” assistant that runs a small large-language model entirely in the browser on the user’s GPU via WebLLM (MLC). It produces a plain-language explanation of the current scene and then supports a multi-turn conversation for follow-up questions. The model is grounded on the structured scene data — the \(C_d\), \(C_l\), \(Re\), Mach, regime and judgement values come from the solver and reference correlations, not the model — so it narrates and reasons but does not invent coefficients.
A dropdown selects the local model (Llama 3.2 1B, Gemma 2 2B default, Qwen2.5 3B, Llama 3.2 3B, or DeepSeek-R1 Distill 7B). The chosen model is downloaded from the MLC CDN on first use and cached in-browser thereafter; everything runs locally, so no scene data leaves the device. On hardware without WebGPU (or if a model cannot be allocated), it transparently falls back to the cloud assistant. The simulation auto-pauses during generation so the language model and the renderer do not contend for the GPU.
The AI tab (brain icon in the left sidebar, directly below Demo) provides a machine-learning workflow for training and running neural-network surrogate models that approximate the physics solver. A surrogate learns the solver's behaviour from data and, once trained, can advance the flow with a single network evaluation instead of the full iterative solve. This is offered as an experimental research feature: surrogates trade physical fidelity for speed, and are most interesting in 3D where the iterative pressure solve is the dominant cost.
Rather than predicting the next flow field directly, the networks are trained to predict the residual — the change in the field over one solver step — which the simulation then integrates:
where \( \mathbf{q} \) is the stacked velocity/pressure state, \( \text{mask} \) is the obstacle occupancy, \( Re \) is the (normalised) Reynolds number, and \( \mathcal{N}_\theta \) is the trained network. Predicting the residual keeps the targets small and well-conditioned and improves step-to-step stability. All inputs and outputs are normalised internally by fixed reference scales so the network operates on \(\mathcal{O}(1)\) values.
Resolution lock: a network's weights encode the physical scale of the grid it trained on, so each model is bound to a single grid resolution. Applying a model automatically sets the simulation to that grid. To run at a different resolution you train a separate model — resolution is part of a model's identity, not a runtime knob.
Trains a compact U-Net (three encoder/decoder levels with skip connections, ~tens of thousands of parameters) on the 2D incompressible solver using TensorFlow.js. The data pipeline drives the real GPU solver headlessly across randomised parameters, captures (state, next-state) pairs near quasi-steady state, and converts them to normalised residual targets. Controls:
Collecting…, then Epoch n — loss …, then Saved ….2D inference uses a CPU round-trip (read the field off the GPU, evaluate the network in TensorFlow.js, write the result back). At small 2D grids the data volume is tiny, so this stays real-time.
Trains a small fully-convolutional 3D CNN (a flat stack of 3×3×3 convolutions, 8 → 16 → 16 channels, plus a 1×1×1 output convolution — deliberately not a U-Net so the GPU inference path needs only one kernel type). The data pipeline runs the real 3D incompressible solver headlessly, reads back the velocity and pressure volumes, and bakes the obstacle's occupancy mask as an additional input channel. Controls:
32, 64, 128, 256, 512 (the same set the 3D solver offers, so an applied model always lands on a valid sim resolution).Input channels (6): \(v_x, v_y, v_z\), pressure, obstacle occupancy (SDF), normalised Reynolds number. Output channels (4): residual \(\Delta v_x, \Delta v_y, \Delta v_z, \Delta p\).
Unlike the 2D path, the trained 3D network runs entirely on the GPU. Its weights are uploaded to GPU buffers and the forward pass executes as a chain of WebGPU compute shaders operating directly on the simulation's velocity/pressure volumes:
The pack stage gathers the velocity, pressure, and obstacle-mask channels into a packed input buffer; each conv3d stage performs one zero-padded ("same") convolution with ReLU; the write-back stage integrates the predicted residual and writes the new velocity and pressure back to the volume textures (zeroing velocity inside solid cells). Because there is no CPU round-trip, the surrogate can in principle outrun the iterative solver — whose 3D cost is dominated by the many Jacobi pressure-Poisson iterations per step. The advantage is largest at higher resolutions; at small grids the physics solver is already inexpensive, so the surrogate may show little or no speed-up.
Correctness self-test. When a 3D model is applied, a one-off check runs the GPU forward pass and compares it against the reference TensorFlow.js evaluation on the identical input, logging the maximum absolute difference to the browser console. A small value (≈\(10^{-3}\) or below) confirms the GPU kernels reproduce the trained network.
My Models lists every saved surrogate with its parameter ranges and final training loss; each row has a trash icon to delete it. Models (weights plus a metadata record) persist in the browser through IndexedDB, so they survive page reloads.
Apply Model selects an Active Model and toggles Enable AI Solver. Enabling a model swaps the surrogate in for the physics solver: a 2D model switches the simulation to 2D incompressible at the model's grid; a 3D model switches to 3D incompressible at the model's grid (open the 3D tab to view the result). Disabling the toggle — or selecting None — instantly restores the real physics solver.
Inspired by the CATIA V5 Fly Mode, the 3D view supports a first-person fly-through camera that lets you navigate inside the control volume — go forward, backward, turn left/right, look up/down — independently of the normal orbit/zoom interaction.
| Input | Action |
|---|---|
| Mouse movement | Steer — left/right yaws the view; up/down pitches. Moving the mouse to the left turns the camera left (intuitive first-person mapping). |
| Scroll wheel up | Decrease forward speed. |
| Scroll wheel down | Increase forward speed. |
| Right mouse button (hold) | Fly backward while held; release to resume forward flight. |
| Middle-click or Esc | Exit fly mode. |
While fly mode is active, a semi-transparent overlay is drawn on top of the 3D canvas:
FLY MODE | Speed: 0.20) and a reminder of the key controls.The default forward speed is 0.2 world units / second. The control volume spans ±1 unit per axis by default, so the camera crosses it in about 10 seconds at default speed. Speed is adjustable in real time via the scroll wheel over the range 0.05 – 10 world units/s. If the Pointer Lock API is available in the browser, the cursor is hidden during fly mode and movement is captured as raw deltas for smoother steering.
All simulation settings and the current 3D camera state are automatically saved to localStorage under the key wl_session_v1. The session is restored when the page is reloaded — you continue from exactly where you left off.
Ephemeral display values (FPS, frame counter, live Cd/Cl/Reynolds, STL triangle count) are intentionally excluded so that restored values are always meaningful.
beforeunload event) — fires when you close the tab, navigate away, or refresh.To start fresh, clear localStorage in your browser's developer tools (Application → Storage → Local Storage) and reload.
The GUI grows new per-item fields over time (e.g. the Spin X/Y/Z and Show Local Axes controls added to Extra Obstacles and Ventilation Fans). A session, saved scene file, or Demo written before such a field existed won't have it — restoring that old data verbatim would leave the corresponding slider with no value to bind to and fail to build the sidebar. Every load path (session restore, Load button, Demo, and the AI assistant's scene edits) therefore backfills any missing per-item field with its default before rebuilding the GUI, so older saves keep working after an update instead of failing to load.
The bottom of the sidebars ends with these controls, below Load/Save:
The House tab (home icon in the left sidebar, below Study) shows how the wind ventilates a building, room by room. It is meant for architects, designers and householders who want to know which windows and doors actually bring fresh air in, which ones let it out, and what happens when one of them is closed, moved or added. The plan is not drawn as an overlay: walls and door leaves become real solid cells in the 2D solver (rendered as concrete), the yard around the house is ground, and the indoor air is the flow.
.dxf files at once (drawn in CADCraft, AutoCAD or any CAD program that saves DXF). Each becomes an entry in Active Plan, so several houses or variants can be compared in one session.$INSUNITS header and otherwise guesses from the plan size; override with mm, cm, m, inches or feet if the size shown under Plan Size is wrong.How the drawing is read. The importer understands LINE, LWPOLYLINE (including arc bulges), POLYLINE, ARC, CIRCLE and nested block INSERTs. Walls, windows and doors are recognised by their layer or block name — e.g. A-WALL, A-GLAZ, A-DOOR, WALL, WINDOW, DOOR, plus common names in other languages. Wall thickness comes from the two faces of each wall; a door's width comes from its swing arc and leaf. Every opening gets an id (W1, W2… for windows, D1, D2… for doors) and is classed as exterior (between a room and outside, with the facade it sits on: N, E, S, W…) or interior (between two rooms).
The wind is not simulated around the building; it is represented by the pressure it puts on each facade, the usual approach in natural-ventilation design. For wind speed \(U\) and a facade pressure coefficient \(C_p\), the pressure at an opening is
$ p = \tfrac{1}{2}\,\rho\,U^2\,C_p $with defaults \(C_p = +0.6\) on the windward facade, \(-0.6\) on the side walls and \(-0.3\) on the leeward facade (all three are sliders in the Wind folder). Wind From is the direction the wind comes from in degrees, 0° = North = the top of the plan. Openings are driven at two pressure levels: all inlets at the average \(C_p\) of the inlet facades, all outlets at the average \(C_p\) of the outlet facades; the Results box shows both pressures in Pa. If every open window is on facades with the same \(C_p\), there is no pressure difference and the panel says so: no cross-flow is expected.
Wind at the building. By default Wind Speed is a weather-station wind at 10 m in open country (Wind Speed Is = "Weather station (10 m)"). The \(C_p\) values refer to the wind at the building, so it is converted with the BS 5925 / CIBSE method, \(U_H = U_{10}\,K\,H^a\), using Site Terrain (open flat country \(K\)=0.68, \(a\)=0.17; country with scattered windbreaks 0.52, 0.20; suburban / urban 0.35, 0.25; city centre 0.21, 0.33) and Building Height. A 4 m/s station wind is about 2 m/s at a low house in a suburb; the result is shown as Wind at Building. Choose "At the building" to enter that speed directly. Outside Windows Are (Windows & Doors folder): Fully open (purge), Half open or Tilted (kiep, ~10%); the 2D model applies it as the windows' effective open height, so flows scale with it (interior and outside doors are not reduced).
Internally the scene is a Control-Volume, pressure-driven, SI-unit incompressible case (air, \(\nu = 1.48\times10^{-5}\) m²/s, LES on): inlet openings are painted as CV inlet cells and outlet openings as CV outlet cells, so the pressure difference, not a prescribed velocity, decides how much air goes where. The time step follows the wind, \(\Delta t = 0.8\,\Delta x / u_{max}\) with \(u_{max} = U\sqrt{C_{p,windward} - C_{p,side}}\), so the fastest jet moves less than one cell per step.
Set them in the Windows & Doors folder, or simply click a window or door on the map: a small menu opens at that spot with the same choices. Labels on the map show each opening's id and live airflow, coloured green (inlet), blue (outlet) or grey (closed); the arrow at the top right shows the wind. Changes apply to the air that is already moving — the simulation is not restarted, so the effect of opening or closing a window can be watched as it develops.
The Edit Plan (on the map) folder turns the viewport into a simple plan editor:
| Tool | What it does |
|---|---|
| Pick | Click a window or door to open its menu: set its role (Air in / Air out / Closed / Auto), slide it along its wall in 10 cm steps (◀ ▶ or ▲ ▼), make it narrower or wider in 10 cm steps (− / +, growing evenly on both sides, with the width shown), set the Wind at that window or door (0.5 m/s steps, ↺ back to the global Wind Speed), or press Drag on the map to switch to the Move tool. The menu stays open while you click, and the flow updates at once. Panning the view still works. |
| Move | Drag a window or door to slide it along its wall (it stays on the wall); drag one of the round handles at its ends to make it wider or narrower (5 cm steps, at least 0.4 m; the width is shown while dragging). Both stop at the end of the wall and 5 cm before a neighbouring opening. Drag a wall corner, or drag the side of a wall to push the whole wall — both faces and the windows in it move together and connected walls stretch. |
| Draw Wall | Drag to draw a wall of the Drawn Wall Thickness; snaps to 5 cm, to horizontal/vertical and to existing corners, and shows its length. |
| Add Window / Add Door | Click on a wall to put an opening there. It takes the thickness of the wall it sits in. |
| Delete | Click a wall, window or door to remove it. |
Edits are re-rasterised on a fixed layout, so the map never jumps, and swapped into the running solver immediately. Undo (Ctrl+Z, up to 50 steps), Reset Plan to Original and Download Edited Plan (DXF) complete the loop: the exported DXF (metres; layers A-WALL, A-GLAZ, A-DOOR; doors as leaf + swing arc) opens in CADCraft or AutoCAD and re-imports to exactly the same walls, windows and doors.
Every ~0.6 s the tab reads the velocity field and integrates the flow across the air side of each opening. In 2D this is a flow per metre of height, \(q_{2D}\) (m²/s); it is turned into a volume flow with the opening height from Room Data (window or door height, capped by the ceiling):
$ Q = q_{2D}\,h, \qquad \mathrm{ACH} = \frac{3600\,Q_{in}}{A_{floor}\,H_{ceiling}} $The Results box shows ACH, fresh air in L/s, and the flow through every opening (in, out or through). Because indoor air is incompressible, the air coming in and going out should match; the remaining difference (typically 10–15% once the flow has settled) is shown as the measurement uncertainty, and a larger difference is flagged as "still settling".
Each Opening's Effect → Rank Each Opening runs a short study: a baseline with everything as set, then each outside opening closed in turn. Each case settles for one to three flow-through times (Study Length Quick / Standard / Long) and is averaged over 8 samples. The table lists, for every opening, how much the fresh-air rate drops when it is closed — the share each window or door contributes. Your own settings are restored afterwards.
Reference numbers (bundled apartment, wind 4 m/s from the north): about 75 ACH and 4.5–4.6 m³/s through the house; a simple orifice-equation estimate for the same openings gives 4.7 m³/s, and in and out agree within about 12%. Closing window W5 drops it to about 51 ACH; the ranking shows W4 and W5 carrying most of the cross-flow and the south windows contributing 7–11% each.
The House tab can also be driven by the AI assistant (§9): ask it to load the example, change the wind, set windows to inlet/outlet/closed, or rank the openings, and it reads the live per-opening flows and ACH back to you.
Tools that catch setup mistakes before they produce a wrong answer, explain results in plain language, and turn photos, scans and meshes into usable geometry. The checks are deterministic rules (the numbers they quote come from the same formulas the solver uses); the AI assistant is given their findings so it can explain and apply them, but does not invent them. The two learned models used are small, open-licence and run on your device.
The Setup Check button (checklist icon, top right next to Undo/Redo) shows a badge with the number of findings and opens a panel listing each one, why it matters, and — where a safe fix exists — a one-click fix. Fixes go through the normal settings path, so they run each control's own update and can be undone with Ctrl+Z. Ask AI to explain hands the findings to the assistant.
| Check | Rule | Fix offered |
|---|---|---|
| Time step (CFL) | 2D: warning above 2.2, note above 1.5 (the 2D scheme stays stable but smears). 3D: error above 2.2, warning above 1.5. In SI mode the real-world time step is checked against the cell size. | Time step for CFL ≈ 0.8 |
| DNS resolution | LES off while the grid is coarser than the DNS estimate | Turn LES on |
| Forces with Vorticity Confinement | Confinement adds artificial spin and biases Cd/Cl | Turn it off |
| Cells across the body | 2D under 20, 3D under 14 (the validated Re-100 sphere has 16) | Finer grid |
| Blockage | Frontal size over 10% of the channel (2D) or area over 5% of the tunnel section (3D) | Smaller body |
| Run-up and wake | Body closer than 1 body length to the inlet, or under 3 lengths of wake (only while forces are measured; not for spinning rotors) | Move the body (only if it then still fits) |
| Shape mismatch (3D) | The shape menu and the shape the solver runs disagree | Re-select the shape |
| Other 3D | Compressible below Mach 0.3, Multigrid off while measuring forces, Temperature drawn as a fogging volume, fans on with no power, inflow with the outlet closed | Matching one-click fix |
The blow-up guard reads one slice of the velocity field every ~2 s. Any NaN pauses the run (a NaN field never recovers) and opens the Setup Check with lower the time step and restart. A cell moving more than 8 cells per step — far beyond any stable flow — is reported as a runaway without pausing.
Runs on every STL/OBJ/3DS import, before the (slow) voxelizer: triangle count, size and likely units, watertightness (open and non-manifold edges — inside/outside is decided by ray counting, so holes leak), zero-area triangles and heavy meshes. It proposes an orientation — the longest side along the flow (+X), up = Z for STL/3DS and Y for OBJ — with side and top previews and buttons to change the flow axis, the up axis, turn it front-to-back or upside down, or keep the file orientation. Options: size it for the wind tunnel (about 5% blockage and at most 35% of the tunnel length), place it 30% along the tunnel, and close small gaps with a thin shell. The chosen rotation is stored with the mesh and re-applied when a session is restored. Import as-is skips everything.
The HTML report (2D Aerodynamics folder) now opens with a written Summary: what was simulated; the time-averaged Cd and Cl with their RMS fluctuation and whether the average had settled; the Ground Truth reference and the percentage difference (with the known 20–30% low reading of the 2D solver in shedding flow); a trust level (Good / Moderate / Low) with its reasons; and next steps. The Setup Check findings at report time are listed too. Every number in the summary also appears in the report's tables. The optional AI-written summary (off by default) adds a paragraph written by your selected AI provider from the report's numbers only; the screenshot is not sent.
Short lessons that load demos, let the flow develop and explain what to look at, with Back/Next and Ask AI about this: Shock waves (Sod tube, its exact solution in the Truth tab, a Mach 2 and a Mach 3 wedge — theory 47.8° and 34.0° for this 16.7° wedge), Vortex shedding & Reynolds number (honey Re 5 vs a von Kármán street at Re 200), Measuring drag you can trust (sphere at Re 100: Cd ≈ 1.11 vs 1.09), Seeing vortices in 3D (Q-criterion and Lambda-2) and Heat and ventilation. The AI assistant can start them ("teach me shock waves").
3D View → Smoke Glow (volume) renders the field as glowing smoke, the style of production CFD volume renders (for example Flow360's vorticity images). Opacity is a band: zero for calm air, rising above the Threshold, and falling back to zero at Glow Band Top (a fraction of the colour window), so the strongest cores stay see-through and every filament shows a glowing edge instead of filling in like fog. The colormap is spread across that band (Magma or Inferno look best), Volume Opacity is the opacity of one grid cell of glow (so the look does not change with Render Scale), and Fade Near Body fades the glow to zero within that distance of the body so the boundary layer does not speckle its surface. The march takes twice the samples in this style. Environment → Dark Studio gives a dark backdrop whatever the menu theme. The Swept Wing Tip Vortices 3D demo uses it on vorticity at Re ≈ 15,000, 160³.
The Demo tab groups the scenes into Aerodynamics & Vortices, Machines & Rotating Parts, Shocks & Compressible Flow and Heat, Ventilation & Buildings (assigned from words in the file name); each group, the Teaching Labs and the Guided Tours open with a click and remember whether they were left open.
A lab session done end to end for a teacher: one press sets up a classic experiment in the 2D solver, lets the flow develop, measures it, compares every measurement with published data and downloads a student worksheet (aim, theory, prediction questions with blank answer boxes, method, the measured data and charts, analysis questions) and an instructor answer key (the same pack with every answer worked from the measured numbers), plus Data (CSV). Optional Course and School fields go on the header.
The worksheet states the tool's measured accuracy (within about 10% for steady, resolved flow; time-mean drag 20–30% low once the wake sheds) so students treat the numbers as a measurement with an error to discuss. The AI assistant and Claude Code can run a lab with {"action": "lab", "changes": {"run": "cylinder", "set": "quick"}} and download the files with {"download": "instructor"}.
📋 Ventilation Assessment → Run Assessment + Report does the whole study in one press and downloads a report you can hand to a client. The plan is run for each wind direction (Wind Directions: 4, 8 or the current wind only) at the set Wind Speed and Cp values, with the same settle-and-sample schedule as Rank Each Opening (Study Length). For every room and direction it measures the through-flow air changes per hour,
$ \mathrm{ACH}_{room} = \frac{3600 \cdot \tfrac12 \sum_{open} |Q_i|}{A_{room}\,H_{ceiling}} $(half the sum of the flows through all its open windows and doors, since what comes in also goes out), and compares it with Target per Room (ACH). The default of 4 ACH is the usual purge-ventilation figure for a habitable room. Each direction has its own uncertainty band: the larger of its measured in/out mismatch and twice its sample fluctuation, never below 10%. A direction whose in and out flows differ by more than 25% is flagged. If its samples still vary, the flow had not settled and the report suggests a longer Study Length. If they are steady, it is a bias in the outflow measurement: ACH is taken from the inflow (on the example apartment, wind from E, it stayed within 1% across run lengths and grid sizes while the mismatch did not improve), and that direction is judged with the wider band. A room within that band of the target is reported Marginal rather than pass or fail. Corridors and small windowless rooms (from the room-type guess) are listed but not judged.
The report (self-contained HTML, prints to PDF) contains: an overall verdict, a polar chart of whole-house ACH by wind direction against the target, rule-based recommendations, a room × direction table (green / amber / red), whole-house and per-opening flows per direction, a flow snapshot for each direction, and the method and its limits (2D plan model, generic Cp, no single-sided or stack ventilation, design-stage tool). Cross-check: every direction is also recalculated by hand-calculation methods that do not use the simulation, with the same opening sizes and facade pressures: an orifice network (every room a zone at its own pressure, every open window and door, interior doors included, an orifice \(Q = C_d A \sqrt{2\Delta p/\rho}\), room pressures solved by Newton's method so each room's flows balance — the multizone method of CIBSE AM10 and CONTAM) and the single-zone BS 5925 formula \(Q = C_d A_{eff} U \sqrt{\Delta C_p}\), \(1/A_{eff}^2 = 1/A_{in}^2 + 1/A_{out}^2\), with \(C_d = 0.6\). The simulation agrees when its fresh air is within the \(C_d\) 0.5–0.7 range of the network figure, widened by that direction's uncertainty band. A direction that disagrees is named in the recommendations (above: usually an inlet jet crossing straight to an outlet; below: an undeveloped flow or a sharply bending air path); the verdict itself is never changed by the cross-check. The report shows both figures per direction and the simulated vs network ACH per room. Download Results (CSV) gives the same numbers as a spreadsheet. The recommendations are fixed rules applied to the measured numbers, so the report never contains a figure the simulation did not produce. The AI agent and Claude Code can start it with {"assess": true} and read the verdict from the scene state.
Example reports (open in a new tab; each is the file the tab downloads, unchanged):
Upload Image opens a cut-out dialog: click the object to keep (more clicks add parts), right-click to mark background. It uses SlimSAM, a distilled Segment Anything model (Apache-2.0, ~14 MB, downloaded once, runs on the CPU in the browser; the picture never leaves the device). The mask is cleaned for use as a body: specks removed, small gaps closed and enclosed holes filled (a car's window is not a hole through the car). Use brightness threshold instead keeps the original silhouette method; the AI Cut-out on Upload checkbox turns the dialog off. The model only decides which pixels are the body; it does not alter the flow or its rendering.
With Enable Bubbles every 3D particle becomes a bubble parcel: released at a ring or disk sparger, carried by the liquid plus its own rise (slip) speed \(v_s\), burst at the Liquid Surface height and released again. It moves with the physical time step, not the Particle Speed display multiplier. The coupling is two-way: the bubbles in each cell are counted every substep, and the liquid receives an upward acceleration proportional to the local count (averaged with the six neighbouring cells), applied before the pressure projection like buoyancy:
$ a_y = k\,\bar{n}_{cell}, \qquad k = G\,\frac{v_s^2}{H_p}\,\frac{V_p}{N} $where \(N\) is the number of bubble parcels, \(H_p\) the plume height (sparger to surface) and \(V_p\) the plume volume (sparger radius × height). This is calibrated on bubble-column behaviour — the liquid dragged up by a bubble plume moves at about the bubbles' rise speed — so Gas Push \(G = 1\) gives a plume about as fast as \(v_s\), independent of the bubble count and the grid. In a still tank (impellers stopped) the bubbles alone drive a plume up to ≈1.8× \(v_s\) and the return flow down the walls. Not modelled: bubble break-up and merging, size distribution, gas dissolving into the liquid (mass transfer), and a free liquid surface. Demo: Stirred Tank with Sparger 3D.
The demo scenes in the DEMO tab are collected in a slide deck: for each scene, a Blender (Cycles) render of a run recorded in the app, what the picture shows, the physics and solver settings, and the result, tagged VALIDATED (compared with a reference), PARTLY or SHOWCASE (demonstrates a feature; numbers not validated). It ends with a table of the measured values against their references.
OTOM CFD Demo Gallery (PDF, 39 slides) — also linked at the top of the DEMO tab, and online in the OTOMcomposite image gallery.
| Scene | Measured vs reference |
|---|---|
| Sphere Drag at Re 100 (3D) | Cd ≈ 1.11 vs 1.09 Schiller–Naumann (≈ 3%) |
| Supersonic Wedge, Mach 2 | Shock angle ≈ 47–50° vs 47.8° oblique-shock theory |
| Sod Shock Tube | Density, velocity and pressure profiles on the exact Riemann solution |
| Kármán Vortex Street (Re 200) | Shedding reproduced; Cd ≈ 0.9–1.0 vs ≈ 1.3 (known 20–30% low bias of the 2D solver in shedding flow) |
The numerical methods and boundary immersion techniques driving this WebGPU solver heavily draw upon academic research published in top-tier Elsevier journals. For further reading on the mathematics of WaterLily-style models, consult the following literature: