Compare commits

..
47 Commits
Author SHA1 Message Date
Christian Svensson e9ccf10692 engine: add a small heated flow sample
Demonstrate temperature slices and signed face-average wall heat with cool air passing an isothermal bracket. Register the original CPU-rendered gallery thumbnail and document the coarse transient setup.

Part of #157.
2026-10-17 14:08:50 -04:00
Christian Svensson 1e5ff08c5f engine: show temperature and wall heat in fluid results
Route saved temperature through slices, obstacle surfaces, streamlines and both result views, with availability and model units shared by commands and the UI. Show signed obstacle heat histories per complete face area at the selected frame, leaving unrecorded times uncoloured and retaining the saved slice settings.

Part of #157.
2026-10-17 14:08:50 -04:00
jyvikoandClaude Opus 5.5 8f8124dfa1 release: version 0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 13:50:17 -04:00
jyvikoandClaude Opus 5.5 1f2446935f ci: run develop's Linux jobs on the self-hosted runner when SELF_HOSTED_RUNNER is set
Pushes, the schedule and Run workflow go to the runner labelled by the
repository variable SELF_HOSTED_RUNNER (tangency-labs/ci-runner, label
fieldcraft-ci); pull requests always stay on GitHub's runners, so fork
code never reaches it. Unset the variable and every job is back on
ubuntu-24.04. macOS and Windows jobs are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 11:32:58 -04:00
jyvikoandClaude Opus 5.5 1daee9d969 docs: ask for pictures in issues and pull requests
The bug template's Evidence asks for a screenshot or recording first,
marked up, and a section for meshes; the feature template gains a
Pictures field; the pull request template and CONTRIBUTING ask for
before/after pictures of any visible change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:57:58 -04:00
Christian Svensson 8cd37e0a68 gpu: take wgpu's backend options from its environment variables
The adapter selection built its wgpu instance with default backend
options, so WGPU_DX12_COMPILER had no effect: on DX12 there was no way to
choose between FXC and DXC when measuring them (#561: FXC takes ~8.5 s
to create the GPU solver's pipelines and runs it at half of Vulkan's
speed, DXC 0.5 s and full speed). The options now come from wgpu's own
environment variables. staticdxc is refused with an error: it needs
wgpu's static-dxc feature, which FieldCraft does not build, and wgpu
would panic on it.

Part of #561.
2026-10-17 10:45:13 -04:00
Christian Svensson 60fd0998f5 lbm-gpu: stream in place with the AA pattern
The collide and stream passes each moved the whole population field
through two buffers (304 bytes per cell update). The AA pattern (Bailey
et al. 2009; Wittmann et al. 2013) keeps one buffer: even steps collide
each cell in place with its directions swapped, odd steps read a cell's
incoming populations at its neighbours and write the outgoing ones where
the next step reads them, so each thread writes the addresses it read
and streaming happens inside the collision (152 bytes). A cell's
coordinates are computed once and neighbours wrap with a compare and an
add. The per-link wall rules gather their sources before a scatter
writes them, so no link overwrites another's post-collision value, and
the momentum exchange is taken in the gather of the step whose forces
are read back.

Options::streaming picks the layout; Auto, what a solve uses, streams
in place, which measured faster on every device tried: 1.5x on an RTX
4090, 1.85x on the GB10 and 1.45x on an M4 Max at periodic 128^3.
Options::profile times each kernel with timestamp queries for the
throughput test, which now runs both layouts. The verification tests
run in both layouts, with two new ones comparing fields, mass, forces
and heat with the CPU after odd and even step counts across submission
boundaries.

Part of #562.
2026-10-17 10:45:09 -04:00
Christian Svensson 74dce59c7d lbm-gpu: sum the Zou-He deviations before adding the weights
The open boundaries summed the tangential and outgoing populations as
deviations from their weights plus the weights (2/3 and 1/6), in single
precision; at low speed the weights round away the deviations' last
digits. A low-speed case through an inlet, an outlet and a slip face
came out 2.26e-5 of the largest speed from the CPU under DXC, against
the 2e-5 the tests allow. The deviations are now summed first and the
total weight, exactly 1, added once: the same Zou-He rule, so results
change only at the round-off level.

Part of #562.
2026-10-17 10:45:09 -04:00
jyvikoandClaude Opus 5.5 c1941ad349 ui: Move Nodes prepares once the form settles and skips large regions
A changed form (Alt + scroll on the radius) is prepared 150 ms after it last
changed, the sphere drawn meanwhile; a region of more than 1000 nodes is not
previewed and moves on release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:35:53 -04:00
jyvikoandClaude Opus 5.5 8a178879ee engine: mesh.node.move's live preview, capped before its costly part
prepare_capped counts the nodes that would follow and stops before the design's
faces and the harmonic solve when they are more than a cap; preview_live
reuses the region's baseline and guards more coarsely.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:35:53 -04:00
jyvikoandClaude Opus 5.5 1dd2afe525 mesh: a node move measures its region in parallel and can reuse its baseline
move_nodes_with takes the region's quality before the move from an earlier run
and a coarser guard bisection, for a live preview; each pass measures the
elements in parallel (fixed blocks). The relaxation's state sizes its elements
(a certified check each) only for the strong mode, which alone uses the size:
on the crank arm's hex20 mesh that was 114 ms of every 130 ms preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:35:53 -04:00
jyvikoandClaude Opus 5.5 53a370ed01 ui: Move Nodes icon shows the grid and the node's new position over it
The original 2×2 grid in the line colour, the moved node and its four
edges overlaid in the accent colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:35:53 -04:00
jyvikoandClaude Opus 5.5 ee09ff2fca ui: Move Nodes tool: drag nodes in the view with a ghost and quality colours
With the form open a drag on the mesh grabs the node under the press and
moves it in the screen plane; Alt + scroll sizes the radius. The view draws
the original edges as a ghost under the moved ones, coloured by how each
element's scaled Jacobian changed, and the sphere; the form shows the region's
numbers live. Release runs mesh.node.move (one undo step), Esc cancels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:35:53 -04:00
jyvikoandClaude Opus 5.5 711e51074c engine: mesh.node.move moves nodes by a vector or to a point
The grabbed nodes (listed, on B-rep faces, or a sphere's) move; the nodes
within the radius follow by a falloff or harmonically; nodes stay on the
geometry as Relax Nodes moves them; the move is scaled back until no element
inverts. The reply gives the region's scaled Jacobian before and after and the
elements that got worse and better. One undo step. Toolbar entry Move Nodes
(Mesh, Improve) with its own icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:35:53 -04:00
jyvikoandClaude Opus 5.5 e52eda728f mesh: move nodes by hand, with falloff or harmonic followers and an inversion guard
relax::move_weights gives each node its share of a displacement (the grabbed
nodes all of it, the nodes within the radius strength x falloff, or the
harmonic function of the element-edge graph, zero beyond the radius and at
held nodes); relax::move_nodes applies it as the relaxation's constraints let
each node move (faces in UV, edges along, vertices held), scaled back by
bisection until no valid element inverts, and reports each element's scaled
Jacobian before and after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:35:53 -04:00
Christian Svensson 99b0b0cfa4 lbm-gpu: the temperature lattice on the GPU
The D3Q7 scalar lattice steps on the device after the flow's step, in
its own A–B pair of buffers: BGK collision with the velocity the
momentum collision leaves in the moments buffer, pull streaming with
the links' rules read through each group's thermal condition
(anti-bounce-back for a held temperature, bounce-back for an adiabatic
face, a flux added to the bounced population), inlets at their held
temperature and outlets copying their neighbour. The populations are
stored as deviations from w_q T₀ so single precision keeps the digits
of the differences. The heat each link exchanged goes into the fourth
component of the momentum-exchange contributions and the group sums;
the buoyant acceleration −β g θ is read per cell in the collision, the
open boundaries and the moments. A temperature that stops being a
number ends the chunk with Error::Unstable like the flow's checks.

Flow exposes what the backend needs to build the device state (the
thermal settings and populations).

Verification against the CPU on lavapipe: plates held at 20 and 21
(temperatures within 1.4e-5 of the range, the wall heats within 6e-5
and both against k ΔT A / H), a heated channel with a parabolic inlet
carrying its temperature, a flux wall, a held wall and an outlet
(temperature 3e-7, heat 1e-7) and a buoyant cavity at Ra 10³
(velocity 1.3e-6, temperature 6e-7, heat 2.4e-6). The tests now fail
on a shader that does not compile instead of skipping; only a missing
device skips.

Part of #157.
2026-10-17 08:32:30 -04:00
Christian Svensson 190f958b23 engine: heat in a fluid step
A fluid domain takes `thermal` settings (the initial temperature, a
thermal condition per box face and per obstacle face, gravity with the
expansion coefficient for buoyancy) in model.fluid_domain.create and
.edit, and model.fluid_domain.thermal_boundary sets one boundary's
condition as model.fluid_domain.boundary does for the flow. A material
a domain with heat uses keeps needing its conductivity and specific
heat when it is replaced.

solve.fluid's summary adds the thermal relaxation time, the fluid's
temperature range and the heat into every wall (final and the mean
over the last quarter of the history); solve.probe gives the
temperature in the cells at points and, with `heat`, the walls' heat
histories. The form, the catalogue and docs/features.md follow.

Part of #157.
2026-10-17 08:32:30 -04:00
Christian Svensson 939b64e660 lbm: a temperature lattice on the flow
A D3Q7 passive scalar rides on the D3Q19 flow: it is advected by the
cell velocity, diffuses with the fluid's thermal diffusivity (the
relaxation time follows from k / (rho c)), and feeds back into the
momentum through a per-cell Boussinesq force when the domain has an
expansion coefficient and gravity. Walls and obstacle faces hold a
temperature (anti-bounce-back), a heat flux (added to the bounced
population) or are adiabatic (bounce-back); velocity inlets carry the
held temperature in, pressure outlets copy their neighbour.

The heat crossing each face group is accumulated during streaming and
reported in physical units (W) in the result's heat histories, next to
the force histories; frames gain a temperature field and the stats
report the thermal relaxation time.

Closed-form checks: linear conduction between held plates, a flux wall
against q H / k with the wall's heat balance, an inlet temperature
carried through a plug flow, and a periodic blob that conserves its
total and returns after one lap. The de Vahl Davis cavity (Ra 1e3 and
1e4 against the published Nusselt numbers) is there as an ignored test
because it takes minutes.

The GPU backend declines domains with heat for now and asks for the
CPU; its kernels follow.

Part of #157.
2026-10-17 08:32:30 -04:00
Christian Svensson ea284caf80 model: heat in a fluid domain
A fluid domain may carry thermal settings: the initial temperature,
gravity and the fluid's expansion coefficient for buoyancy, and a thermal
condition (adiabatic, held temperature, heat flux) on each box face and on
named obstacle faces, adiabatic unless told otherwise. The material then
needs an isotropic conductivity and a specific heat. Boundaries are set
one at a time like the flow ones.

Part of #157.
2026-10-17 08:32:30 -04:00
jyvikoandClaude Opus 5.5 7d419b73f4 mesh: the tet report carries the scaled Jacobian; both metrics tested on the same tets (#547)
The dihedral range and mesh.quality's scaled Jacobian were both right:
they are computed by the same function on the same final corners (tet4
and tet10 give identical numbers), but a dihedral range does not bound
the corner scaled Jacobian. A needle has dihedral angles near 60 and 90
degrees and a nearly flat apex corner. Before 417c0c88 sphere-on-plate
at size 4 had dihedrals 36.3-141.6 and 106 needles below SJ 0.2 (min
0.095); now 36.0-137.6 and min SJ 0.351.

The mesher's report now carries min_scaled_jacobian so it cannot read
as all-good while needles remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 08:12:09 -04:00
jyvikoandClaude Opus 5.5 e315b2bef7 docs: the plan's apply, the cells mesher and the partition are jobs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 08:12:04 -04:00
jyvikoandClaude Opus 5.5 2729511cb5 ui: mesh.plan.apply, mesh.cells and geometry.partition run as jobs (#545)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 08:12:04 -04:00
jyvikoandClaude Opus 5.5 b5f71612af mesh: mesh.plan.apply, mesh.cells and geometry.partition report their stages and pass a cancel through
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 08:12:04 -04:00
jyvikoandClaude Opus 5.5 b2fcf2c5bc geometry: partition cuts and the projection mesher report progress and stop when cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 08:12:04 -04:00
Christian Svensson 8dce4b14ce engine: the flow test fixture names its backend
`FluidStats` gained `backend` in #535 and the fixture of the flow drawing
tests arrived with #550; merged one after the other, the fixture lacks the
field and `cargo test -p fieldcraft-engine` no longer compiles on develop.

Part of #157.
2026-10-17 08:12:01 -04:00
Erkin Alp Güney 18be60672b verification: activate the two shell benchmark cases (#106)
The Scordelis-Lo roof and the pinched cylinder had their published
references cited but no recipe, because mesh.block's 2D form is planar
and cannot build a curved patch. Both are now active: the mesh is
written node by node (mesh.node.add / mesh.element.add, the route
docs/verification.md allows), the same cylinder patch the solver's own
benchmarks.rs builds in code, with the quarter/eighth symmetry planes,
the rigid diaphragm and the published load.

- scordelis-lo-roof-shell: 16x16 flat quad4 (MITC4) shells, R = 25,
  t = 0.25, self-weight 90 per area; u_z at the free-edge mid-span
  -0.3024 within 2% (ours -0.29901009815961804, 1.12% low).
- pinched-cylinder: 32x32 flat quad4 shells, R = 300, t = 3, a quarter
  of the unit pinch; u under the load -1.8248e-5 within 2% (ours
  -1.803845342860719e-05, 1.15% low).

Each README carries the generator (so the recipe is reproducible), the
refinement study the tolerance reason cites, and how the reference was
cited before. pinched-hemisphere and mh-twisted-beam now say what they
actually wait for: a doubly curved or helical shell mesh written node
by node, not missing shell elements.
2026-10-17 07:25:44 -04:00
Christian Svensson a0c7d6ce9d ui: show fluid results in the viewport with the part, legend and controls
The window built a deformed copy of the model for every results plot,
which drops the geometry surfaces, so after a fluid solve the bracket
vanished and nothing else was drawn.

A fluid step's plot keeps the part as it is and draws the engine's flow
display through it (the same scene as the headless pictures): the
field on a slice, the part coloured by the flow beside it and
streamlines. The legend shows the field with its bands and the cells
of its extremes (Max/Min mark and turn the view to them); the results
panel swaps the deformation rows for the slice's normal and position,
the obstacles' colours, the streamlines and a frame slider, all
running the results.* commands. The status line describes the new
commands.

Part of #193.
2026-10-17 07:25:04 -04:00
Christian Svensson f7dcd46b2f engine: draw fluid results as slices, coloured obstacles and streamlines
After solve.fluid the results view had nothing to draw: a fluid step's
fields are per cell, the view only knew nodal fields, and the deformed
display it built dropped the geometry surfaces, so even the part
vanished.

A fluid step now has its own display (`engine::flow`, on render's
flow display data), shared by the headless pictures and the window:
the part drawn as it is, its faces coloured by the field in the fluid
beside them, the field on a slice through the domain and streamlines
seeded on the inlet face upstream of the obstacles, coloured by speed
when the legend shows velocity. It is first shown with velocity
magnitude on a slice through the middle, normal to the horizontal axis
across the flow.

New commands: results.slice {step, field: velocity|pressure, axis:
x|y|z|none, position, frame}, results.surface {step, show} and
results.streamlines {step, show, count, max_steps, seeds | line,
frame}, which replies with what it traced. results.contour takes
velocity and pressure for fluid steps (a new `pressure` field) with
the usual bands and legend clamp, and results.frame walks a fluid
step's frames. Streamlines leave the coming-soon list. Hostile
parameters are refused, and the counts are capped.

Part of #193.
2026-10-17 07:25:04 -04:00
Christian Svensson 0d30ddb747 render: slices, surface values and streamlines of cell flow fields
A fluid step's result is a field per cell of a regular grid, not per
node, so the contour machinery had nothing to draw it with and the
viewport showed nothing after a fluid solve.

Add `render::flow`, CPU display data for such fields with no solver
dependency (render stays at L1): an axis-aligned slice through the
cells as triangles carrying cell-centred values (interpolated between
the two layers either side, outer centres moved to the domain faces,
cells inside obstacles left out), the field sampled at a surface
beside the fluid cells for colouring an obstacle, and streamlines
traced through the trilinear velocity field by the midpoint rule,
seeded on a lattice upstream of the obstacles. Seeds, steps and the
total number of points are capped so hostile counts cost a bounded
time. `contour::banded` fans band pieces into scene triangles for the
callers.

Part of #193.
2026-10-17 07:25:04 -04:00
Christian Svensson 1e0c21d088 lbm-gpu: one atomic per workgroup for the chunk's largest speed
Every cell did an atomicMax on the same word; a workgroup reduction first
leaves one per 256 cells. Same result, less contention on a single
address.

Part of #529.
2026-10-17 07:23:40 -04:00
Christian Svensson 48cc80bef5 solve.fluid: backend "cpu" (the default) or "gpu"
The CPU stays the default, so every existing result and fixture is
bit-identical; "gpu" steps on the device FIELDCRAFT_GPU picks through the
registered backend, and is an error when the build has none. The desktop
app and the CLI (hence the MCP server) register fieldcraft-lbm-gpu at
start; the summary names what stepped. Hostile values are errors.

Part of #529.
2026-10-17 07:23:40 -04:00
Christian Svensson b123f31d47 lbm-gpu: the lattice Boltzmann step on the GPU, checked against the CPU
A new L7 crate: the CPU scheme's collision (TRT with Guo forcing), the
per-link streaming rules (bounce-back with Ladd's term, slip reflection,
open placeholders), the Zou–He boundaries and the momentum exchange per
force group as wgpu compute shaders, stepping the flow fieldcraft_lbm's
Flow prepared, through its Backend trait: populations stay on the device,
a chunk of steps is one submission, and only frames, forces and the
stability check come back. Populations are stored as deviations from
their weights, which keeps single precision honest: the verification
cases (Poiseuille with Λ = 3/16, Couette with wall forces, Taylor–Green,
a sphere's drag, a Zou–He channel) agree with the CPU to 2–5e-6 in
velocity, 1e-6 in density and 5e-5 in force, with those tolerances stated
in the tests. The rules are applied one thread per link rather than in a
per-cell loop, which lavapipe's JIT miscompiles (and which diverges on
real GPUs anyway). Native only for now; the browser registers nothing.

Part of #529.
2026-10-17 07:23:40 -04:00
Christian Svensson 944a54c76b lbm: a Backend trait steps the flow, with the CPU scheme as its reference
run() drives a Backend in chunks of steps (to the next frame, history sample
or progress check) instead of the CPU Flow directly, so a device backend
registered by an application can step the same prepared flow: Flow keeps
building the grid, links and open cells and exposes them read-only, and
implements the trait itself. solve_fluid_on picks the CPU or the registered
device backend; solve_fluid stays the CPU and is unchanged in its results.

Part of #529.
2026-10-17 07:23:40 -04:00
jyvikoandClaude Opus 5.5 417c0c8897 mesh: the tet objective sees needles; no tet below scaled Jacobian 0.3 on the skill's tet parts
The improvement objective was the smallest dihedral sine, which misses
needles: every dihedral angle fine while a corner is nearly flat, so
the corpus meshes had scaled Jacobians down to 0.01-0.07. The objective
is now the smaller of the dihedral sine and the scaled Jacobian at the
worst corner (the measure analysts check, Knupp 2001), weighted so a
Jacobian of 0.3 (the skill's limit) counts as a 30 degree dihedral
angle (the schedule's target): the Jacobian decides only near that
limit. The existing edge removal, multi-face removal, smoothing and
insertion then remove the needles.

Skill tet10 min SJ: sphere-on-plate 0.092 -> 0.335, filleted-bracket
0.021 -> 0.318 (both pass, nothing marked). Default-size tet10:
thin-ribs 0.240, shallow-groove 0.321, rounded-box 0.311.

The strong-relaxation test relaxes below 0.45: nothing is left below
0.3. Literature note in docs/meshing/literature.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 00:51:20 -04:00
jyvikoandClaude Opus 5.5 d7444edac7 solver: a tie row nearly repeated on a held secondary node is dropped
Where a tie's secondary surface meets a boundary condition or an
axis-aligned symmetry plane (a crosspoint), the secondary node's motion
is already prescribed and its mortar row can only constrain main
degrees of freedom. Its weights on main nodes off the boundary do not
vanish (the dual basis spreads over the node's faces), so after the
substitutions a small but real constraint is left: 1.3e-6 of the
coefficients that cancelled to give it on the tied hex-tet bar with
the needle-aware tet mesh (round-off would be ~1e-14; the geometry is
exact). It was refused as a near repeat, which made the tied bar
depend on how the tet faces fell. It is now dropped (the tie is then
off by that fraction at the crosspoint), after checking it does not
contradict the prescribed values. A near repeat on a secondary degree
of freedom another constraint eliminated (a node secondary in two
ties) is still refused (#389). Dropping every held secondary node's
row instead fails the tie patch test, so only near repeats go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 00:51:20 -04:00
jyvikoandClaude Opus 5.5 6b4a966e1a Feature graph: test the panel's words and face selection on a real design
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 00:50:52 -04:00
jyvikoandClaude Opus 5.5 2ffbb9b978 Feature graph panel: features per body in words, click selects their faces, hover highlights them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 00:50:52 -04:00
jyvikoandClaude Opus 5.5 e955b67bb7 Feature graph: each feature of a timeline in plain words
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 00:50:52 -04:00
jyvikoandClaude Opus 5.5 3977a0b630 deps: zip 9.0.4; ignore the zip 3 extract advisories that come through solidcraft-io
RUSTSEC-2026-0367/0368/0369 are in ZipArchive::extract and
ZipStreamReader::extract. Our zip 9 is updated to the fixed 9.0.4. zip 3
comes only through the pinned solidcraft-io, which never calls extract
(3MF parts are read by name), so the three are ignored with that reason
until SolidCraft moves to a fixed zip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 00:50:21 -04:00
jyvikoandClaude Opus 5.5 ec8e165fbb engine: guards in the Relax Nodes form without a default (strong mode only)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:41:27 -04:00
jyvikoandClaude Opus 5.5 a2a4f8895e mesh: strong relax keeps a morph unless it inverts, default reach five element lengths
Owner's rule (#438): move the bad nodes, interpolate, keep the morph as long as no
element inverts (guards "valid", the default); the old guards stay as "strict". The
run keeps its best round (highest smallest scaled Jacobian, then fewest elements below
the threshold, then highest mean) and never ends below the start. Default reach five
mean edge lengths of the bad elements (was three); on the Pi 5 base reach 3, 5 and 7
end at the same quality, 5 moving fewer nodes than 7 while leaving none below 0.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:41:27 -04:00
jyvikoandClaude Opus 5.5 426df4a333 mesh: improvement never leaves a tetrahedron floating; the gate refuses one
Deleting a cap tetrahedron (two boundary faces of one B-rep face) gives each of its
two other neighbours a boundary face; a neighbour that had three was left with every
face on the boundary, joined to the mesh by its vertices alone. On Linux the dome on
a plate (cmd::plan::decompose::tests::a_dome_on_a_plate...) got one and the static
solve found the stiffness singular. The kernel's tessellation and sphere parameters
differ in their last bits between macOS and Linux (SolidCraft, outside this repo);
given the same input the tet mesher gives the same mesh on both (checked by replaying
Linux's surface and spheres on macOS, which reproduced the floating tetrahedron).

The deletion now refuses when a neighbour would float, and the linear validity gate
fails a mesh with a tetrahedron that shares no face with the rest, saying so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:41:22 -04:00
jyvikoandClaude Opus 5.5 2e639336c3 docs: skill results with the plan sized for its chords
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
jyvikoandClaude Opus 5.5 8e5072edf0 testkit: why the sphere's fallback misses the volume limit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
jyvikoandClaude Opus 5.5 f2ea44b62d testkit: skill volume limit 1 %, causes of the parts now passing dropped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
jyvikoandClaude Opus 5.5 8ad0800aa6 mesh: size a plan so its chords keep the volume within 0.5 %
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
jyvikoandClaude Opus 5.5 729a770d6c mesh: divide a block edge that bends with a curved target by its length
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
120 changed files with 12547 additions and 496 deletions

No files matched your search

+2 -1
View File
@@ -24,7 +24,8 @@ body:
id: evidence
attributes:
label: Evidence
description: Screenshots, log output, numbers. For a crash, attach the crash report (`fieldcraft-cli crashes`, then `crashes show <id>`).
description: |
Pictures first: for anything you can see (the view, a mesh, a panel, a plot), drag a screenshot or a short screen recording in here, and mark the problem on it. For a mesh, a section through it (`fieldcraft-cli snapshot … --section`) shows what the outside hides. Then log output and numbers. For a crash, attach the crash report (`fieldcraft-cli crashes`, then `crashes show <id>`).
- type: textarea
id: expected
attributes:
+5
View File
@@ -21,6 +21,11 @@ body:
attributes:
label: Expected behaviour
description: What FieldCraft should do, as precisely as you can.
- type: textarea
id: pictures
attributes:
label: Pictures
description: A screenshot of where it would go, a sketch, or a picture of the result you are after. Drag images in here.
- type: textarea
id: reference
attributes:
+4
View File
@@ -2,10 +2,14 @@ Closes #
## What and why
## Before and after
<!-- For any visible change (the view, meshes, panels, results, icons): a picture before and after. Drag them in here. -->
## How it was verified
<!-- Tests added or changed; oracle cases (tests/oracle) for solver and meshing results, with their cited references. -->
## Checklist
- [ ] The issue was assigned to me before I started (see CONTRIBUTING.md)
- [ ] `cargo fmt --all`, `cargo clippy -p <crates I touched> --all-targets -- -D warnings` and their tests pass
- [ ] Before/after pictures for any visible change
- [ ] No code, text or data from GPL-family projects or commercial CAE products
+5 -5
View File
@@ -57,7 +57,7 @@ jobs:
plan:
name: Plan (affected crates)
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
runs-on: ubuntu-24.04
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 15
permissions:
contents: read
@@ -117,7 +117,7 @@ jobs:
ci:
name: cargo xtask ci
needs: plan
runs-on: ubuntu-24.04
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -149,7 +149,7 @@ jobs:
name: cargo xtask test --heavy
needs: plan
if: github.event_name != 'pull_request' && (needs.plan.outputs.solver == 'true' || needs.plan.outputs.everything == 'true')
runs-on: ubuntu-24.04
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -168,7 +168,7 @@ jobs:
name: cargo xtask oracle
needs: plan
if: needs.plan.outputs.solver == 'true' || needs.plan.outputs.everything == 'true'
runs-on: ubuntu-24.04
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -219,7 +219,7 @@ jobs:
advisories:
name: Dependency licences, advisories and sources
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
runs-on: ubuntu-24.04
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+2
View File
@@ -30,6 +30,7 @@ is committed and listed here with its exports.
| docs/media/examples/impact.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved impact sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/ibeam.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved ibeam sample (docs/examples.md) | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/ibeam.png | FieldCraft contributors | Original: the solved ibeam sample rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the ibeam sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/heated-flow.png | FieldCraft contributors | Original: the heated-flow sample's temperature slice rendered directly at 320 × 213 by `fieldcraft-cli snapshot` with the CPU renderer; geometry is the original upright-bracket recipe | MIT OR Apache-2.0 |
| docs/media/examples/parts.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the parts sample (meshes only, nothing to solve) (docs/examples.md) | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/flow-past.png | FieldCraft contributors | Original: the flow-past sample as docs/media/examples/flow-past.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the flow-past sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/cantilever.png | FieldCraft contributors | Original: the cantilever sample as docs/media/examples/cantilever.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the cantilever sample's thumbnail on the Start card | MIT OR Apache-2.0 |
@@ -96,6 +97,7 @@ is committed and listed here with its exports.
| assets/icons/mesh.node.add.svg, assets/icons/mesh.node.add.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.add.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.node.merge.svg, assets/icons/mesh.node.merge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.merge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.node.relax.svg, assets/icons/mesh.node.relax.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.relax.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.node.move.svg, assets/icons/mesh.node.move.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.move.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.order.quadratic.svg, assets/icons/mesh.order.quadratic.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.order.quadratic.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.quad.faces.svg, assets/icons/mesh.quad.faces.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quad.faces.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.quality.svg, assets/icons/mesh.quality.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
+10 -1
View File
@@ -68,6 +68,13 @@ Every change starts from an issue, and only the maintainer merges.
Issues labelled **in progress** are being worked on by the core team; please don't start on them.
## Reporting a problem or an idea
Use the templates under **New issue**. Show it: for anything you can see — the view, a mesh, a
panel, a plot — add a screenshot or a short screen recording (drag it into the issue) and mark
the problem on it. For a mesh, a section (`fieldcraft-cli snapshot … --section`) shows what the
outside hides. A picture is often the fastest way for someone else to see what you see.
## Making the change
1. Branch from `develop` (the trunk; `main` holds released code only) and open the pull request
@@ -78,7 +85,9 @@ Issues labelled **in progress** are being worked on by the core team; please don
3. User-visible behaviour is a command (`crates/engine/src/cmd/`) with tests, including hostile
parameters.
4. Meshing and solver algorithms cite the paper they come from in the module docs.
5. Never contribute code, text or data from GPL-family projects or commercial CAE products
5. A change you can see (the view, meshes, panels, results, icons) shows a picture before and after
in the pull request.
6. Never contribute code, text or data from GPL-family projects or commercial CAE products
(see [docs/adr/0001-libraries.md](docs/adr/0001-libraries.md)).
By contributing you agree your work is licensed under MIT OR Apache-2.0.
Generated
+43 -27
View File
@@ -1264,13 +1264,14 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "fieldcraft"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
"embed-resource",
"fieldcraft-engine",
"fieldcraft-io",
"fieldcraft-lbm-gpu",
"fieldcraft-ui-egui",
"fieldcraft-update",
"getrandom 0.4.3",
@@ -1285,11 +1286,12 @@ dependencies = [
[[package]]
name = "fieldcraft-cli"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"fieldcraft-engine",
"fieldcraft-gpu",
"fieldcraft-io",
"fieldcraft-lbm-gpu",
"fieldcraft-mcp",
"fieldcraft-render",
"fieldcraft-update",
@@ -1299,7 +1301,7 @@ dependencies = [
[[package]]
name = "fieldcraft-decks"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"fieldcraft-model",
"serde_json",
@@ -1308,7 +1310,7 @@ dependencies = [
[[package]]
name = "fieldcraft-engine"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"fieldcraft-decks",
"fieldcraft-explicit",
@@ -1327,12 +1329,12 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"zip 9.0.3",
"zip 9.0.4",
]
[[package]]
name = "fieldcraft-explicit"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"fieldcraft-fem",
"fieldcraft-model",
@@ -1343,7 +1345,7 @@ dependencies = [
[[package]]
name = "fieldcraft-fem"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"fieldcraft-model",
"thiserror 2.0.21",
@@ -1351,7 +1353,7 @@ dependencies = [
[[package]]
name = "fieldcraft-geometry"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"fieldcraft-model",
"quick-xml 0.42.0",
@@ -1363,12 +1365,12 @@ dependencies = [
"solidcraft-io",
"solidcraft-kernel",
"thiserror 2.0.21",
"zip 9.0.3",
"zip 9.0.4",
]
[[package]]
name = "fieldcraft-gpu"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"fieldcraft-render",
"log",
@@ -1380,7 +1382,7 @@ dependencies = [
[[package]]
name = "fieldcraft-io"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"fieldcraft-model",
"log",
@@ -1388,12 +1390,12 @@ dependencies = [
"serde_json",
"sha2 0.11.1",
"thiserror 2.0.21",
"zip 9.0.3",
"zip 9.0.4",
]
[[package]]
name = "fieldcraft-lbm"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"fieldcraft-mesh",
"fieldcraft-model",
@@ -1401,9 +1403,22 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "fieldcraft-lbm-gpu"
version = "0.7.0"
dependencies = [
"fieldcraft-gpu",
"fieldcraft-lbm",
"fieldcraft-mesh",
"fieldcraft-model",
"log",
"pollster 1.0.1",
"wgpu",
]
[[package]]
name = "fieldcraft-mcp"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"fieldcraft-engine",
"fieldcraft-gpu",
@@ -1415,9 +1430,10 @@ dependencies = [
[[package]]
name = "fieldcraft-mesh"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"fieldcraft-model",
"fieldcraft-parallel",
"libm",
"num-bigint",
"num-rational",
@@ -1428,7 +1444,7 @@ dependencies = [
[[package]]
name = "fieldcraft-model"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"serde",
"serde_json",
@@ -1437,14 +1453,14 @@ dependencies = [
[[package]]
name = "fieldcraft-parallel"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"rayon",
]
[[package]]
name = "fieldcraft-render"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"fieldcraft-model",
"png",
@@ -1454,7 +1470,7 @@ dependencies = [
[[package]]
name = "fieldcraft-solver"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"fieldcraft-fem",
"fieldcraft-mesh",
@@ -1466,7 +1482,7 @@ dependencies = [
[[package]]
name = "fieldcraft-testkit"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"fieldcraft-engine",
"fieldcraft-geometry",
@@ -1478,7 +1494,7 @@ dependencies = [
[[package]]
name = "fieldcraft-ui-egui"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"craft-ui",
"egui",
@@ -1500,7 +1516,7 @@ dependencies = [
[[package]]
name = "fieldcraft-update"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"flate2",
"log",
@@ -1512,12 +1528,12 @@ dependencies = [
"tar",
"thiserror 2.0.21",
"ureq",
"zip 9.0.3",
"zip 9.0.4",
]
[[package]]
name = "fieldcraft-web"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64",
"eframe",
@@ -5410,7 +5426,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"serde_json",
]
@@ -5563,9 +5579,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.0.3"
version = "9.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d98561b612a84ae525331a7a094429ef8a5772514ed507ff6db5f54badc971"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
dependencies = [
"crc32fast",
"flate2",
+2 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.6.0"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -29,6 +29,7 @@ fieldcraft-ui-egui = { path = "crates/ui-egui" }
fieldcraft-mcp = { path = "crates/mcp" }
fieldcraft-update = { path = "crates/update" }
fieldcraft-gpu = { path = "crates/gpu" }
fieldcraft-lbm-gpu = { path = "crates/lbm-gpu" }
# foundation
thiserror = "2"
+9 -2
View File
@@ -38,7 +38,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned |
| M10 Explicit dynamics | Shared element and material kernels with the implicit solver (`fem`); central-difference solver with lumped mass, stable time step, one-point hex8 with hourglass control and nodal-pressure tet4, bulk viscosity, energy accounting (X1 core: done — `fieldcraft-explicit`, `explicit_dynamic` steps with initial velocities, prescribed motion and planar rigid walls, `solve.explicit`/`solve.history`, small-strain linear elastic; bar wave, rigid-wall impact, energy balance and cantilever-period tests); large-deformation materials (X2: done — updated Lagrangian elements, Jaumann/Hughes–Winget and Green–Naghdi/Flanagan–Taylor rates, J2 bilinear/table plasticity, Johnson–Cook strength with heating and damage, erosion; materials gain `plastic`, `damage`, `erosion` with `model.material.plasticity`/`damage`; Dienes shear, rigid rotation, J2 uniaxial and Taylor-bar tests); contact (X3: done — `interaction.contact.pair`/`general`/`find`, segment-to-segment penalty contact both ways with polygon clipping (passes the contact patch test on non-matching meshes), pairs, general (all exterior faces) and self contact, bucket-grid search, deterministic on any thread count, a penalty from the masses and the stable increment that never shortens it, Coulomb friction with exponential decay by a return map, friction on rigid walls, contact energy and friction dissipation in the balance, initial penetrations adjusted or refused; Abaqus `*CONTACT PAIR`/`*CONTACT`/`*FRICTION` and LS-DYNA automatic contacts in the decks; verified by colliding bars, sliding blocks, the patch test and Hertz's sphere impact); hyperelastic materials (neo-Hookean, Mooney–Rivlin, Yeoh) on the shared material point with average nodal pressure tets, and opt-in mass scaling with the added mass reported: done; runs on hex and tet meshes | started |
| M11 Equations of state and shells | Polynomial, ideal-gas, Mie–Grüneisen, JWL with programmed burn, tabulated; energy coupling; shock verification (Rankine–Hugoniot, Sod, Noh, Sedov); Belytschko–Lin–Tsay shells with self-contact on quad meshes | planned |
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark) | started |
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark; results in the viewport and headless pictures: velocity or pressure on a slice, the obstacles coloured by the flow beside them and streamlines seeded upstream, `results.slice`, `results.surface`, `results.streamlines`: done) | started |
| M13 Fluid–structure interaction | Immersed-boundary coupling of the lattice Boltzmann fluid with the explicit or implicit structure; interface force transfer; sub-cycling; Aitken then interface quasi-Newton coupling; free-surface flow | planned |
| M14 Blast and impact fluids | EOS-driven fluids in the explicit solver: ALE multi-material or SPH, with fluid–structure contact | planned |
| M15 Nonlinear implicit core | Newton–Raphson with line search, automatic increments and cutbacks, convergence on force, displacement and energy; geometric nonlinearity (total and updated Lagrangian, large rotations for shells and beams, follower pressure); every `fem` material behind one interface with its consistent tangent (plasticity, damage and thermal strain from the explicit solver reused implicitly); arc-length (Riks) for snap-through; linear buckling (eigen) with preload stress stiffening, and imperfections from buckling modes; nonlinear stabilisation; restart. Verified by the elastica, Lee's frame, the snap-through arch, the hinged cylindrical shell and plastic collapse loads (solids core: done — `nonlinear_static` steps with automatic increments and cutbacks, Newton–Raphson with the consistent tangent, force, displacement and energy convergence and an optional line search; a material point interface (Kirchhoff stress and spatial tangent from F, with history) with St Venant–Kirchhoff and compressible neo-Hookean; total Lagrangian tet4, tet10, wedge6, hex8, hex20 (full and reduced), hybrid hex8/hex20/tet10 by the three-field mixed form, plane strain and axisymmetric elements; follower pressure with its symmetrised load stiffness; Cauchy stress and logarithmic strain, increment history and frames, `solve.nonlinear`; Abaqus and LS-DYNA decks; verified by the patch and objectivity tests, uniaxial stretch, the elastica and tube inflation by a follower pressure (finite-strain Cook's membrane pending its transcribed reference; J2 plasticity on the interface: Simo's exponential return map at finite strain and the radial return at small strain, both with consistent tangents and the return shared with explicit dynamics, bilinear isotropic and kinematic (small strain), tabulated and Johnson–Cook (rate-independent) hardening, hybrid elements included, load paths for cyclic loading, a tangent predictor for path-dependent materials, verified by uniaxial tension to large strain, Hill's thick cylinder (plastic zone and collapse pressure), a cantilever's collapse and the Bauschinger effect, with quadratic Newton convergence; Mooney–Rivlin and Yeoh options (decoupled, hybrid elements), verified in Treloar's three modes and by a stretched tube's inflation; thermal strain by the multiplicative split at finite strain and the additive one at small strain, with temperatures from temperature loads or the step's own conduction, verified by free and constrained expansion and a heated bar held past yield. a supernodal symmetric indefinite LDLᵀ factorization (bounded Bunch–Kaufman within supernodes, inertia) on which Newton continues through indefinite tangents, so nearly incompressible hybrid elements (ν = 0.4999) converge under prescribed displacement; linear buckling steps (linear preload, initial-stress stiffness of solids and 2D elements, block Lanczos in the K inner product, the shifted buckling transformation on LDLᵀ with an inertia count, `solve.buckle`, Abaqus `*BUCKLE`), verified by Euler columns and Bryan's plate; imperfections from buckling modes; arc-length (Riks–Crisfield, cylindrical or spherical) with limit and bifurcation points from the tangent's inertia, verified by the snap-through of a shallow arch. Next: Lee's frame and the hinged cylindrical shell (with shells), the follower pressure's full tangent (LU), incompatible modes (EAS), kinematic hardening at finite strain, shells and beams) | started |
@@ -50,7 +50,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M21 Biomechanics and biomaterials | Soft tissue: Holzapfel–Gasser–Ogden fibre-reinforced hyperelasticity with dispersion, Fung, transversely isotropic models; biphasic and poro-hyperelastic cartilage (M19); active muscle (Hill-type); hydrogel swelling. Bone: density-dependent moduli from CT data, strain-energy-density remodelling (Huiskes, Beaupré). Growth and remodelling by kinematic growth (F = Fe·Fg). Implants and biomaterials (titanium, PEEK, UHMWPE) from the M17 library. Verified against published experiments with their tolerances | planned |
| M22 Design studies | Parameters of the SolidCraft design driven from FieldCraft: parametric sweeps, design of experiments, response surfaces and sensitivities; topology optimisation (SIMP with filters, manufacturing constraints); size and shape optimisation; submodelling (driven boundaries from a global model); uncertainty quantification | planned |
| M23 Wave propagation and ultrasound | Elastic, acoustic and coupled waves from the explicit solver's 1D bar today to guided and ultrasonic waves, by two methods. Finite differences first, for fast 2D (then 3D) ultrasound on regular grids: staggered-grid velocity–stress FDTD (Virieux) and the elastodynamic finite integration technique for solids, the rotated staggered grid for anisotropic media, a k-space pseudospectral scheme for weakly heterogeneous tissue and water, all on grids voxelised from the face-tagged geometry by the LBM's exact voxeliser, with heterogeneous, layered and fluid–solid media, CPML boundaries, threads and the GPU backend; staircased curved boundaries scatter spuriously, so the FD grid is checked against the finite elements on curved parts. Finite elements for curved geometry and coupling with structures: spectral elements (high-order Gauss–Lobatto–Legendre hexes with diagonal mass) on the hex meshes, and a mesh check in elements per wavelength with the dispersion error it implies; absorbing boundaries (convolutional PML, absorbing layers with increasing damping) and infinite media; tone-burst sources (Hann-windowed cycles), point, line and surface excitation, and piezoelectric transducers (M19); anisotropic media (composites, crystals) and viscoelastic, power-law-in-frequency attenuation; fluid–solid interfaces and fluid-loaded plates; ultrasound in water and tissue, nonlinear acoustics (Westervelt, KZK) for focused ultrasound. Guided waves: dispersion curves by semi-analytical finite elements (SAFE) for plates, pipes, rails and layered and anisotropic laminates — Lamb, Rayleigh, Love and SH modes — with phase and group velocities and mode shapes; mode-selective excitation. Output: A-scans and B-scans at sensors, wavefield snapshots, 2D FFT (frequency–wavenumber) for mode identification, and time–frequency diagrams of any signal — short-time Fourier spectrograms, continuous wavelet transforms (Morlet), reassigned spectrograms and smoothed pseudo Wigner–Ville — with the SAFE or Rayleigh–Lamb group-velocity curves overlaid as arrival times, and group velocities extracted from the ridges. GPU paths (M12's backend) for long runs. Verified by Lamb's problem (point load on a half-space), Rayleigh-wave speed, Rayleigh–Lamb dispersion, Love waves in a layer over a half-space, reflection and transmission at interfaces, and published guided-wave measurements | planned |
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 26 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 9 published benchmarks (MacNeal–Harder solid tests, Scordelis–Lo with solids, NAFEMS LE1 in 3D and 2D, LE10, FV32) and 9 pending cases with citations (shells, thermal, static plasticity, Taylor bar, CFD); `mesh.block` mapped blocks and 2D patches build their meshes | started |
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 62 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 11 published benchmarks (the MacNeal–Harder solid tests, the Scordelis–Lo roof with solids and with shells, the pinched cylinder, NAFEMS LE1 in 3D and 2D, LE10, FV32) and 8 pending cases with citations (the pinched hemisphere and twisted beam shell meshes, thermal, Cook's membrane, Taylor bar, CFD, explicit Hertz contact); `mesh.block` mapped blocks and 2D patches build their meshes, curved shell patches are written node by node | started |
| Continuous: performance | CPU first, deterministic: results never depend on the thread count (fixed partitions, sums in a fixed order; checked by hashing results on 1, 2 and 7 threads). Supernodal left-looking Cholesky (11–15× faster than the column solver on one thread), parallel element matrices, stresses and Lanczos products, explicit element updates and LBM cell blocks on rayon threads (done — `fieldcraft-parallel`, `threads` on the solve commands, `FIELDCRAFT_THREADS`); parallel forward/back substitution and explicit tet4 assembly; GPU paths later, within a stated tolerance of the CPU | started |
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve, and an examples gallery of ready-to-solve samples, each checked by a test (docs/examples.md): done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; symbols as wireframes, one mark per constrained degree of freedom, a constant size on screen with a size setting, highlighted with their selected definition, concentrated loads drawn over the part: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done; in-app updates — a daily check of GitHub Releases, Help › Check for Updates and Reinstall, downloads verified against the release key's signature of SHA256SUMS.txt and swapped in after closing, `app.update.check`, `fieldcraft-cli update`, Reload to update in the browser, docs/updates.md: done), command schema as the public API | started |
@@ -123,3 +123,10 @@ What exists and what is missing, by area; each gap is in a milestone above.
| Fluid dynamics | LBM D3Q19 TRT, walls, inlets/outlets, obstacle forces | Turbulence with wall models, thermal and conjugate heat transfer, non-Newtonian, porous media, multiphase, GPU (M12, M13) |
| Wave propagation | One-dimensional elastic waves and impact in the explicit solver (bar cases in the oracle) | A 2D/3D finite-difference ultrasound solver (velocity–stress FDTD, EFIT, k-space pseudospectral) on voxelised geometry, time–frequency diagrams (spectrogram, wavelet, Wigner–Ville) with dispersion overlays, spectral elements, absorbing boundaries, tone-burst and transducer sources, attenuation, Lamb/Rayleigh/Love guided waves and SAFE dispersion curves, ultrasound in fluids and tissue, nonlinear acoustics, A/B-scans and frequency–wavenumber analysis (M23) |
| Design | — | Parametric studies on SolidCraft parameters, optimisation, submodelling (M22) |
### Heated flow results (#157)
Temperature is available on fluid slices, obstacle surfaces and streamline colours when the
saved frames carry it. Signed face-average wall heat follows recorded histories and the
model's power/area units. Commands, the Results panel and headless pictures share the view;
initial frames without recorded wall heat remain uncoloured.
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
fieldcraft-engine = { workspace = true }
fieldcraft-lbm-gpu = { workspace = true }
fieldcraft-gpu = { workspace = true }
fieldcraft-render = { workspace = true }
fieldcraft-io = { workspace = true }
+2
View File
@@ -63,6 +63,8 @@ usage:
const MAX_SCRIPT_BYTES: u64 = 256 * 1024 * 1024;
fn main() -> ExitCode {
// The lattice Boltzmann GPU backend, for `solve.fluid` with `backend: "gpu"` (scripts and MCP).
fieldcraft_lbm_gpu::register();
let args: Vec<String> = std::env::args().skip(1).collect();
let result = match args.first().map(String::as_str) {
Some("commands") if args.get(1).map(String::as_str) == Some("--json") => {
+1
View File
@@ -21,6 +21,7 @@ neg_cmp_op_on_partial_ord = "allow"
[dependencies]
fieldcraft-engine = { workspace = true }
fieldcraft-lbm-gpu = { workspace = true }
fieldcraft-ui-egui = { workspace = true }
fieldcraft-io = { workspace = true }
fieldcraft-update = { workspace = true }
+2
View File
@@ -224,6 +224,8 @@ fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>)
}
fn main() -> eframe::Result {
// The lattice Boltzmann GPU backend, for `solve.fluid` with `backend: "gpu"`.
fieldcraft_lbm_gpu::register();
let args = match parse_args(std::env::args().skip(1), std::env::var("FIELDCRAFT_CONTROL_PORT").ok()) {
Ok(Some(a)) => a,
Ok(None) => return Ok(()),
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>mesh.node.move</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M5 4 L19 4 C19.552 4 20 4.448 20 5 L20 19 C20 19.552 19.552 20 19 20 L5 20 C4.448 20 4 19.552 4 19 L4 5 C4 4.448 4.448 4 5 4 Z" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 12 L20 12 M12 4 L12 20" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 12 L15 9 L20 12 M12 4 L15 9 L12 20" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.2 9 C13.2 8.006 14.006 7.2 15 7.2 C15.994 7.2 16.8 8.006 16.8 9 C16.8 9.994 15.994 10.8 15 10.8 C14.006 10.8 13.2 9.994 13.2 9 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"rect": [4, 4, 16, 16], "radius": 1, "stroke": "line", "width": 1.5}, {"d": "M4 12 L20 12 M12 4 L12 20", "stroke": "line", "width": 1.5}, {"d": "M4 12 L15 9 L20 12 M12 4 L15 9 L12 20", "stroke": "accent", "width": 1.5}, {"circle": [15, 9, 1.8], "fill": "accent", "stroke": "accent"}]}
+32 -4
View File
@@ -18,9 +18,15 @@ const PROMPT: Duration = Duration::from_secs(5);
/// (mid-run), or before it starts (`None`): it must fail with `cancelled`, soon after, leaving
/// the model and the journal as they were.
fn cancel(s: &mut Session, command: &str, params: Value, stage: Option<&str>) {
cancel_when(s, command, params, stage.map(|stage| move |now: &progress::State| now.stage.starts_with(stage) && now.done > 0.0));
}
/// [`cancel`] once the reported state passes `when` (before it starts with `None`).
fn cancel_when(s: &mut Session, command: &str, params: Value, when: Option<impl Fn(&progress::State) -> bool>) {
let (model, journal) = (s.model.clone(), s.journal.len());
let design = s.design.as_ref().map(|d| d.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>());
let p = Progress::new();
if stage.is_none() {
if when.is_none() {
p.cancel();
}
let (r, waited) = std::thread::scope(|scope| {
@@ -31,18 +37,18 @@ fn cancel(s: &mut Session, command: &str, params: Value, stage: Option<&str>) {
scope.spawn(move || progress::scoped(Some(p), || s.execute(command, &params)))
};
let mut at = Instant::now();
if let Some(stage) = stage {
if let Some(when) = &when {
let deadline = Instant::now() + Duration::from_secs(600);
while !worker.is_finished() && Instant::now() < deadline {
let now = p.snapshot();
if now.stage.starts_with(stage) && now.done > 0.0 {
if when(&now) {
at = Instant::now();
p.cancel();
break;
}
std::thread::sleep(Duration::from_millis(1));
}
assert!(p.is_cancelled(), "{command}: never reached stage `{stage}` ({:?})", p.snapshot());
assert!(p.is_cancelled(), "{command}: never reached the stage ({:?})", p.snapshot());
}
let r = worker.join().unwrap();
(r, at.elapsed())
@@ -53,6 +59,7 @@ fn cancel(s: &mut Session, command: &str, params: Value, stage: Option<&str>) {
assert!(waited < PROMPT, "{command}: stopped {waited:?} after the cancel");
assert!(s.model == model, "{command}: the model changed");
assert_eq!(s.journal.len(), journal, "{command}: journalled");
assert_eq!(s.design.as_ref().map(|d| d.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>()), design, "{command}: the design changed");
}
/// A sample's script run on a fresh session.
@@ -128,3 +135,24 @@ fn meshers_stop_and_leave_no_mesh() {
cancel(&mut s, "mesh.hex.octree", json!({"body": "Body1", "size": 5.0, "max_depth": 2}), None);
assert!(s.model.nodes().is_empty());
}
/// The partition, the cells mesher and the plan's apply (#545) stop at their stages: between
/// the kernel's cuts, between cells, between the projection's phases.
#[test]
fn partition_cells_and_plan_apply_stop_and_change_nothing() {
let (_dir, mut s) = plate();
s.execute("geometry.datum", &json!({"name": "X30", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap();
s.execute("geometry.datum", &json!({"name": "Y5", "by": {"type": "offset", "plane": "zx", "offset": 5}})).unwrap();
let plan = s.execute("mesh.plan", &json!({"body": "Body1"})).unwrap();
cancel(&mut s, "mesh.plan.apply", json!({"plan": plan["name"]}), None);
cancel_when(&mut s, "mesh.plan.apply", json!({"plan": plan["name"]}), Some(|now: &progress::State| now.stage.starts_with("Projection: ")));
cancel(&mut s, "geometry.partition", json!({"name": "Half", "datum": "X30"}), None);
s.execute("geometry.partition", &json!({"name": "Half", "datum": "X30"})).unwrap();
// The second partition cuts again by both: cancelled once the first cut is made.
cancel(&mut s, "geometry.partition", json!({"name": "Strip", "datum": "Y5"}), Some("Partition cuts"));
let cells = json!({"bodies": ["Body1/a", "Body1/b"], "default": "tet", "size": 0.5});
cancel(&mut s, "mesh.cells", cells.clone(), None);
// Inside a cell's tet mesher: the cancel reaches the command, never a fallback.
cancel(&mut s, "mesh.cells", cells, Some("Refinement round"));
assert!(s.model.nodes().is_empty());
}
+25 -4
View File
@@ -437,6 +437,13 @@ pub static CATALOGUE: &[Capability] = &[
"Relax the nodes inside a sphere to fix spikes and slivers; surface nodes slide on the geometry; kept as a field to apply again after a remesh",
&["mesh.node.relax.apply"],
),
ready(
"mesh.node.move",
"Move Nodes",
Mesh,
"Improve",
"Drag nodes of the mesh, the nodes round them following by a falloff or harmonically, on the geometry; the original drawn as a ghost and the elements coloured by how their quality changes",
),
ready_more("mesh.node.add", "Node", Mesh, "Edit", "Add a node", &["mesh.node.remove"]),
ready_more("mesh.element.add", "Element", Mesh, "Edit", "Add an element on existing nodes", &["mesh.element.remove", "mesh.clear"]),
ready("mesh.mirror", "Mirror Mesh", Mesh, "Edit", "Mirror a body's mesh across a plane, joined on the plane (mesh half a symmetric part)"),
@@ -543,12 +550,13 @@ pub static CATALOGUE: &[Capability] = &[
"The box of fluid cells, its fluid, and the obstacle bodies voxelised from their face-tagged surfaces",
&["model.fluid_domain.edit", "model.fluid_domain.delete"],
),
ready(
ready_more(
"model.fluid_domain.boundary",
"Inlet & Outlet",
Loads,
"Flow",
"Velocity inlets, pressure outlets, walls, moving and slip walls, periodic faces",
"Velocity inlets, pressure outlets, walls, moving and slip walls, periodic faces; held temperatures, heat fluxes and adiabatic faces",
&["model.fluid_domain.thermal_boundary"],
),
// ---- Interactions ----
ready_more(
@@ -649,7 +657,13 @@ pub static CATALOGUE: &[Capability] = &[
"Transient dynamics of an explicit step by central differences (contact and erosion coming in M10)",
),
soon("solve.blast", "Blast & Impact", Solve, "Explicit", "M14", "EOS-driven fluids with fluid–structure contact (ALE or SPH)"),
ready("solve.fluid", "Lattice Boltzmann", Solve, "Fluids", "Lattice Boltzmann flow of a fluid step on the CPU, with wall forces"),
ready(
"solve.fluid",
"Lattice Boltzmann",
Solve,
"Fluids",
"Lattice Boltzmann flow of a fluid step on the CPU or a GPU, with wall forces, and the heat it carries",
),
soon("solve.fsi", "Fluid–Structure", Solve, "Fluids", "M13", "Coupled fluid and structure solve"),
// ---- Results ----
ready_more("results.deformed", "Deformed", Results, "Display", "The deformed shape of a solved step, to scale", &["results.clear"]),
@@ -677,7 +691,14 @@ pub static CATALOGUE: &[Capability] = &[
soon("results.path", "Path Plot", Results, "Plots", "M9", "Plot a quantity along a path"),
soon("results.cut", "Cut", Results, "Plots", "M9", "Section cuts through the model"),
soon("results.display_group", "Display Group", Results, "Plots", "M9", "Show and hide parts of the model"),
soon("results.streamlines", "Streamlines", Results, "Plots", "M12", "Streamlines and flow fields"),
ready_more(
"results.streamlines",
"Streamlines",
Results,
"Plots",
"A fluid step's velocity, pressure or temperature on slices and obstacles, speed or temperature along streamlines, and signed wall heat per area",
&["results.slice", "results.surface"],
),
ready("results.export.vtu", "Export VTU", Results, "Files", "Write a solved step as VTK unstructured grids for ParaView"),
soon("results.read", "Read Results", Results, "Files", "M9", "Read results in public formats"),
soon("results.convergence", "Convergence", Results, "Files", "M9", "Mesh convergence studies"),
+12
View File
@@ -201,6 +201,8 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
if members.len() < 2 {
continue;
}
fieldcraft_model::progress::stage(&format!("Cells: sweeping {source}'s cells together"), None, "");
fieldcraft_model::progress::check()?;
let inputs: Vec<Input> = members
.iter()
.filter_map(|&i| shapes.get(i))
@@ -227,6 +229,9 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
if joint.get(i).copied().flatten().is_some() {
continue;
}
fieldcraft_model::progress::stage(&format!("Cells: meshing {}", shape.name), Some(shapes.len() as f64), "cells");
fieldcraft_model::progress::count(i as f64);
fieldcraft_model::progress::check()?;
let method = method_of(&shape.name);
let input = || Ok::<_, EngineError>(Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? });
let hex = |m: HexMethod| -> Result<(Input<'static>, HexMesh)> {
@@ -246,6 +251,8 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
let info = json!({"method": mesh.report.method.name()});
(Meshed::Hex(Box::new((b, mesh))), info)
}
// A cancelled job stops; any other failure falls back to tet.
Err(e) if e.is_cancelled() => return Err(e),
Err(e) => (Meshed::Tet, json!({"method": "tet", "why": e.to_string()})),
},
};
@@ -301,6 +308,9 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
}
for (i, mut info) in tet_cells {
let Some(shape) = shapes.get(i) else { continue };
fieldcraft_model::progress::stage(&format!("Cells: meshing {} (tet)", shape.name), Some(shapes.len() as f64), "cells");
fieldcraft_model::progress::count(i as f64);
fieldcraft_model::progress::check()?;
let input = Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? };
let pyramids = if transition == Transition::Tie {
Err("`transition` is \"tie\"".to_string())
@@ -348,6 +358,8 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
.filter(|r| r.get("kind").and_then(Value::as_str).is_some_and(|k| k.starts_with("tet")))
.filter_map(|r| r.get("body").and_then(Value::as_str).map(String::from))
.collect();
fieldcraft_model::progress::stage("Cells: joining the cells", None, "");
fieldcraft_model::progress::check()?;
interfaces.extend(join(model, &names, &tet, COINCIDENT * diagonal)?);
explain_ties(&design, &report, &mut interfaces);
let (nodes, elements) = (model.nodes().len(), model.elements().len());
+4 -1
View File
@@ -527,7 +527,10 @@ fn partition(s: &mut Session, p: &Value) -> Result<Value> {
let cells: Vec<String> = design.bodies.iter().map(|b| b.name.clone()).filter(|b| split.iter().any(|x| b.starts_with(&format!("{x}/")))).collect();
let (faces, bodies): (usize, Vec<String>) = (report.map_or(0, |r| r.faces), design.bodies.iter().map(|b| b.name.clone()).collect());
let mut warnings: Vec<String> = report.map(|r| r.warnings.clone()).unwrap_or_default();
for cell in &cells {
fieldcraft_model::progress::stage("Partition: checking the cells", Some(cells.len() as f64), "cells");
for (k, cell) in cells.iter().enumerate() {
fieldcraft_model::progress::count(k as f64);
fieldcraft_model::progress::check()?;
if let Some(why) = design.body(cell).and_then(|shape| unmeshable(shape, None).err()) {
warnings.push(format!(
"partition `{name}`: cell `{cell}` has a boundary no mesher takes ({why}): the plane passes within the kernel's tolerance of a face, edge or vertex it cut (along a face, through the axis of a hole); move the plane off it (by a fraction of the element size)"
+20 -2
View File
@@ -8,7 +8,7 @@
use fieldcraft_model::{
BoundaryCondition, Coupling, FaceCondition, FluidBoundary, FluidDomain, Load, Material, Model, PointMass, ReferencePoint, Section, Set, Spring,
Step,
Step, ThermalCondition,
};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
@@ -288,7 +288,7 @@ definition_commands!(
FluidDomain,
"fluid_domain",
"Fluid Domain",
r#"{"name": "Flow", "material": "Water" (a density and fluid behaviour), "min": [x, y, z], "max": [x, y, z], "spacing": 1.0 (cubic cells; every extent a whole number of spacings), "faces": {"x_min": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"}, "x_max": {"type": "pressure", "pressure": 0}, "y_min": {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w] (in the face's plane)} | {"type": "slip"} | {"type": "periodic"} (in opposite pairs), …} (default walls), "obstacles": ["Body1"] (solid bodies of the design or imported surfaces, by name), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "moving_wall", "velocity": [u, v, w]}}] (obstacle faces are walls unless listed), "initial_velocity": [u, v, w], "acceleration": [x, y, z] (gravity or a driving force per mass), "tolerance": 0.1 (obstacle triangulation; default a tenth of the spacing)}"#,
r#"{"name": "Flow", "material": "Water" (a density and fluid behaviour), "min": [x, y, z], "max": [x, y, z], "spacing": 1.0 (cubic cells; every extent a whole number of spacings), "faces": {"x_min": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"}, "x_max": {"type": "pressure", "pressure": 0}, "y_min": {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w] (in the face's plane)} | {"type": "slip"} | {"type": "periodic"} (in opposite pairs), …} (default walls), "obstacles": ["Body1"] (solid bodies of the design or imported surfaces, by name), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "moving_wall", "velocity": [u, v, w]}}] (obstacle faces are walls unless listed), "initial_velocity": [u, v, w], "acceleration": [x, y, z] (gravity or a driving force per mass), "tolerance": 0.1 (obstacle triangulation; default a tenth of the spacing), "thermal": {"initial_temperature": 20, "gravity": [0, -9810, 0], "expansion": 2.1e-4 (β per temperature; buoyancy needs both, Boussinesq), "faces": {"y_min": {"type": "temperature", "value": 80} | {"type": "flux", "value": 1000 (heat per area into the fluid)} | {"type": "adiabatic"}, …} (default adiabatic; a velocity inlet carries its held temperature in, a pressure outlet lets heat leave), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "temperature", "value": 80}}] (obstacle faces are adiabatic unless listed)} (heat in the fluid, carried by the flow and diffused with the material's conductivity and specific heat; left out, the flow is isothermal)}"#,
super::forms::FLUID_DOMAIN,
super::forms::FLUID_DOMAIN_DELETE,
add_fluid_domain,
@@ -319,6 +319,24 @@ fn fluid_boundary(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"domain": domain}))
}
pub const FLUID_THERMAL_BOUNDARY: CommandSpec = CommandSpec {
id: "model.fluid_domain.thermal_boundary",
label: "Temperature or Heat Flux",
params: r#"{"domain": "Flow", "at": "x_min" (a box face) | {"body": "Body1", "face": "F2:top"} (an obstacle face), "condition": {"type": "temperature", "value": 80} | {"type": "flux", "value": 1000 (heat per area into the fluid)} | {"type": "adiabatic"}} — sets one boundary's thermal condition; the domain needs `thermal` settings (model.fluid_domain.edit)"#,
form: Some(super::forms::FLUID_THERMAL_BOUNDARY),
kind: CommandKind::Model,
requires: &[Requirement::FluidDomain],
run: fluid_thermal_boundary,
};
fn fluid_thermal_boundary(s: &mut Session, p: &Value) -> Result<Value> {
let domain: &str = req(p, "domain")?;
let at: FluidBoundary = req(p, "at")?;
let condition: ThermalCondition = req(p, "condition")?;
s.model.set_fluid_thermal_boundary(domain, at, condition)?;
Ok(json!({"domain": domain}))
}
pub const STEP_CREATE: CommandSpec = CommandSpec {
id: "model.step.create",
label: "New Step",
+142 -1
View File
@@ -608,10 +608,59 @@ const SURFACE_CONDITION: &[Variant] = &[
),
];
const THERMAL_CONDITION: &[Variant] = &[
variant("adiabatic", "Adiabatic", &[]),
variant("temperature", "Temperature", &[num("value", "Temperature", D::Temperature).help("The temperature held at the boundary.").req()]),
variant("flux", "Heat flux", &[num("value", "Heat flux", D::HeatFlux).help("Heat per area into the fluid (negative out of it).").req()]),
];
const fn face(key: &'static str, label: &'static str) -> Field {
variants(key, label, FACE_CONDITION).hint("wall")
}
const fn thermal_face(key: &'static str, label: &'static str) -> Field {
variants(key, label, THERMAL_CONDITION).hint("adiabatic")
}
const FLUID_THERMAL: Field = group(
"thermal",
"Heat",
&[
num("initial_temperature", "Initial temperature", D::Temperature)
.help("The temperature everywhere at the start, and the reference of the buoyancy.")
.def("0"),
xyz("gravity", "Gravity", D::Acceleration).help("Gravity for the buoyancy; zero for none. Needs the expansion coefficient."),
positive("expansion", "Expansion β", D::Expansion).help("Volumetric thermal expansion coefficient of the fluid, for the buoyancy."),
group(
"faces",
"Box faces",
&[
thermal_face("x_min", "X min").help("Thermal condition on the box face at the smallest X."),
thermal_face("x_max", "X max").help("Thermal condition on the box face at the largest X."),
thermal_face("y_min", "Y min").help("Thermal condition on the box face at the smallest Y."),
thermal_face("y_max", "Y max").help("Thermal condition on the box face at the largest Y."),
thermal_face("z_min", "Z min").help("Thermal condition on the box face at the smallest Z."),
thermal_face("z_max", "Z max").help("Thermal condition on the box face at the largest Z."),
],
)
.help("The thermal condition on each face of the box; a velocity inlet carries its held temperature in."),
rows(
"surfaces",
"Obstacle faces",
&[
OBSTACLE_FACE,
variants("condition", "Condition", THERMAL_CONDITION)
.help("The thermal condition on that face.")
.req()
.def(r#"{"type": "adiabatic"}"#),
],
)
.help("Thermal conditions on single obstacle faces; every other obstacle face is adiabatic."),
],
)
.help("Heat carried by the flow: needs the material's conductivity and specific heat. Off for an isothermal flow.")
.hint("isothermal when off");
pub const FLUID_DOMAIN: &[Field] = &[
NAME,
reference("material", "Fluid", C::Material).help("The fluid material: needs a density and a viscosity.").req(),
@@ -649,6 +698,7 @@ pub const FLUID_DOMAIN: &[Field] = &[
positive("tolerance", "Obstacle tolerance", D::Length)
.help("How close a cell must be to an obstacle surface to count as on it.")
.hint("a tenth of the cell size"),
FLUID_THERMAL,
];
pub const FLUID_BOUNDARY: &[Field] = &[
@@ -660,6 +710,15 @@ pub const FLUID_BOUNDARY: &[Field] = &[
variants("condition", "Condition", FACE_CONDITION).help("The flow condition on that boundary.").req().def(r#"{"type": "velocity"}"#),
];
pub const FLUID_THERMAL_BOUNDARY: &[Field] = &[
reference("domain", "Domain", C::FluidDomain).help("The fluid domain to change; it needs heat settings.").req(),
name_or("at", "Boundary", &BOX_FACE, "Obstacle face", OBSTACLE_FACE_FIELDS)
.help("A face of the domain's box, or a named face of an obstacle body.")
.req()
.def("\"x_min\""),
variants("condition", "Condition", THERMAL_CONDITION).help("The thermal condition on that boundary.").req().def(r#"{"type": "temperature"}"#),
];
const PROCEDURE: Field = variants(
"procedure",
"Procedure",
@@ -1427,7 +1486,10 @@ pub const NODE_RELAX: &[Field] = &[
.help("Guarded: each move keeps the worst element around the node from getting worse. Strong: move the nodes of the bad elements and morph the mesh round them, nodes on CAD edges held.")
.def("\"guarded\""),
range("below", "Below", D::None, 0.0, 1.0).help("Strong: the elements below this scaled Jacobian are moved.").hint("0.7"),
positive("reach", "Reach", D::Length).help("Strong: nodes this close to a moved node follow it, blended to zero at the reach.").hint("three element lengths"),
positive("reach", "Reach", D::Length).help("Strong: nodes this close to a moved node follow it, blended to zero at the reach.").hint("five element lengths"),
choice("guards", "Keep a morph", &[("valid", "Unless an element inverts"), ("strict", "Only when it improves the elements")])
.help("Strong: valid keeps every morph that inverts no element; strict keeps one only when the worst element it touches improves and no element gets longer.")
.hint("valid"),
int("iterations", "Iterations", 1, 200).help("Guarded: sweeps over the nodes (1 to 100). Strong: morph rounds.").hint("5 (strong: 20)"),
range("strength", "Strength", D::None, 0.0, 1.0).help("How far each node moves toward its relaxed position, 0 to 1.").def("1"),
choice("falloff", "Falloff", &[("smooth", "Smooth"), ("linear", "Linear"), ("none", "None")])
@@ -1437,6 +1499,33 @@ pub const NODE_RELAX: &[Field] = &[
flag("reapply", "Reapply after remesh").help("Apply the field again by itself after each remesh.").def("false"),
];
pub const NODE_MOVE: &[Field] = &[
labels("nodes", "Nodes", LabelOf::Node).help("The nodes to grab: drag a node in the view, or box-select nodes and Use selection.").hint("drag a node"),
face_rows("faces", "Faces").help("Instead of nodes: every node on these CAD faces.").hint("none"),
xyz("center", "Centre", D::Length).help("Instead of nodes: a sphere's centre; every node inside follows by the falloff.").hint("none"),
xyz("by", "Move by", D::Length).help("The displacement of the grabbed nodes; dragging in the view sets it."),
xyz("to", "Move to", D::Length).help("Instead of Move by: where the grabbed nodes' centroid (or the centre) goes.").hint("none"),
positive("radius", "Radius", D::Length)
.help("Nodes this close to a grabbed node follow it, blended to zero at the radius; Alt + scroll over the view changes it.")
.hint("only the grabbed nodes"),
choice("propagate", "Followers", &[("falloff", "Falloff (by distance)"), ("harmonic", "Harmonic (through the mesh)")])
.help("Falloff: a node follows by the falloff of its distance. Harmonic: the move spreads through the element edges as a membrane carries it, zero at the radius.")
.def("\"falloff\""),
choice("falloff", "Falloff", &[("smooth", "Smooth"), ("linear", "Linear"), ("none", "None")])
.help("Falloff followers: how the share falls from the grabbed nodes to the radius.")
.hint("smooth"),
range("strength", "Strength", D::None, 0.0, 1.0).help("Falloff followers: the largest share of the move a follower takes, 0 to 1.").hint("1"),
flag("on_geometry", "Stay on the geometry")
.help("Nodes on CAD faces slide on them, on CAD edges along them; nodes on CAD vertices stay.")
.def("true"),
choice("guard", "Guard", &[("no_inversion", "Scale back to no inverted element"), ("none", "None")])
.help("No inversion: the move is scaled back until no element inverts or falls below the smallest quality.")
.def("\"no_inversion\""),
range("min_quality", "Smallest quality", D::None, 0.0, 1.0)
.help("No inversion: the smallest scaled Jacobian the move may leave an element with (one already below keeps its own).")
.def("0.05"),
];
pub const NODE_RELAX_APPLY: &[Field] = &[
text("field", "Field").req().help("The relaxation field kept by Relax Nodes."),
references("bodies", "Bodies", C::Body).help("Only the nodes of these bodies.").hint("the field's bodies"),
@@ -1882,6 +1971,7 @@ pub const SAMPLE: &[Field] = &[choice(
("drop-test", "Drop test"),
("rubber-seal", "Rubber seal"),
("flow-past", "Flow past a bracket"),
("heated-flow", "Heated flow past a bracket"),
("heated-bracket", "Heated bracket"),
("bracket-hex", "Hex-meshed bracket"),
("crank-arm", "Bicycle crank arm"),
@@ -1950,6 +2040,9 @@ pub const SOLVE_EXPLICIT: &[Field] = &[
pub const SOLVE_HISTORY: &[Field] = &[SOLVED.req(), int("every", "Keep one in", 1, 1_000_000_000).help("Keep one history sample in this many.")];
pub const SOLVE_FLUID: &[Field] = &[
STEP,
choice("backend", "Backend", &[("cpu", "CPU"), ("gpu", "GPU")])
.help("The CPU (double precision, the same result on every machine) or the GPU the build and FIELDCRAFT_GPU pick (single precision).")
.def("\"cpu\""),
int("max_cells", "Max cells", 1, 16_000_000).help("Cap on the lattice cells."),
int("max_steps", "Max steps", 1, 10_000_000).help("Cap on the lattice time steps."),
positive("max_cell_updates", "Max cell updates", D::None).help("Cap on cells × steps."),
@@ -1966,6 +2059,7 @@ pub const SOLVE_PROBE: &[Field] = &[
flag("elements", "Element results").help("Also read element results (stresses) around the nodes."),
points("points", "Fluid points", D::Length, 10_000).help("Points in the fluid to read."),
json("forces", "Fluid walls").help("Walls whose fluid forces to sum, as JSON.").hint("[\"x_min\", \"F2:top\"]"),
json("heat", "Fluid walls (heat)").help("Walls whose heat histories to read, as JSON.").hint("[\"y_min\", \"F2:top\"]"),
];
// ---- Results ----
@@ -1986,6 +2080,10 @@ pub const CONTOUR: &[Field] = &[
("displacement_x", "Displacement X"),
("displacement_y", "Displacement Y"),
("displacement_z", "Displacement Z"),
("velocity", "Velocity magnitude"),
("pressure", "Pressure (fluid)"),
("temperature", "Temperature"),
("wall_heat_flux", "Wall heat flux (into wall)"),
("max_principal", "Max principal"),
("mid_principal", "Mid principal"),
("min_principal", "Min principal"),
@@ -2024,6 +2122,49 @@ pub const FRAME: &[Field] = &[
int("frame", "Frame", 0, 1_000_000_000).help("The frame shown."),
positive("time", "Time", D::Time).help("Show the frame nearest this time; the last when empty.").hint("the last frame"),
];
const FLUID_FRAME: Field = int("frame", "Frame", 0, 1_000_000_000).help("The frame shown, from 0.").hint("the frame shown");
pub const SLICE: &[Field] = &[
SOLVED,
choice("field", "Field", &[("velocity", "Velocity magnitude"), ("pressure", "Pressure"), ("temperature", "Temperature (heated flow)")])
.help("The field coloured on the slice and the obstacles.")
.hint("the field shown"),
choice("axis", "Normal", &[("x", "X"), ("y", "Y"), ("z", "Z"), ("none", "None (hide)")])
.help("The axis the slice is normal to.")
.hint("the slice shown"),
number_or_auto("position", "Position", D::Length, None)
.help("Where the slice crosses its axis; auto: the domain's middle.")
.hint("the position shown"),
FLUID_FRAME,
];
pub const FLOW_SURFACE: &[Field] = &[
SOLVED,
flag("show", "Show").help("Colour the obstacles by the selected field.").def("true"),
choice(
"field",
"Field",
&[("velocity", "Velocity"), ("pressure", "Pressure"), ("temperature", "Temperature"), ("wall_heat_flux", "Wall heat flux (into wall)")],
)
.help("Colour the obstacle faces by the adjacent fluid field, or signed wall heat received per area.")
.hint("the field shown"),
FLUID_FRAME,
];
pub const STREAMLINES: &[Field] = &[
SOLVED,
choice("field", "Field", &[("velocity", "Velocity"), ("pressure", "Pressure (plain lines)"), ("temperature", "Temperature")])
.help("Colour streamlines by speed or temperature; pressure leaves the lines in one colour.")
.hint("the field shown"),
flag("show", "Show").help("Draw the streamlines.").def("true"),
int("count", "Count", 1, 2000).help("Seeds on the inlet face, or points along the seed line.").def("64"),
int("max_steps", "Max steps", 1, 100_000).help("Most steps of half a cell each streamline takes.").def("4000"),
points("seeds", "Seeds", D::Length, 2000).help("The points streamlines start from; the inlet face when empty."),
group(
"line",
"Seed line",
&[xyz("from", "From", D::Length).help("The seed line's first end."), xyz("to", "To", D::Length).help("The seed line's last end.")],
)
.help("Start `count` streamlines evenly along a line instead of the inlet face."),
FLUID_FRAME,
];
pub const EXPORT_CSV: &[Field] = &[
choice("what", "Export", &[("reactions", "Reaction sums"), ("probe", "Probed nodes")]).help("What to write.").req().def("\"reactions\""),
path("path", "File", true).help("The CSV file to write.").req(),
+2 -1
View File
@@ -151,7 +151,7 @@ pub(super) fn optional_count(p: &Value, key: &str, default: u64, range: std::ops
}
/// A whole number in `range`, as a problem's kind.
fn whole_in(range: &std::ops::RangeInclusive<u64>) -> ProblemKind {
pub(super) fn whole_in(range: &std::ops::RangeInclusive<u64>) -> ProblemKind {
let int = |n: u64| i64::try_from(n).unwrap_or(i64::MAX);
ProblemKind::NotAWholeNumber { min: int(*range.start()), max: int(*range.end()) }
}
@@ -250,6 +250,7 @@ pub(super) fn quality_json(q: &TetQuality) -> Value {
"max_dihedral": q.max_dihedral,
"min_radius_ratio": q.min_radius_ratio,
"mean_radius_ratio": q.mean_radius_ratio,
"min_scaled_jacobian": q.min_scaled_jacobian,
"slivers": q.slivers,
"inadmissible": q.inadmissible,
})
+11
View File
@@ -18,12 +18,18 @@ pub(crate) mod mesh;
pub use datum::edit_params as datum_edit_params;
pub use datum::preview as datum_preview;
pub use fieldcraft_mesh::relax::{ElementChange, Moved as NodeMoved};
pub use mesh::analytic_surfaces;
pub use node_move::{
Capped as NodeMoveCapped, Prepared as NodeMovePrepared, nearest_node as node_move_nearest, prepare as node_move_prepare,
prepare_capped as node_move_prepare_capped,
};
pub use nodes::merge_preview as node_merge_preview;
pub use plan::untagged_faces;
pub use relax::{Preview as RelaxPreview, preview as relax_preview, sphere as relax_sphere};
mod mirror;
mod model;
pub(crate) mod node_move;
pub(crate) mod nodes;
pub(crate) mod octree;
pub(crate) mod plan;
@@ -121,6 +127,7 @@ pub static ALL: &[CommandSpec] = &[
nodes::RENUMBER,
relax::RELAX,
relax::APPLY,
node_move::MOVE,
tags::TAG,
tags::TAG_CLEAR,
model::UNITS,
@@ -170,6 +177,7 @@ pub static ALL: &[CommandSpec] = &[
define::FLUID_DOMAIN_EDIT,
define::FLUID_DOMAIN_DELETE,
define::FLUID_BOUNDARY,
define::FLUID_THERMAL_BOUNDARY,
define::STEP_CREATE,
define::STEP_EDIT,
define::STEP_DELETE,
@@ -202,6 +210,9 @@ pub static ALL: &[CommandSpec] = &[
results::MODES,
results::ANIMATE,
results::FRAME,
results::SLICE,
results::SURFACE,
results::STREAMLINES,
results::REACTIONS,
results::EXPORT_CSV,
results::EXPORT_VTU,
+298
View File
@@ -0,0 +1,298 @@
//! Moving nodes by hand: `mesh.node.move` displaces grabbed nodes (listed, on listed B-rep faces,
//! or a sphere's) by a vector or to a point, the nodes round them following by a falloff or by
//! harmonic interpolation ([`fieldcraft_mesh::relax::move_weights`]), nodes on the geometry
//! staying on it as Relax Nodes moves them ([`super::relax`]), the move scaled back until no
//! element inverts; it reports what the move did to the elements' quality.
//!
//! The interactive tool (drag a node in the view) works out the same move live: [`prepare`] does
//! the part that does not depend on the displacement once (which nodes follow, and the design's
//! faces they slide on), and [`Prepared::preview`] applies a displacement to it.
use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_mesh::relax::{self, Constraints, Falloff, Guard, Influence, Moved, Propagate, Unconstrained};
use serde_json::{Value, json};
use super::relax::{DesignGeometry, design_geometry, face_refs, nodes_on_faces, point, quality_json, unmeshed_faces};
use crate::requirement::Requirement;
use crate::schema::ProblemKind;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const MOVE: CommandSpec = CommandSpec {
id: "mesh.node.move",
label: "Move Nodes",
params: r#"{"nodes": [labels] (the nodes to grab) | "faces": [{"body": "Base", "face": "F3"}] (every node on these B-rep faces) | "center": [x, y, z] with "radius": mm (a sphere: every node inside follows by the falloff of its distance to the centre), "by": [dx, dy, dz] | "to": [x, y, z] (where the grabbed nodes' centroid, or the centre, goes), "radius"?: mm (with nodes or faces: the nodes this close to a grabbed one follow; default none, only the grabbed nodes move), "propagate"?: "falloff" (default) | "harmonic" (the displacement carried into the nodes within the radius by harmonic interpolation on the graph of element edges, 1 at the grabbed nodes and zero beyond the radius; needs a radius, and the grabbed nodes: a sphere grabs its nearest node), "falloff"?: "smooth" (default) | "linear" | "none", "strength"?: 1 (0..1]: the largest share of the displacement a node that is not grabbed takes (falloff only), "on_geometry"?: true (nodes on a B-rep face slide on it in its UV parameters, on a B-rep edge along it, on a vertex stay, and skin nodes of a mesh without geometry stay; false: every node moves in space), "guard"?: "no_inversion" (default: the move is scaled back until no element inverts or falls below `min_quality`) | "none", "min_quality"?: 0.05 (0..1, no_inversion only: the smallest scaled Jacobian the move may leave an element with; an element already below keeps at least its own)} — moves the nodes; mid-side nodes follow. Reports the nodes moved, the largest displacement, the displacement applied and its scale (below 1 when the guard scaled it back), the quality of the elements round the moved nodes before and after (smallest and mean scaled Jacobian, largest edge ratio, invalid elements, the worst) and how many got worse and better. One undo step; a move that moves nothing (a zero displacement, or one the guard scales back to nothing) changes nothing and records no undo step (`"moved": 0`, `"nothing_moved": true`)"#,
form: Some(super::forms::NODE_MOVE),
kind: CommandKind::Model,
requires: &[Requirement::Mesh],
run: move_nodes,
};
/// Where nodes may go: the design's faces and edges, or anywhere.
enum Geometry {
Design(DesignGeometry),
Free,
}
impl Geometry {
fn constraints(&self) -> &dyn Constraints {
match self {
Geometry::Design(g) => g,
Geometry::Free => &Unconstrained,
}
}
}
/// A move worked out up to its displacement: who follows and by how much, and where they may go.
pub struct Prepared {
/// Node → its share of the displacement.
weights: BTreeMap<u32, f64>,
/// The grabbed nodes.
handles: BTreeSet<u32>,
/// Where a move `to` a point starts from: the grabbed nodes' centroid, or the centre.
origin: Option<[f64; 3]>,
/// The sphere of influence round the centre or the grabbed nodes, for the view.
pub sphere: Option<([f64; 3], f64)>,
guard: Guard,
geometry: Geometry,
/// The region's elements' quality before the move, measured by the first live preview.
baseline: std::sync::OnceLock<Vec<(f64, bool)>>,
}
/// Halvings of the guard's bisection in a live preview (the scale to within 2⁻⁶; the command
/// itself finds it to 2⁻²⁰).
const PREVIEW_GUARD_STEPS: usize = 6;
impl Prepared {
/// Nodes that take a share of the displacement.
pub fn nodes(&self) -> usize {
self.weights.len()
}
/// The grabbed nodes.
pub fn handles(&self) -> &BTreeSet<u32> {
&self.handles
}
/// The displacement `params` ask for (`by`, or `to` less the origin).
pub fn displacement(&self, params: &Value) -> Result<[f64; 3]> {
match (point(params, "by")?, point(params, "to")?) {
(Some(_), Some(_)) => Err(EngineError::Params("give `by` or `to`, not both".into())),
(Some(d), None) => Ok(d),
(None, Some(to)) => {
let o = self.origin.ok_or_else(|| EngineError::Params("no node to move `to` a point".into()))?;
Ok([to[0] - o[0], to[1] - o[1], to[2] - o[2]])
}
(None, None) => Err(EngineError::Params("say how far to move: give `by` [dx, dy, dz] or `to` [x, y, z]".into())),
}
}
/// The move by `d` on `s`'s mesh, without doing it.
pub fn preview(&self, s: &Session, d: [f64; 3]) -> Result<Moved> {
Ok(relax::move_nodes(&s.model, &self.weights, d, self.guard, self.geometry.constraints())?)
}
/// [`Prepared::preview`] for a drag frame: the region's quality before the move is measured
/// once and reused, and the guard's scale is found more coarsely.
pub fn preview_live(&self, s: &Session, d: [f64; 3]) -> Result<Moved> {
let run = relax::MoveRun { baseline: self.baseline.get().cloned(), guard_steps: PREVIEW_GUARD_STEPS };
let m = relax::move_nodes_with(&s.model, &self.weights, d, self.guard, self.geometry.constraints(), &run)?;
if self.baseline.get().is_none() {
let _ = self.baseline.set(m.elements.iter().map(|c| (c.before, c.valid_before)).collect());
}
Ok(m)
}
}
/// The corner node nearest `p`: the node a drag in the view grabs.
pub fn nearest_node(s: &Session, p: [f64; 3]) -> Option<u32> {
relax::nearest_corner(&s.model, p)
}
/// A positive number parameter, when given.
fn positive(p: &Value, key: &str) -> Result<Option<f64>> {
match p.get(key) {
None => Ok(None),
Some(_) => Some(super::optional_number(p, key, 0.0)?)
.filter(|r| *r > 0.0 && r.is_finite())
.ok_or_else(|| EngineError::param(key, ProblemKind::NotPositive))
.map(Some),
}
}
/// Everything of `mesh.node.move` but its displacement.
pub fn prepare(s: &Session, p: &Value) -> Result<Prepared> {
match prepare_capped(s, p, None)? {
Capped::Ready(prep) => Ok(*prep),
Capped::TooLarge(n) => Err(EngineError::Params(format!("{n} nodes to move"))),
}
}
/// [`prepare`], or the count of the nodes that would follow when it is more than `cap`: the
/// costly part (the design's faces, the harmonic solve) is then not done.
pub enum Capped {
Ready(Box<Prepared>),
TooLarge(usize),
}
/// [`prepare`] that stops before the costly part when more than `cap` nodes would follow.
pub fn prepare_capped(s: &Session, p: &Value, cap: Option<usize>) -> Result<Capped> {
let model = &s.model;
let propagate = match super::opt::<String>(p, "propagate")? {
None => Propagate::Falloff,
Some(v) => Propagate::from_name(&v).ok_or_else(|| EngineError::param("propagate", ProblemKind::NotAChoice))?,
};
if propagate == Propagate::Harmonic && ["falloff", "strength"].iter().any(|k| p.get(*k).is_some()) {
return Err(EngineError::Params("`falloff` and `strength` go with \"propagate\": \"falloff\"".into()));
}
let falloff = match super::opt::<String>(p, "falloff")? {
None => Falloff::Smooth,
Some(f) => Falloff::from_name(&f).ok_or_else(|| EngineError::param("falloff", ProblemKind::NotAChoice))?,
};
let strength = super::optional_number(p, "strength", 1.0)?;
if !(strength > 0.0 && strength <= 1.0) {
return Err(EngineError::param("strength", ProblemKind::NotInRange { min: 0.0, max: 1.0 }));
}
let min_quality = super::optional_number(p, "min_quality", relax::DEFAULT_MIN_QUALITY)?;
if !(0.0..=1.0).contains(&min_quality) {
return Err(EngineError::param("min_quality", ProblemKind::NotInRange { min: 0.0, max: 1.0 }));
}
let guard = match super::opt::<String>(p, "guard")?.as_deref() {
None | Some("no_inversion") => Guard::NoInversion { min_quality },
Some("none") if p.get("min_quality").is_some() => {
return Err(EngineError::Params("`min_quality` goes with \"guard\": \"no_inversion\"".into()));
}
Some("none") => Guard::None,
Some(_) => return Err(EngineError::param("guard", ProblemKind::NotAChoice)),
};
let on_geometry = super::opt::<bool>(p, "on_geometry")?.unwrap_or(true);
let radius = positive(p, "radius")?;
let centre = point(p, "center")?;
let listed = ["nodes", "faces"].iter().filter(|k| p.get(**k).is_some()).count() + usize::from(centre.is_some());
if listed != 1 {
return Err(EngineError::Params("say which nodes to move: give one of `nodes`, `faces` or `center` (with `radius`)".into()));
}
let mut handles: BTreeSet<u32> = BTreeSet::new();
if p.get("nodes").is_some() {
for n in super::labels(p, "nodes", fieldcraft_model::MAX_NODES)? {
if model.node(n).is_none() {
return Err(EngineError::Params(format!("no node {n}")));
}
handles.insert(n);
}
}
if p.get("faces").is_some() {
handles.extend(nodes_on_faces(model, &face_refs(p)?)?);
}
let mut origin = relax::centroid(model, &handles);
if let Some(c) = centre {
if radius.is_none() {
return Err(EngineError::Params("a sphere needs both `center` and `radius`".into()));
}
origin = Some(c);
// A harmonic move needs something to carry: the sphere grabs the node nearest its centre.
if propagate == Propagate::Harmonic {
handles.extend(relax::nearest_corner(model, c));
}
}
let influence = Influence { centre: if propagate == Propagate::Falloff { centre } else { None }, radius, propagate, strength, falloff };
// The constraints of every node that might take a share: the grabbed ones and those within
// the radius (all of the mesh's corner nodes when the region is not known yet).
let candidates =
relax::move_weights(model, &handles, &Influence { propagate: Propagate::Falloff, falloff: Falloff::None, ..influence }, &Unconstrained)?;
if let Some(cap) = cap
&& candidates.len() > cap
{
return Ok(Capped::TooLarge(candidates.len()));
}
let geometry = if on_geometry {
let mut nodes: BTreeSet<u32> = candidates.keys().copied().collect();
// Their elements' nodes too: the mid-side nodes that follow.
for e in model.elements().values() {
if e.nodes.iter().any(|n| candidates.contains_key(n)) {
nodes.extend(e.nodes.iter().copied());
}
}
Geometry::Design(design_geometry(s, &nodes)?)
} else {
Geometry::Free
};
let weights = relax::move_weights(model, &handles, &influence, geometry.constraints())?;
if weights.is_empty() {
return Err(EngineError::Params("no mesh nodes to move: the nodes given are no element's corners, or none lies in the sphere".into()));
}
let sphere = radius.and_then(|r| centre.or(origin).map(|c| (c, r)));
Ok(Capped::Ready(Box::new(Prepared { weights, handles, origin, sphere, guard, geometry, baseline: std::sync::OnceLock::new() })))
}
/// The quality of the region as JSON, with its mean scaled Jacobian.
fn quality(q: &relax::RegionQuality, mean: f64) -> Value {
let mut v = quality_json(q);
if let Some(o) = v.as_object_mut() {
o.insert("mean_scaled_jacobian".into(), json!(mean));
}
v
}
fn move_nodes(s: &mut Session, p: &Value) -> Result<Value> {
let prep = prepare(s, p)?;
let d = prep.displacement(p)?;
let r = prep.preview(s, d)?;
let asked = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if r.moved == 0 && asked > 0.0 && r.scale > 0.0 {
return Err(EngineError::Params(
"the grabbed nodes may not move: they are on B-rep vertices (or on the skin of a mesh without geometry); give \"on_geometry\": false to move them freely".into(),
));
}
if r.positions.is_empty() {
// Nothing to do: the model is unchanged, so no undo step is recorded.
let why = if asked == 0.0 {
"the displacement is zero"
} else {
"the guard scaled the move back to nothing (any move would invert an element or go below min_quality)"
};
return Ok(json!({
"moved": 0,
"nodes_changed": 0,
"nothing_moved": true,
"scale": r.scale,
"before": quality(&r.before, r.mean_before),
"summary": format!("nothing moved: {why}"),
}));
}
for (n, x) in &r.positions {
s.model.set_node(*n, *x)?;
}
let summary = format!(
"moved {} node{} (largest {:.3e}{}); scaled Jacobian min {:.3} to {:.3}, mean {:.3} to {:.3}; {} element{} worse, {} better",
r.moved,
if r.moved == 1 { "" } else { "s" },
r.max_displacement,
if r.scale < 1.0 { format!(", scaled to {:.3} so that no element inverts", r.scale) } else { String::new() },
r.before.min_quality,
r.after.min_quality,
r.mean_before,
r.mean_after,
r.worse,
if r.worse == 1 { "" } else { "s" },
r.better,
);
Ok(json!({
"moved": r.moved,
"nodes_changed": r.positions.len(),
"grabbed": prep.handles.len(),
"max_displacement": r.max_displacement,
"displacement": d.map(|x| x * r.scale),
"scale": r.scale,
"before": quality(&r.before, r.mean_before),
"after": quality(&r.after, r.mean_after),
"worse": r.worse,
"better": r.better,
"sphere": prep.sphere.map(|(c, r)| json!({"center": c, "radius": r})),
"faces_without_mesh": unmeshed_faces(&s.model, p),
"summary": summary,
}))
}
#[cfg(test)]
mod tests;
+274
View File
@@ -0,0 +1,274 @@
use super::*;
use fieldcraft_model::Model;
/// The plate (40 × 30 × 10) with its Ø10 hole round (15, 15).
const HOLE: ([f64; 2], f64) = ([15.0, 15.0], 5.0);
fn plate() -> Session {
let mut s = super::super::datum::tests::plate().0;
s.execute("mesh.hex.generate", &json!({"size": 2.0})).unwrap();
s
}
fn hole_radius(p: [f64; 3]) -> f64 {
((p[0] - HOLE.0[0]).powi(2) + (p[1] - HOLE.0[1]).powi(2)).sqrt()
}
fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
(0..3).map(|i| (a[i] - b[i]).powi(2)).sum::<f64>().sqrt()
}
/// The node nearest `p` among those `keep` accepts.
fn nearest(m: &Model, p: [f64; 3], keep: impl Fn([f64; 3]) -> bool) -> u32 {
let mut best = (f64::INFINITY, 0);
for (n, x) in m.nodes() {
if keep(x.xyz) && dist(x.xyz, p) < best.0 {
best = (dist(x.xyz, p), *n);
}
}
best.1
}
/// An interior node (off every face of the plate).
fn interior(m: &Model) -> u32 {
nearest(m, [30.0, 15.0, 5.0], |p| p[2] > 1e-6 && p[2] < 10.0 - 1e-6 && p[0] > 1e-6 && p[0] < 40.0 - 1e-6 && p[1] > 1e-6 && p[1] < 30.0 - 1e-6)
}
fn invalid(s: &mut Session) -> u64 {
s.execute("mesh.quality", &json!({})).unwrap()["invalid"]["count"].as_u64().unwrap_or(0)
}
/// `by` moves the grabbed node by the vector, the reply carries the quality before and after,
/// one undo step takes it back and redo does it again.
#[test]
fn by_moves_a_node_in_one_undo_step() {
let mut s = plate();
let start = s.model.clone();
let n = interior(&s.model);
let p0 = s.model.node(n).unwrap().xyz;
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.3, -0.2, 0.1]})).unwrap();
assert_eq!((r["moved"].as_u64(), r["scale"].as_f64(), r["grabbed"].as_u64()), (Some(1), Some(1.0), Some(1)), "{r}");
assert!(dist(s.model.node(n).unwrap().xyz, [p0[0] + 0.3, p0[1] - 0.2, p0[2] + 0.1]) < 1e-12);
assert!(r["before"]["min_scaled_jacobian"].as_f64().unwrap() > r["after"]["min_scaled_jacobian"].as_f64().unwrap(), "{r}");
assert!(r["worse"].as_u64().unwrap() > 0 && r["before"]["mean_scaled_jacobian"].is_number(), "{r}");
let moved = s.model.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.model, start);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.model, moved);
}
/// `to` puts the grabbed nodes' centroid (or the sphere's centre) at the point.
#[test]
fn to_puts_the_centroid_at_the_point() {
let mut s = plate();
let n = interior(&s.model);
let p0 = s.model.node(n).unwrap().xyz;
let to = [p0[0] + 0.25, p0[1], p0[2] - 0.25];
s.execute("mesh.node.move", &json!({"nodes": [n], "to": to})).unwrap();
assert!(dist(s.model.node(n).unwrap().xyz, to) < 1e-12);
let c = s.model.node(n).unwrap().xyz;
let r = s.execute("mesh.node.move", &json!({"center": c, "radius": 3.0, "to": [c[0], c[1] + 0.2, c[2]]})).unwrap();
assert!(r["moved"].as_u64().unwrap() > 1, "{r}");
assert!((s.model.node(n).unwrap().xyz[1] - c[1] - 0.2).abs() < 1e-9, "the node at the centre takes all of it");
}
/// Each falloff moves the nodes within the radius, none beyond, the grabbed node by all of it;
/// they move the followers differently.
#[test]
fn each_falloff_moves_only_within_the_radius() {
let mut followers = Vec::new();
for falloff in ["smooth", "linear", "none"] {
let mut s = plate();
let start = s.model.clone();
let n = interior(&s.model);
let p0 = start.node(n).unwrap().xyz;
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.0, 0.0, 0.2], "radius": 4.0, "falloff": falloff})).unwrap();
assert!(r["moved"].as_u64().unwrap() > 1, "{falloff}: {r}");
let mut sum = 0.0;
for (k, x) in s.model.nodes() {
let old = start.node(*k).unwrap().xyz;
if old != x.xyz {
assert!(dist(old, p0) < 4.0 + 1e-9, "{falloff}: node {k} beyond the radius moved");
sum += dist(old, x.xyz);
}
}
assert!((s.model.node(n).unwrap().xyz[2] - p0[2] - 0.2).abs() < 1e-12);
followers.push(sum);
}
assert!(followers[0] < followers[1] && followers[1] < followers[2], "{followers:?}");
}
/// Harmonic: the nodes within the radius follow, the move falls to nothing at the radius and no
/// node beyond it moves; falloff and strength do not go with it.
#[test]
fn harmonic_propagation_is_zero_outside_the_radius() {
let mut s = plate();
let start = s.model.clone();
let n = interior(&s.model);
let p0 = start.node(n).unwrap().xyz;
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.2, 0.0, 0.0], "radius": 5.0, "propagate": "harmonic"})).unwrap();
assert!(r["moved"].as_u64().unwrap() > 5, "{r}");
for (k, x) in s.model.nodes() {
let old = start.node(*k).unwrap().xyz;
if old != x.xyz {
let d = dist(old, p0);
assert!(d < 5.0 + 1e-9, "node {k} beyond the radius moved");
assert!(dist(old, x.xyz) <= 0.2 + 1e-12);
}
}
assert_eq!(invalid(&mut s), 0);
let e = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.1, 0.0, 0.0], "radius": 5.0, "propagate": "harmonic", "falloff": "linear"}));
assert!(e.is_err());
assert!(s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.1, 0.0, 0.0], "propagate": "harmonic"})).is_err(), "needs a radius");
}
/// On the geometry: a node on the hole slides round it (stays on the cylinder), its followers on
/// the caps stay in their planes; a node at a corner of the plate is held (an error says how to
/// move it), and moves with the geometry off.
#[test]
fn on_geometry_keeps_nodes_on_faces_and_edges_and_holds_vertices() {
let mut s = plate();
let start = s.model.clone();
let on_hole = |p: [f64; 3]| (hole_radius(p) - HOLE.1).abs() < 1e-6;
let n = nearest(&s.model, [20.0, 15.0, 5.0], |p| on_hole(p) && p[2] > 1e-6 && p[2] < 10.0 - 1e-6);
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.0, 0.6, 0.4], "radius": 3.0, "propagate": "harmonic"})).unwrap();
assert!(r["moved"].as_u64().unwrap() > 1, "{r}");
assert_ne!(s.model.node(n).unwrap().xyz, start.node(n).unwrap().xyz);
for (k, x) in s.model.nodes() {
let old = start.node(*k).unwrap().xyz;
if on_hole(old) {
assert!(on_hole(x.xyz), "node {k} left the hole: r = {}", hole_radius(x.xyz));
}
for z in [0.0, 10.0] {
if (old[2] - z).abs() < 1e-9 {
assert!((x.xyz[2] - z).abs() < 1e-9, "node {k} left its cap");
}
}
}
// A node on the hole's rim (the edge of the hole and a cap) slides along the rim.
let rim = nearest(&start, [20.0, 15.0, 10.0], |p| on_hole(p) && (p[2] - 10.0).abs() < 1e-9);
let mut s2 = plate();
s2.execute("mesh.node.move", &json!({"nodes": [rim], "by": [0.0, 0.8, 0.5]})).unwrap();
let x = s2.model.node(rim).unwrap().xyz;
assert!(on_hole(x) && (x[2] - 10.0).abs() < 1e-9 && x != start.node(rim).unwrap().xyz, "{x:?}");
// The plate's corner is a vertex.
let corner = nearest(&start, [0.0, 0.0, 0.0], |_| true);
let e = s2.execute("mesh.node.move", &json!({"nodes": [corner], "by": [0.3, 0.3, 0.3]})).unwrap_err();
assert!(e.to_string().contains("on_geometry"), "{e}");
s2.execute("mesh.node.move", &json!({"nodes": [corner], "by": [0.3, 0.3, 0.3], "on_geometry": false})).unwrap();
assert!(dist(s2.model.node(corner).unwrap().xyz, [0.3, 0.3, 0.3]) < 1e-9);
}
/// The guard scales a move that would invert elements back so that none does; without it they
/// invert.
#[test]
fn the_guard_scales_back_to_no_inversion() {
let mut s = plate();
let n = interior(&s.model);
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [3.0, 0.0, 0.0]})).unwrap();
let scale = r["scale"].as_f64().unwrap();
assert!(scale > 0.0 && scale < 1.0, "{r}");
assert_eq!(r["after"]["invalid"], 0, "{r}");
assert_eq!(invalid(&mut s), 0);
let mut s = plate();
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [3.0, 0.0, 0.0], "guard": "none"})).unwrap();
assert_eq!(r["scale"], 1.0);
assert!(r["after"]["invalid"].as_u64().unwrap() > 0, "{r}");
}
/// The preview of the interactive tool is the command's move, without doing it.
#[test]
fn the_preview_is_the_move_without_doing_it() {
let mut s = plate();
let n = interior(&s.model);
let p = json!({"nodes": [n], "by": [0.2, 0.1, 0.0], "radius": 4.0});
let prep = prepare(&s, &p).unwrap();
assert!(prep.nodes() > 1 && prep.handles().contains(&n) && prep.sphere.is_some());
let start = s.model.clone();
let preview = prep.preview(&s, prep.displacement(&p).unwrap()).unwrap();
assert_eq!(s.model, start);
s.execute("mesh.node.move", &p).unwrap();
for (k, x) in &preview.positions {
assert_eq!(s.model.node(*k).unwrap().xyz, *x);
}
assert!(preview.elements.iter().any(|e| e.after != e.before));
}
#[test]
fn hostile_move_params_are_refused() {
let mut s = plate();
let n = interior(&s.model);
let start = s.model.clone();
for p in [
json!({}),
json!({"nodes": [n]}),
json!({"by": [1, 0, 0]}),
json!({"nodes": [n], "by": [1, 0]}),
json!({"nodes": [n], "by": ["x", 0, 0]}),
json!({"nodes": [n], "by": [1e308, 1e308, 1e308]}),
json!({"nodes": [n], "by": [1, 0, 0], "to": [0, 0, 0]}),
json!({"nodes": [n], "center": [0, 0, 0], "radius": 1, "by": [1, 0, 0]}),
json!({"center": [0, 0, 0], "by": [1, 0, 0]}),
json!({"nodes": [n], "by": [1, 0, 0], "radius": -1}),
json!({"nodes": [n], "by": [1, 0, 0], "radius": "big"}),
json!({"nodes": [n], "by": [1, 0, 0], "strength": 0}),
json!({"nodes": [n], "by": [1, 0, 0], "strength": 2}),
json!({"nodes": [n], "by": [1, 0, 0], "falloff": "cubic"}),
json!({"nodes": [n], "by": [1, 0, 0], "propagate": "magic"}),
json!({"nodes": [n], "by": [1, 0, 0], "guard": "maybe"}),
json!({"nodes": [n], "by": [1, 0, 0], "on_geometry": "yes"}),
json!({"nodes": [99_999_999], "by": [1, 0, 0]}),
json!({"nodes": [-1], "by": [1, 0, 0]}),
json!({"nodes": [], "by": [1, 0, 0]}),
json!({"faces": [], "by": [1, 0, 0]}),
json!({"faces": [{"body": "Nope", "face": "F1"}], "by": [1, 0, 0]}),
json!({"center": [1e9, 0, 0], "radius": 1, "by": [1, 0, 0]}),
json!({"nodes": [n], "by": [1, 0, 0], "radius": 1e-300}),
json!({"center": [-1e300, -1e300, 0], "radius": 1e-300, "by": [1, 0, 0]}),
json!({"nodes": [n], "by": [1, 0, 0], "radius": 1e-300, "propagate": "harmonic"}),
json!({"nodes": [n], "by": [1, 0, 0], "min_quality": 1.5}),
json!({"nodes": [n], "by": [1, 0, 0], "min_quality": -0.1}),
json!({"nodes": [n], "by": [1, 0, 0], "guard": "none", "min_quality": 0.2}),
] {
assert!(s.execute("mesh.node.move", &p).is_err(), "{p} should be refused");
assert_eq!(s.model, start, "{p} changed the model");
}
}
/// A zero move, or one the guard scales back to nothing, changes nothing and records no undo
/// step; `min_quality` sets how far the guard lets a move go.
#[test]
fn a_move_of_nothing_records_no_undo_step_and_min_quality_is_adjustable() {
let mut s = plate();
let n = interior(&s.model);
let (journal, start) = (s.journal.len(), s.model.clone());
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0, 0, 0], "radius": 3.0})).unwrap();
assert_eq!((r["moved"].as_u64(), r["nothing_moved"].as_bool()), (Some(0), Some(true)), "{r}");
assert_eq!((s.journal.len(), &s.model), (journal, &start));
let scale = |s: &mut Session, q: f64| {
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [3.0, 0.0, 0.0], "min_quality": q})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
(r["scale"].as_f64().unwrap(), r["after"]["min_scaled_jacobian"].as_f64().unwrap())
};
let (loose, strict) = (scale(&mut s, 0.0), scale(&mut s, 0.5));
assert!(strict.0 < loose.0 && strict.1 >= 0.5 - 1e-9, "{loose:?} {strict:?}");
}
/// The live preview's cap: more followers than it allows are counted, not prepared; the live
/// preview is the command's move.
#[test]
fn a_capped_prepare_counts_a_large_region_and_the_live_preview_matches() {
let s = plate();
let n = interior(&s.model);
let p = json!({"nodes": [n], "by": [0.2, 0.1, 0.0], "radius": 4.0, "propagate": "harmonic"});
assert!(matches!(prepare_capped(&s, &p, Some(3)).unwrap(), Capped::TooLarge(k) if k > 3));
let Capped::Ready(prep) = prepare_capped(&s, &p, None).unwrap() else { panic!("not prepared") };
let (exact, first, again) = (
prep.preview(&s, [0.2, 0.1, 0.0]).unwrap(),
prep.preview_live(&s, [0.2, 0.1, 0.0]).unwrap(),
prep.preview_live(&s, [0.2, 0.1, 0.0]).unwrap(),
);
assert_eq!(exact.positions, first.positions);
assert_eq!(first, again, "the reused baseline gives the same move");
}
+133 -7
View File
@@ -54,6 +54,81 @@ const MAX_ELEMENTS: usize = 5_000_000;
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
/// The finest tessellation a plan asks for, as a fraction of the diagonal (as the meshers take).
const MIN_RELATIVE_TOLERANCE: f64 = 1e-5;
/// The share of the body's volume the chords of its curved faces may cost at the plan's size: a
/// linear element's face spans a chord of the surface, and the sag between them is volume lost
/// (or gained, in a hole).
const CHORD_VOLUME_BUDGET: f64 = 0.005;
/// The principal curvatures' magnitudes, larger first, of an analytic surface at `p` (none for a
/// plane or a free-form surface).
fn curvatures(s: &FaceSurface, p: V3) -> Option<(f64, f64)> {
let off_axis = |origin: V3, axis: V3| {
let v = std::array::from_fn(|k| p[k] - origin[k]);
let a = unit(axis)?;
let along = dot(v, a);
let r: V3 = std::array::from_fn(|k| v[k] - along * a[k]);
Some(dot(r, r).sqrt())
};
let k = match *s {
FaceSurface::Plane { .. } => return None,
FaceSurface::Cylinder { radius, .. } => (1.0 / radius, 0.0),
FaceSurface::Sphere { radius, .. } => (1.0 / radius, 1.0 / radius),
FaceSurface::Cone { apex, axis, half_angle } => (half_angle.cos() / off_axis(apex, axis)?, 0.0),
// Round the tube 1/minor; round the axis cos φ / ρ, with ρ the distance from the axis.
FaceSurface::Torus { center, axis, major, minor } => {
let rho = off_axis(center, axis)?;
(1.0 / minor, ((rho - major) / (minor * rho)).abs())
}
};
let k = (k.0.abs(), k.1.abs());
(k.0.is_finite() && k.1.is_finite()).then_some(if k.0 >= k.1 { k } else { (k.1, k.0) })
}
/// The largest element size whose chords cost each curved face at most [`CHORD_VOLUME_BUDGET`]
/// of the body's `volume`, with the curved face that needs the finest size and its radius.
///
/// A linear face of size h across a surface of principal curvatures κ1, κ2 sags h²(κ1 + κ2)/12
/// on average below it, so a face of area A costs A h²(κ1 + κ2)/12 of volume. A face's share is
/// bounded two ways, and the looser bound is enough: its cost of all the faces against the body's volume (a
/// small blend costs little whatever its curvature), and per face its cost against the volume it
/// bounds itself, the turning angle per element (a thin tube's wall, whose inner and outer chords nearly
/// cancel, needs no finer size than a solid bar of its radius).
fn curvature_size(surfaces: &[Option<FaceSurface>], mesh: &fieldcraft_geometry::SurfaceMesh, volume: f64) -> Option<(f64, usize, f64)> {
let n = surfaces.len();
// Per face: ∫(κ1 + κ2) dA, area, ∫ κ1 (κ1 + κ2) dA, ∫ κ1 dA.
let mut sums = vec![[0.0f64; 4]; n];
for (t, &f) in mesh.triangles.iter().zip(&mesh.tri_face) {
let Some(Some(surface)) = surfaces.get(f as usize) else { continue };
let [a, b, c] = t.map(|i| mesh.positions.get(i as usize).copied().unwrap_or_default());
let centroid = std::array::from_fn(|k| (a[k] + b[k] + c[k]) / 3.0);
let Some((k1, k2)) = curvatures(surface, centroid) else { continue };
let area = 0.5 * {
let n = cross(std::array::from_fn(|k| b[k] - a[k]), std::array::from_fn(|k| c[k] - a[k]));
dot(n, n).sqrt()
};
if let Some(s) = sums.get_mut(f as usize) {
s[0] += area * (k1 + k2);
s[1] += area;
s[2] += area * k1 * (k1 + k2);
s[3] += area * k1;
}
}
let e = CHORD_VOLUME_BUDGET;
// The whole body's cost against its volume, then the face that needs the finest size by its
// own turning angle, never finer than the body's bound.
let total: f64 = sums.iter().map(|s| s[0]).sum();
let global = (12.0 * e * volume.abs() / total).sqrt();
let (local, face, radius) = sums
.iter()
.enumerate()
.filter(|(_, s)| s[1] > 0.0 && s[2] > 0.0 && s[3] > 0.0)
// A circle split into arcs of angle θ loses θ²/6 of its area, a sphere's θ²/3: h² κ1 (κ1 + κ2)/6.
.map(|(i, s)| ((6.0 * e * s[1] / s[2]).sqrt(), i, s[1] / s[3]))
.filter(|(h, _, _)| h.is_finite() && *h > 0.0)
.min_by(|a, b| a.0.total_cmp(&b.0))?;
let h = global.max(local);
h.is_finite().then_some((h, face, radius))
}
pub const PLAN: CommandSpec = CommandSpec {
id: "mesh.plan",
@@ -849,6 +924,18 @@ pub(crate) fn build(design: &Design, body: &str, size: Option<f64>, layers: u32,
h = through;
}
}
// The curved faces: fine enough that the chords keep the volume.
let chords = shape.tessellate(1e-3 * diagonal)?;
if let Some((c, face, r)) = curvature_size(&surfaces, &chords, m.volume)
&& c < h
{
let name = shape.faces().get(face).map_or_else(|| format!("face {face}"), |f| f.name.clone());
notes.push(format!(
"size {c:.4} mm: the chords of {name} (radius ~{r:.4} mm) cost at most {:.1} % of the volume",
100.0 * CHORD_VOLUME_BUDGET
));
h = c;
}
let floor = (m.volume.abs() / max_elements as f64).cbrt();
if h < floor {
notes.push(format!("size {floor:.4} mm instead of {h:.4}: at most {max_elements} elements (fewer layers through the thinnest wall)"));
@@ -1046,6 +1133,8 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
// The copies: each meshed by a plan of its own at this plan's size, saved beside it.
let mut copies = Vec::with_capacity(plan.copies.len());
for copy in &plan.copies {
fieldcraft_model::progress::stage(&format!("Plan: meshing the copy {copy}"), None, "");
fieldcraft_model::progress::check()?;
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
if design.source_body(copy).is_none() {
return Err(EngineError::Params(format!("mesh plan `{name}`: no copy `{copy}` in the design")));
@@ -1163,7 +1252,13 @@ fn apply_one(s: &mut Session, name: &str, plan: &MeshPlan) -> Result<Value> {
let body = cells.first().cloned().unwrap_or_else(|| plan.body.clone());
(super::hex::HEX_GENERATE.run)(s, &json!({"body": body, "size": plan.size, "method": "swept", "tolerance": tolerance}))
}
.map_err(|e| EngineError::Params(format!("mesh plan `{name}`: the cells {cells:?} (cuts {made:?}) do not mesh all-hex: {e}")))?;
.map_err(|e| {
if e.is_cancelled() {
e
} else {
EngineError::Params(format!("mesh plan `{name}`: the cells {cells:?} (cuts {made:?}) do not mesh all-hex: {e}"))
}
})?;
let summary = summary(&mesh, &cells);
// What today's meshers cannot express yet: the rings round circles are built by the
// projection mesher (#399); until then the frame cuts box the circle and the sweep meshes the
@@ -1359,6 +1454,8 @@ fn apply_symmetric(s: &mut Session, name: &str, plan: &MeshPlan, corners: bool)
let cells: Vec<String> = design.bodies.iter().filter(|b| design.source_of(&b.name) == Some(plan.body.as_str())).map(|b| b.name.clone()).collect();
let meshed_first = meshed.clone();
for (a, c) in &planes {
fieldcraft_model::progress::stage("Plan: mirroring the cell", None, "");
fieldcraft_model::progress::check()?;
(super::mirror::MIRROR.run)(s, &json!({"plane": {"point": a.map(|x| x * c), "normal": a}, "bodies": meshed}))?;
meshed = cells.iter().filter(|n| !s.model.body_elements(n).is_empty()).cloned().collect();
}
@@ -1545,6 +1642,7 @@ fn copy_corner(
}
Ok(Ok(copy))
}
Err(e) if e.is_cancelled() => Err(e),
// A corner copy that does not mesh leaves the cell to be meshed whole.
Err(e) => {
s.restore(before);
@@ -1576,9 +1674,13 @@ fn corner_meshes(
// planar, the corner taken from that mesh: independent meshes, made side by side. The best
// corner wins.
let whole = &whole_body;
let runs = fieldcraft_parallel::map(candidates.len() + 1, |k| match k.checked_sub(1).and_then(|i| candidates.get(i)) {
None => super::project::project_only(whole, cell, t0, &opts).map(|m| (m.report.min_scaled_jacobian, None)),
Some(&x1) => corner_of_whole(whole, cell, t0, a, [x1, 2.0 * c - x1], &opts).map(|r| (r.sj, Some(([x1, 2.0 * c - x1], r)))),
// The job's progress on the pool's threads too, so a cancel stops the candidates.
let progress = fieldcraft_model::progress::current();
let runs = fieldcraft_parallel::map(candidates.len() + 1, |k| {
fieldcraft_model::progress::scoped(progress.clone(), || match k.checked_sub(1).and_then(|i| candidates.get(i)) {
None => super::project::project_only(whole, cell, t0, &opts).map(|m| (m.report.min_scaled_jacobian, None)),
Some(&x1) => corner_of_whole(whole, cell, t0, a, [x1, 2.0 * c - x1], &opts).map(|r| (r.sj, Some(([x1, 2.0 * c - x1], r)))),
})
});
let mut reference: Option<f64> = None;
let mut best: Option<(f64, [f64; 2], CornerOfWhole)> = None;
@@ -1590,6 +1692,7 @@ fn corner_meshes(
best = Some((sj, at, corner));
}
}
Err(e) if e.is_cancelled() => return Err(e),
Err(_) => {}
}
}
@@ -1786,8 +1889,9 @@ fn apply_projection(s: &mut Session, name: &str, plan: &MeshPlan, tolerance: f64
let (fixed, fixed_faces) = cut_face_patches(s, &body, &plan.body, plan.size)?;
let field = super::sizes::sizing(s)?.local_field(plan.size)?;
let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, fixed, field, ..Default::default() };
let mut mesh = super::project::project_body(s, &plan.body, body, &layout.topology, &opts, merge)
.map_err(|e| EngineError::Params(format!("mesh plan `{name}`: the projected layout does not mesh: {e}")))?;
let mut mesh = super::project::project_body(s, &plan.body, body, &layout.topology, &opts, merge).map_err(|e| {
if e.is_cancelled() { e } else { EngineError::Params(format!("mesh plan `{name}`: the projected layout does not mesh: {e}")) }
})?;
if let Some(m) = mesh.as_object_mut() {
// The block listing is long; the plan reports the layout instead.
m.remove("topology");
@@ -1859,6 +1963,8 @@ fn cut(
let cleared = super::datum::recut(s)?;
let mut skipped: Vec<(String, String)> = Vec::new();
for _ in 0..=names.len() {
fieldcraft_model::progress::stage("Plan: checking the cells", None, "");
fieldcraft_model::progress::check()?;
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
let live: Vec<&String> = names.iter().filter(|n| !skipped.iter().any(|(k, _)| k == *n)).collect();
let mut failed: Vec<(String, String)> = live
@@ -2443,6 +2549,24 @@ mod acceptance {
accept(&mut s, &r, 0.65, 10.0);
}
#[test]
fn curved_bodies_mesh_within_a_percent_of_their_volume_at_the_plan_s_size() {
// The plan's size keeps the chords of a torus (swept) and a cylinder (butterfly) within
// the volume budget; the butterfly's rim is divided by its length, not its class's mean.
for cmds in [
json!([{"command": "solid.torus", "params": {"major": 30.0, "minor": 8.0, "body_name": "B"}}]),
json!([{"command": "solid.cylinder", "params": {"radius": 10.0, "height": 40.0, "body_name": "B"}}]),
] {
let (_d, mut s) = open(cmds.clone());
let body = s.execute("geometry.bodies", &json!({})).unwrap()[0]["volume"].as_f64().unwrap();
let plan = s.execute("mesh.plan", &json!({"body": "B"})).unwrap();
assert!(plan["notes"].to_string().contains("the chords of"), "{plan}");
s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap();
let mesh = s.execute("mesh.quality", &json!({})).unwrap()["volume"].as_f64().unwrap();
assert!(((mesh - body) / body).abs() < 0.01, "{cmds}: mesh {mesh} against {body}");
}
}
/// The crank arm sample, whole (#455, #190): its sides are at an angle to the axes and its
/// root arcs meet the bosses at a kink, so it takes the ring-and-pillow layout: each boss an
/// annulus from its bore or pedal hole to its circle through every level, the arm a carved
@@ -2462,7 +2586,9 @@ mod acceptance {
let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap();
let layout = r["layout"].as_str().unwrap();
assert!(layout.contains("2 circle(s) ringed") && layout.contains("pillow layer"), "{r}");
accept(&mut s, &r, 0.5, 7.0);
// The rims are divided by their own length (no chord longer than the size), which
// thins the bore's ring elements a little at 2.5 mm (edge ratio 7.1).
accept(&mut s, &r, 0.5, 7.5);
}
}
+6 -2
View File
@@ -202,8 +202,12 @@ fn squeezed_junction_elements_are_reported_not_meshed() {
p["min_quality"] = json!(0.3);
let model = s.model.clone();
let e = s.execute("mesh.hex.project", &p).unwrap_err().to_string();
// Smoothing leaves the worst element in the squeezed ring block (34) or in block 36.
assert!(e.contains("smallest scaled Jacobian") && (e.contains("worst in block 34") || e.contains("worst in block 36")), "{e}");
// Smoothing leaves the worst element in the squeezed ring block (34) or in block 36; with
// the cup's rim divided by its own length, the junction's corner (block 13) folds instead,
// and the validity gate refuses it.
let squeezed = e.contains("smallest scaled Jacobian") && (e.contains("worst in block 34") || e.contains("worst in block 36"));
let folded = e.contains("inverted, twisted or degenerate") && e.contains("block 13");
assert!(squeezed || folded, "{e}");
assert_eq!(s.model, model);
}
+25 -13
View File
@@ -28,7 +28,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const RELAX: CommandSpec = CommandSpec {
id: "mesh.node.relax",
label: "Relax Nodes",
params: r#"{"center": [x, y, z], "radius": mm (the sphere of influence) | "around_element": label (a sphere round that element; radius default twice its longest edge) | "faces": [{"body": "Base", "face": "F3"}] (persistent B-rep face names, as model.set.create takes them: every node on those faces and the nodes of the elements touching them; pick faces in the view) | "nodes": [labels] | "elements": [labels] (their nodes), "bodies"?: ["Body1"] (only their nodes; a partitioned body's cells count as it), "iterations"?: 5 (1..=100), "strength"?: 1 (0..1], "falloff"?: "smooth" (default: weight 1 at the centre to 0 at the radius) | "linear" | "none", "field"?: "Name" (the relaxation field to keep; default Relax1, Relax2, …), "reapply"?: false (apply the field again after each remesh of these bodies), "mode"?: "guarded" (default) | "strong", "below"?: 0.7 (strong: the elements below this scaled Jacobian are the bad ones), "reach"?: mm (strong: nodes this close to a bad node follow it; default three times the bad elements' mean edge length)} — moves the nodes to improve the worst elements around them (smart Laplacian, then optimization of the worst element; a move is kept only when the worst element around the node does not get worse, and never inverts one). `"mode": "strong"` morphs the region instead (`bodies` alone is the whole body, nothing listed the whole mesh; `iterations` are rounds, default 20, 1..=200): each round moves the corner nodes of the elements below `below` to better places (their own optimisation, the rest held) and carries that displacement to the nodes within `reach` by harmonic (graph Laplacian) interpolation, zero beyond; nodes on B-rep edges and vertices are held, nodes on faces put back on them, nodes outside the reach unchanged. A round is kept (at full or a halved scale) only when nothing inverts, no element gets longer than the longest was, and the worst element it touches improves; the result never has a lower smallest scaled Jacobian than the start. Its reply adds the mean scaled Jacobian and, per round, the bad elements and nodes, their clusters (bad nodes within the reach of each other, each morphed and checked on its own) and how many were kept, the region's nodes, the reach and the min and mean (`rounds`). Nodes on a B-rep face slide in its UV parameters and stay on it, nodes on a B-rep edge slide along it, nodes on vertices stay; nodes on partition cut faces move freely (shared by both cells); mid-side nodes follow, on the geometry. Reports the nodes moved, the largest displacement, the region's smallest scaled Jacobian, largest edge ratio and worst elements before and after, the field kept, and the listed faces no mesh is tagged with (`faces_without_mesh`; an error when none is). One undo step"#,
params: r#"{"center": [x, y, z], "radius": mm (the sphere of influence) | "around_element": label (a sphere round that element; radius default twice its longest edge) | "faces": [{"body": "Base", "face": "F3"}] (persistent B-rep face names, as model.set.create takes them: every node on those faces and the nodes of the elements touching them; pick faces in the view) | "nodes": [labels] | "elements": [labels] (their nodes), "bodies"?: ["Body1"] (only their nodes; a partitioned body's cells count as it), "iterations"?: 5 (1..=100), "strength"?: 1 (0..1], "falloff"?: "smooth" (default: weight 1 at the centre to 0 at the radius) | "linear" | "none", "field"?: "Name" (the relaxation field to keep; default Relax1, Relax2, …), "reapply"?: false (apply the field again after each remesh of these bodies), "mode"?: "guarded" (default) | "strong", "below"?: 0.7 (strong: the elements below this scaled Jacobian are the bad ones), "reach"?: mm (strong: nodes this close to a bad node follow it; default five times the bad elements' mean edge length), "guards"?: "valid" (default) | "strict" (strong: when a cluster's morph is kept)} — moves the nodes to improve the worst elements around them (smart Laplacian, then optimization of the worst element; a move is kept only when the worst element around the node does not get worse, and never inverts one). `"mode": "strong"` morphs the region instead (`bodies` alone is the whole body, nothing listed the whole mesh; `iterations` are rounds, default 20, 1..=200): each round moves the corner nodes of the elements below `below` to better places (their own optimisation, the rest held) and carries that displacement to the nodes within `reach` by harmonic (graph Laplacian) interpolation, zero beyond; nodes on B-rep edges and vertices are held, nodes on faces put back on them, nodes outside the reach unchanged. A cluster's morph is kept whenever no element it touches inverts (`"guards": "valid"`); with `"guards": "strict"` it is kept (at full or a halved scale) only when besides no element gets longer than the longest was, and the worst element it touches improves. The result is the round with the highest smallest scaled Jacobian, never lower than the start. Its reply adds the mean scaled Jacobian and, per round, the bad elements and nodes, their clusters (bad nodes within the reach of each other, each morphed and checked on its own) and how many were kept, the region's nodes, the reach and the min and mean (`rounds`). Nodes on a B-rep face slide in its UV parameters and stay on it, nodes on a B-rep edge slide along it, nodes on vertices stay; nodes on partition cut faces move freely (shared by both cells); mid-side nodes follow, on the geometry. Reports the nodes moved, the largest displacement, the region's smallest scaled Jacobian, largest edge ratio and worst elements before and after, the field kept, and the listed faces no mesh is tagged with (`faces_without_mesh`; an error when none is). One undo step"#,
form: Some(super::forms::NODE_RELAX),
kind: CommandKind::Model,
requires: &[Requirement::Mesh],
@@ -61,7 +61,7 @@ const REMESH: &[&str] = &[
const DEFAULT_ITERATIONS: usize = 5;
/// The region's quality as JSON.
fn quality_json(q: &RegionQuality) -> Value {
pub(crate) fn quality_json(q: &RegionQuality) -> Value {
json!({
"elements": q.elements,
"min_scaled_jacobian": q.min_quality,
@@ -155,7 +155,7 @@ fn same_surface(a: &AnalyticSurface, b: &AnalyticSurface, tol: f64) -> bool {
}
/// The design's geometry as relaxation constraints.
struct DesignGeometry {
pub(crate) struct DesignGeometry {
/// Per body: its tessellation with exact surfaces.
bodies: Vec<Tessellation>,
/// Face ids: (body, B-rep face).
@@ -180,7 +180,7 @@ impl Constraints for DesignGeometry {
}
/// The constraints of `nodes` from the mesh's tags and the open design.
fn design_geometry(s: &Session, nodes: &BTreeSet<u32>) -> Result<DesignGeometry> {
pub(crate) fn design_geometry(s: &Session, nodes: &BTreeSet<u32>) -> Result<DesignGeometry> {
let tags = node_tags(&s.model, nodes);
let mut g = DesignGeometry { bodies: Vec::new(), faces: Vec::new(), curves: Vec::new(), freedom: BTreeMap::new() };
// Body name → (index into `g.bodies`, its exact surfaces), built when first needed.
@@ -298,7 +298,7 @@ fn body_nodes(model: &Model, p: &Value) -> Result<Option<(Vec<String>, BTreeSet<
Ok(Some((bodies, nodes)))
}
fn point(p: &Value, key: &str) -> Result<Option<[f64; 3]>> {
pub(crate) fn point(p: &Value, key: &str) -> Result<Option<[f64; 3]>> {
match super::opt::<[f64; 3]>(p, key)? {
Some(c) if c.iter().all(|x| x.is_finite()) => Ok(Some(c)),
Some(_) => Err(EngineError::param(key, ProblemKind::NotANumber)),
@@ -310,7 +310,7 @@ fn point(p: &Value, key: &str) -> Result<Option<[f64; 3]>> {
const MAX_FACES: usize = 10_000;
/// The `faces` parameter: a list of `{body, face}` B-rep face references.
fn face_refs(p: &Value) -> Result<Vec<GeometryRef>> {
pub(crate) fn face_refs(p: &Value) -> Result<Vec<GeometryRef>> {
let refs: Vec<GeometryRef> = super::req(p, "faces")?;
if refs.is_empty() || refs.len() > MAX_FACES {
return Err(EngineError::invalid("faces", format!("1 to {MAX_FACES} faces")));
@@ -337,7 +337,7 @@ fn face_tags<'a>(model: &Model, refs: &'a [GeometryRef]) -> (BTreeSet<u32>, Vec<
}
/// The listed faces with no tagged mesh, as `{body, face}` (the reply names them).
fn unmeshed_faces(model: &Model, p: &Value) -> Vec<Value> {
pub(crate) fn unmeshed_faces(model: &Model, p: &Value) -> Vec<Value> {
match p.get("faces").map(|_| face_refs(p)) {
Some(Ok(refs)) => face_tags(model, &refs).1.into_iter().map(|r| json!(r)).collect(),
_ => Vec::new(),
@@ -345,9 +345,9 @@ fn unmeshed_faces(model: &Model, p: &Value) -> Vec<Value> {
}
/// The nodes on the B-rep faces `refs` (from the mesh's geometry tags: element faces on them and
/// shell elements meshing them) and the nodes of every element touching those. Faces with no
/// tagged mesh are left out (the reply names them); none with one is an error.
fn face_nodes(model: &Model, refs: &[GeometryRef]) -> Result<BTreeSet<u32>> {
/// shell elements meshing them). Faces with no tagged mesh are left out (the reply names them);
/// none with one is an error.
pub(crate) fn nodes_on_faces(model: &Model, refs: &[GeometryRef]) -> Result<BTreeSet<u32>> {
let c = model.classification();
let (tags, missing) = face_tags(model, refs);
if tags.is_empty() {
@@ -369,6 +369,13 @@ fn face_nodes(model: &Model, refs: &[GeometryRef]) -> Result<BTreeSet<u32>> {
on.extend(local.iter().filter_map(|&i| e.nodes.get(i).copied()));
}
}
Ok(on)
}
/// The nodes on the B-rep faces `refs` ([`nodes_on_faces`]) and the nodes of every element
/// touching those.
fn face_nodes(model: &Model, refs: &[GeometryRef]) -> Result<BTreeSet<u32>> {
let on = nodes_on_faces(model, refs)?;
let mut out = on.clone();
for e in model.elements().values() {
if e.nodes.iter().any(|n| on.contains(n)) {
@@ -522,6 +529,11 @@ fn relax_strong(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| EngineError::param("reach", ProblemKind::NotPositive))
.map(Some)?,
};
let guards = match super::opt::<String>(p, "guards")?.as_deref() {
None | Some("valid") => relax::Guards::Valid,
Some("strict") => relax::Guards::Strict,
Some(_) => return Err(EngineError::param("guards", ProblemKind::NotAChoice)),
};
let rounds = match p.get("iterations") {
None => STRONG_ROUNDS,
Some(v) => v
@@ -543,7 +555,7 @@ fn relax_strong(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Params("no mesh nodes in the region to relax".into()));
}
let g = design_geometry(s, &touched(&s.model, &nodes.iter().map(|&n| (n, 1.0)).collect()))?;
let r = relax::morph(&s.model, &nodes, relax::MorphOptions { below, reach, rounds }, &g)?;
let r = relax::morph(&s.model, &nodes, relax::MorphOptions { below, reach, rounds, guards }, &g)?;
let field_name = match super::opt::<String>(p, "field")? {
Some(n) => n,
None => (1..).map(|i| format!("Relax{i}")).find(|n| s.model.relaxation(n).is_none()).unwrap_or_else(|| "Relax".into()),
@@ -609,8 +621,8 @@ fn relax_nodes(s: &mut Session, p: &Value) -> Result<Value> {
Some("strong") => return relax_strong(s, p),
Some(_) => return Err(EngineError::param("mode", ProblemKind::NotAChoice)),
}
if p.get("below").is_some() || p.get("reach").is_some() {
return Err(EngineError::Params("`below` and `reach` go with \"mode\": \"strong\"".into()));
if ["below", "reach", "guards"].iter().any(|k| p.get(*k).is_some()) {
return Err(EngineError::Params("`below`, `reach` and `guards` go with \"mode\": \"strong\"".into()));
}
let (w, sphere) = weights(&s.model, p)?;
if w.is_empty() {
+4 -1
View File
@@ -275,7 +275,8 @@ fn on_box_edge(p: [f64; 3]) -> bool {
fn strong_relaxation_of_a_filleted_box_keeps_faces_and_edges() {
let (_d, mut s, _) = filleted_box();
let before = s.model.clone();
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "bodies": ["B"], "below": 0.3})).unwrap();
// The tet mesher leaves no element below the skill's 0.3, so relax those below 0.45.
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "bodies": ["B"], "below": 0.45})).unwrap();
let (b, a) = (&r["before"], &r["after"]);
// The worst tets may not improve: their corners can all be held on the box's edges.
assert!(a["min_scaled_jacobian"].as_f64().unwrap() >= b["min_scaled_jacobian"].as_f64().unwrap(), "{r}");
@@ -349,6 +350,8 @@ fn strong_mode_parameters_are_checked() {
json!({"mode": "strong", "nodes": [1], "iterations": 201}),
json!({"below": 0.5}),
json!({"nodes": [1], "reach": 1}),
json!({"nodes": [1], "guards": "strict"}),
json!({"mode": "strong", "nodes": [1], "guards": "loose"}),
json!({"nodes": [1], "mode": "strong", "layers": 1}),
] {
assert!(s.execute("mesh.node.relax", &p).is_err(), "{p}");
+435 -7
View File
@@ -1,11 +1,12 @@
//! `results.*`: what the results display shows (deformed shape, contours, mode shapes,
//! animation) and reaction sums. The display is session state ([`ResultsView`]): these
//! animation; a fluid step's slice, coloured obstacles and streamlines) and reaction sums. The display is session state ([`ResultsView`]): these
//! commands are not edits, so they are not journalled or undone.
use crate::schema::ProblemKind;
use serde_json::{Value, json};
use super::{opt, xyz};
use crate::flow::{Axis, FlowView, MAX_STREAMLINE_STEPS, MAX_STREAMLINES, SliceView};
use crate::requirement::Requirement;
use crate::results::{Field, Kind, MAX_BANDS, MIN_BANDS, ResultsView};
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
@@ -23,7 +24,7 @@ pub const DEFORMED: CommandSpec = CommandSpec {
pub const CONTOUR: CommandSpec = CommandSpec {
id: "results.contour",
label: "Contours",
params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#,
params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, velocity (explicit and fluid steps), pressure (fluid steps), temperature (thermal frames), wall_heat_flux (signed face average on thermal fluid obstacles), von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#,
form: Some(super::forms::CONTOUR),
kind: CommandKind::Session,
requires: &[Requirement::Result],
@@ -53,7 +54,7 @@ pub const ANIMATE: CommandSpec = CommandSpec {
pub const FRAME: CommandSpec = CommandSpec {
id: "results.frame",
label: "Field Frame",
params: r#"{"step": "Impact", "frame": 3, "time": 0.002} — shows a field frame of a solved explicit step, by index from 0 or the frame nearest a time (neither: the last), or of a nonlinear static step (one per converged increment, `time` its load factor; the deformed shape follows it, its contours show the final state); the deformed shape and contours follow it"#,
params: r#"{"step": "Impact", "frame": 3, "time": 0.002} — shows a field frame of a solved explicit or fluid step, by index from 0 or the frame nearest a time (neither: the last), or of a nonlinear static step (one per converged increment, `time` its load factor; the deformed shape follows it, its contours show the final state); the deformed shape and contours follow it"#,
form: Some(super::forms::FRAME),
kind: CommandKind::Session,
requires: &[Requirement::Result],
@@ -90,6 +91,36 @@ pub const EXPORT_CSV: CommandSpec = CommandSpec {
run: export_csv,
};
pub const SLICE: CommandSpec = CommandSpec {
id: "results.slice",
label: "Flow Slice",
params: r#"{"step": "Flow", "field": "velocity" | "pressure" | "temperature", "axis": "x" | "y" | "z" | "none", "position": 20 | "auto", "frame": 10} — shows a fluid step's field (velocity magnitude, gauge pressure or temperature when its frames carry heat, banded with the legend as results.contour sets it) on a slice through the domain normal to `axis`, at `position` along it (inside the domain; "auto" or left out with a new axis: the domain's middle); "none" hides the slice; `frame` from 0 (left out: the one shown, at first the last); what is left out stays as shown. A fluid step is first shown with velocity on a slice through the middle, normal to the horizontal axis across the flow"#,
form: Some(super::forms::SLICE),
kind: CommandKind::Session,
requires: &[Requirement::Result],
run: slice,
};
pub const SURFACE: CommandSpec = CommandSpec {
id: "results.surface",
label: "Flow on Surfaces",
params: r#"{"step": "Flow", "show": true, "field"?: "velocity" | "pressure" | "temperature" | "wall_heat_flux", "frame"?: 10} — colours a fluid step's obstacles (the part drawn) by the field shown, read in the fluid cells beside them; `wall_heat_flux` is signed power received by each obstacle face divided by its complete tessellated area, in model power/area units (positive into the wall); histories interpolate at the shown frame without extrapolation; recorded adiabatic faces are zero, missing histories remain uncoloured; no slice is drawn for this surface-only field. `show: false` draws the part in its own colours"#,
form: Some(super::forms::FLOW_SURFACE),
kind: CommandKind::Session,
requires: &[Requirement::Result],
run: surface,
};
pub const STREAMLINES: CommandSpec = CommandSpec {
id: "results.streamlines",
label: "Streamlines",
params: r#"{"step": "Flow", "show": true, "field"?: "velocity" | "pressure" | "temperature", "count": 64, "max_steps": 4000, "seeds": [[x, y, z], …], "line": {"from": [x, y, z], "to": [x, y, z]}, "frame": 10} — streamlines of a fluid step, traced downstream through its velocity field (trilinear between the cells, midpoint rule, half-cell steps) and coloured by speed or temperature when that field is selected (plain for pressure): from `seeds` (at most 2000 points), from `count` points evenly along `line`, or else from `count` seeds (1 to 2000, default 64) on the inlet face upstream of the obstacles; each at most `max_steps` steps (1 to 100000, default 4000), 2 000 000 points in all; `show: false` hides them; `frame` as for results.slice. Replies with the view and `traced`: the streamlines, their points, the seeds and how many started none (outside the fluid)"#,
form: Some(super::forms::STREAMLINES),
kind: CommandKind::Session,
requires: &[Requirement::Result],
run: streamlines,
};
pub const CLEAR: CommandSpec = CommandSpec {
id: "results.clear",
label: "Hide Results",
@@ -115,23 +146,27 @@ fn solved_list(s: &Session) -> String {
pub(super) enum Want {
Any,
Frequency,
/// Steps with frames: explicit dynamics and nonlinear static (one per converged increment).
/// Steps with frames: explicit dynamics, fluid and nonlinear static (one per converged
/// increment).
Frames,
Fluid,
}
impl Want {
fn fits(self, s: &Session, step: &str) -> bool {
s.latest_result(step).is_some_and(|(r, _)| match (self, r.kind()) {
(Want::Any, _) | (Want::Frequency, Kind::Frequency(_)) | (Want::Frames, Kind::Explicit(_)) => true,
(Want::Any, _) | (Want::Frequency, Kind::Frequency(_)) | (Want::Frames | Want::Fluid, Kind::Fluid(_)) => true,
(Want::Frames, Kind::Explicit(_)) => true,
(Want::Frames, Kind::Static(r)) => r.nonlinear.is_some(),
(Want::Frequency | Want::Frames, _) => false,
(Want::Frequency | Want::Frames | Want::Fluid, _) => false,
})
}
fn name(self) -> &'static str {
match self {
Want::Any => "solved",
Want::Frequency => "frequency",
Want::Frames => "explicit or nonlinear static",
Want::Frames => "explicit, fluid or nonlinear static",
Want::Fluid => "fluid",
}
}
}
@@ -214,12 +249,27 @@ fn contour(s: &mut Session, p: &Value) -> Result<Value> {
if let (Some(f), Some((r, _))) = (field, s.latest_result(&step))
&& !Field::available(r).any(|a| a == f)
{
if f == Field::Temperature && matches!(r.kind(), Kind::Fluid(_)) {
return Err(EngineError::Params(format!(
"temperature is not available for fluid step `{step}`: its frames have no complete temperature field (isothermal flow)"
)));
}
if f == Field::WallHeatFlux {
return Err(EngineError::Params(format!("wall heat flux is not available for step `{step}`: it requires thermal obstacle histories")));
}
return Err(EngineError::Params(format!("{} is not available for step `{step}` (mode shapes have no stress scale)", f.label())));
}
let range = match p.get("range") {
None => None,
Some(r) => Some(legend_range(r)?),
};
if field == Some(Field::WallHeatFlux)
&& let Some((r, _)) = s.latest_result(&step)
&& let Kind::Fluid(f) = r.kind()
{
let pick = s.results_view.as_ref().filter(|v| v.step == step).map(|v| v.pick()).unwrap_or_default();
crate::flow::wall::faces(s, f, pick)?;
}
let v = view_of(s, &step)?;
if v.contour != field {
(v.range_min, v.range_max) = (None, None);
@@ -291,6 +341,9 @@ fn animate(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(s.results_report());
}
let step = step_for(s, &json!({}), Want::Any)?;
if s.latest_result(&step).is_some_and(|(r, _)| matches!(r.kind(), Kind::Fluid(_))) {
return Err(EngineError::Params(format!("step `{step}` is a fluid step: nothing deforms; show its frames with results.frame")));
}
let v = view_of(s, &step)?;
v.animate = true;
v.deformed = true;
@@ -302,6 +355,7 @@ fn frame(s: &mut Session, p: &Value) -> Result<Value> {
let times: Vec<f64> = match s.latest_result(&step).map(|(r, _)| r.kind()) {
Some(Kind::Explicit(e)) => e.frames.iter().map(|f| f.time).collect(),
Some(Kind::Static(r)) => r.nonlinear.as_ref().map(|h| h.load_factors()).unwrap_or_default(),
Some(Kind::Fluid(f)) => f.frames.iter().map(|f| f.time).collect(),
_ => Vec::new(),
};
let last = times.len().checked_sub(1).ok_or_else(|| EngineError::Params(format!("step `{step}` has no field frames")))?;
@@ -322,6 +376,225 @@ fn frame(s: &mut Session, p: &Value) -> Result<Value> {
Ok(s.results_report())
}
/// The fluid step a fluid display command works on (`step`, or the one shown) and its result.
fn fluid_step(s: &Session, p: &Value) -> Result<(String, std::sync::Arc<crate::StepResult>)> {
let step = step_for(s, p, Want::Fluid)?;
let r = s.latest_result(&step).map(|(r, _)| r.clone()).ok_or_else(|| EngineError::Params(solved_list(s)))?;
Ok((step, r))
}
/// A whole number in `range`, when given.
fn whole(p: &Value, key: &str, range: std::ops::RangeInclusive<u64>) -> Result<Option<u64>> {
match p.get(key).filter(|v| !v.is_null()) {
None => Ok(None),
Some(v) => v.as_u64().filter(|n| range.contains(n)).map(Some).ok_or_else(|| EngineError::param(key, super::mesh::whole_in(&range))),
}
}
/// A fluid command's `frame`: from 0 to the last; `None` when left out.
fn fluid_frame(f: &fieldcraft_lbm::FluidResult, p: &Value) -> Result<Option<usize>> {
let last = f.frames.len().saturating_sub(1) as u64;
Ok(whole(p, "frame", 0..=last)?.and_then(|k| usize::try_from(k).ok()))
}
/// A whole number from 1 to `max`, when given.
fn count_param(p: &Value, key: &str, max: u32) -> Result<Option<u32>> {
Ok(whole(p, key, 1..=u64::from(max))?.and_then(|n| u32::try_from(n).ok()))
}
/// Largest coordinate a seed may have.
const MAX_COORDINATE: f64 = 1e12;
/// A point `[x, y, z]` of finite coordinates no larger than [`MAX_COORDINATE`].
fn seed_point(v: &Value) -> Option<[f64; 3]> {
let a = v.as_array().filter(|a| a.len() == 3)?;
let c: Vec<f64> = a.iter().filter_map(Value::as_f64).filter(|x| x.is_finite() && x.abs() <= MAX_COORDINATE).collect();
match c[..] {
[x, y, z] => Some([x, y, z]),
_ => None,
}
}
fn slice(s: &mut Session, p: &Value) -> Result<Value> {
let (step, r) = fluid_step(s, p)?;
let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) };
let field = fluid_field(p, &r)?;
// Some(None): hide the slice.
let axis = match opt::<String>(p, "axis")?.as_deref() {
None => None,
Some("none") => Some(None),
Some(a) => Some(Some(Axis::parse(a).ok_or_else(|| EngineError::invalid("axis", "must be x, y, z or none"))?)),
};
// Some(None): the domain's middle.
let position = match p.get("position").filter(|v| !v.is_null()) {
None => None,
Some(Value::String(a)) if a == "auto" => Some(None),
Some(v) => Some(Some(v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| EngineError::param("position", ProblemKind::NotANumber))?)),
};
let frame = fluid_frame(f, p)?;
let first = FlowView::for_result(f);
let shown = s.results_view.as_ref().filter(|v| v.step == step).and_then(|v| v.flow.clone()).unwrap_or_else(|| first.clone());
let slice = match axis {
Some(None) => None,
Some(Some(a)) => {
// A new axis starts at the middle; the same one keeps its position.
let kept = shown.slice.as_ref().filter(|s| s.axis == a).and_then(|s| s.position);
Some(SliceView { axis: a, position: position.unwrap_or(kept) })
}
None => {
let current = shown.slice.clone().or(first.slice);
current.map(|s| SliceView { position: position.unwrap_or(s.position), ..s })
}
};
if let Some(sv) = &slice
&& let Some(at) = sv.position
{
let g = crate::flow::grid(f);
let (lo, hi) = (g.start(sv.axis.index()), g.end(sv.axis.index()));
if !(at >= lo && at <= hi) {
return Err(EngineError::invalid("position", format!("must be inside the domain: {lo} to {hi} along {}", sv.axis.id())));
}
}
let v = view_of(s, &step)?;
if let Some(field) = field {
if v.contour != Some(field) {
(v.range_min, v.range_max) = (None, None);
}
v.contour = Some(field);
}
if frame.is_some() {
v.frame = frame;
}
v.flow = Some(FlowView { slice, ..shown });
Ok(s.results_report())
}
fn surface(s: &mut Session, p: &Value) -> Result<Value> {
let (step, r) = fluid_step(s, p)?;
let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) };
let show = opt::<bool>(p, "show")?.unwrap_or(true);
let field = if opt::<String>(p, "field")?.as_deref() == Some("wall_heat_flux") {
if !Field::available(&r).any(|f| f == Field::WallHeatFlux) {
return Err(EngineError::invalid("field", "wall heat flux is unavailable: this step has no thermal obstacle histories"));
}
Some(Field::WallHeatFlux)
} else {
fluid_field(p, &r)?
};
let frame = fluid_frame(f, p)?;
if field == Some(Field::WallHeatFlux) {
let mut pick = s.results_view.as_ref().filter(|v| v.step == step).map(|v| v.pick()).unwrap_or_default();
if frame.is_some() {
pick.frame = frame;
}
crate::flow::wall::faces(s, f, pick)?;
}
let v = view_of(s, &step)?;
set_fluid_field(v, field);
if frame.is_some() {
v.frame = frame;
}
let mut flow = FlowView::of(v, f).into_owned();
flow.surface = show;
v.flow = Some(flow);
Ok(s.results_report())
}
fn streamlines(s: &mut Session, p: &Value) -> Result<Value> {
let (step, r) = fluid_step(s, p)?;
let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) };
let show = opt::<bool>(p, "show")?.unwrap_or(true);
let field = fluid_field(p, &r)?;
let count = count_param(p, "count", MAX_STREAMLINES)?;
let max_steps = count_param(p, "max_steps", MAX_STREAMLINE_STEPS)?;
let max = MAX_STREAMLINES as usize;
let seeds = match p.get("seeds").filter(|v| !v.is_null()) {
None => None,
Some(_) => {
let list = super::points(p, "seeds", max)?;
if list.is_empty() || list.iter().flatten().any(|c| !(c.is_finite() && c.abs() <= MAX_COORDINATE)) {
return Err(EngineError::invalid("seeds", format!("must be 1 to {max} points [x, y, z] of finite coordinates")));
}
Some(list)
}
};
let line = match p.get("line").filter(|v| !v.is_null()) {
None => None,
Some(v) => {
let end = |k: &str| v.get(k).and_then(seed_point);
match (end("from"), end("to")) {
(Some(a), Some(b)) => Some((a, b)),
_ => return Err(EngineError::invalid("line", "must be {\"from\": [x, y, z], \"to\": [x, y, z]} of finite coordinates")),
}
}
};
if seeds.is_some() && line.is_some() {
return Err(EngineError::invalid("line", "give `seeds` or `line`, not both"));
}
let frame = fluid_frame(f, p)?;
let v = view_of(s, &step)?;
set_fluid_field(v, field);
let mut flow = FlowView::of(v, f).into_owned();
flow.streamlines = if show {
let mut sl = flow.streamlines.take().unwrap_or_default();
sl.count = count.unwrap_or(sl.count);
sl.max_steps = max_steps.unwrap_or(sl.max_steps);
sl.seeds = match (seeds, line) {
(Some(points), _) => Some(points),
(None, Some((a, b))) => {
let n = sl.count.max(1);
let at = |i: u32| if n == 1 { 0.5 } else { f64::from(i) / f64::from(n - 1) };
Some((0..n).map(|i| std::array::from_fn(|k| a[k] + (b[k] - a[k]) * at(i))).collect())
}
(None, None) => None,
};
Some(sl)
} else {
None
};
if frame.is_some() {
v.frame = frame;
}
let pick = v.pick();
let traced = flow.streamlines.clone();
v.flow = Some(flow);
let mut out = s.results_report();
if let Some(sl) = traced {
// Traced once: the plot drawn after this reply takes the same lines from the session.
let k = crate::flow::frame_index(f, pick)?;
let (seeds, lines) = s.traced.trace(&r, k, &sl)?;
let points: usize = lines.iter().map(|l| l.points.len()).sum();
out["traced"] = json!({"streamlines": lines.len(), "points": points, "seeds": seeds, "no_start": seeds.saturating_sub(lines.len())});
}
Ok(out)
}
fn fluid_field(p: &Value, r: &crate::StepResult) -> Result<Option<Field>> {
let field = match opt::<String>(p, "field")?.as_deref() {
None => return Ok(None),
Some("velocity") => Field::Velocity,
Some("pressure") => Field::Pressure,
Some("temperature") => Field::Temperature,
Some(_) => return Err(EngineError::invalid("field", "must be velocity, pressure or temperature")),
};
if !Field::available(r).any(|f| f == field) {
return Err(EngineError::invalid(
"field",
"temperature is unavailable: this isothermal step has no complete temperature field in its frames",
));
}
Ok(Some(field))
}
fn set_fluid_field(view: &mut ResultsView, field: Option<Field>) {
if let Some(field) = field {
if view.contour != Some(field) {
(view.range_min, view.range_max) = (None, None);
}
view.contour = Some(field);
}
}
fn clear(s: &mut Session, _: &Value) -> Result<Value> {
s.results_view = None;
Ok(json!({"view": null}))
@@ -1161,6 +1434,161 @@ mod tests {
assert!(s.execute("results.reactions", &json!({})).is_err());
}
/// A session showing a synthetic fluid result ([`crate::flow::tests::channel`]).
fn fluid() -> Session {
let mut s = Session::default();
s.store_result(crate::StepResult::Fluid(crate::flow::tests::channel()));
s
}
/// A fluid step is shown with velocity on a slice across the flow, its obstacles coloured
/// and streamlines from the inlet (#193); the commands change what is shown, not the model.
#[test]
fn fluid_steps_show_a_slice_the_obstacles_and_streamlines() {
let mut s = fluid();
let (journal, revision) = (s.journal.len(), s.revision());
let r = s.results_report();
assert_eq!(r["view"]["contour"], "velocity", "{r}");
assert_eq!(r["contour"]["range"]["max"]["value"], 10.0, "{r}");
assert_eq!(r["contour"]["range"]["max"]["at"], json!([9.5, 0.5, 0.5]), "{r}");
assert_eq!(r["slice"], json!({"axis": "y", "position": 2.0}));
assert_eq!((r["surface"].as_bool(), r["streamlines"]["inlet"].as_str()), (Some(true), Some("x_min")), "{r}");
// A slice normal to z at 1.5 coloured by pressure; "auto" goes back to the middle, a new
// axis starts there; positions outside the domain are refused; "none" hides it.
let r = s.execute("results.slice", &json!({"axis": "z", "position": 1.5, "field": "pressure"})).unwrap();
assert_eq!((r["slice"].clone(), r["contour"]["field"].as_str()), (json!({"axis": "z", "position": 1.5}), Some("pressure")), "{r}");
assert_eq!(s.execute("results.slice", &json!({"position": "auto"})).unwrap()["slice"]["position"], 2.0);
assert_eq!(s.execute("results.slice", &json!({"axis": "x"})).unwrap()["slice"], json!({"axis": "x", "position": 5.0}));
let e = s.execute("results.slice", &json!({"position": 11})).unwrap_err().to_string();
assert!(e.contains("0 to 10"), "{e}");
assert!(s.execute("results.slice", &json!({"axis": "none"})).unwrap()["slice"].is_null());
// results.contour reaches the same fields; nodal ones are refused.
s.execute("results.contour", &json!({"field": "velocity", "bands": 6})).unwrap();
assert!(s.execute("results.contour", &json!({"field": "von_mises"})).is_err());
// Frames, by results.frame or with the slice.
assert_eq!(s.execute("results.frame", &json!({"frame": 0})).unwrap()["time"], 0.0);
assert_eq!(s.execute("results.slice", &json!({"frame": 1, "axis": "y"})).unwrap()["time"], 1.0);
// Streamlines from the inlet, from seeds, along a line; hidden.
let r = s.execute("results.streamlines", &json!({"count": 4})).unwrap();
assert_eq!((r["traced"]["seeds"].as_u64(), r["traced"]["streamlines"].as_u64()), (Some(4), Some(4)), "{r}");
// The reply's trace is the one the plot draws.
let (view, result) = (s.results_view.as_ref().unwrap(), s.latest_result("Flow").unwrap().0);
let p = crate::flow::plot(view, result, &s.traced).unwrap();
let sl = view.flow.as_ref().and_then(|f| f.streamlines.as_ref()).unwrap();
assert!(std::sync::Arc::ptr_eq(&p.streamlines, &s.traced.trace(result, 1, sl).unwrap().1));
let r = s.execute("results.streamlines", &json!({"seeds": [[0.5, 0.5, 0.5], [5.5, 1.5, 1.5], [50, 0, 0]]})).unwrap();
assert_eq!((r["traced"]["streamlines"].as_u64(), r["traced"]["no_start"].as_u64()), (Some(1), Some(2)), "{r}");
assert_eq!(r["streamlines"]["seeds"], 3);
let r =
s.execute("results.streamlines", &json!({"line": {"from": [0.5, 0.5, 0.5], "to": [0.5, 3.5, 3.5]}, "count": 5, "max_steps": 3})).unwrap();
assert_eq!((r["traced"]["seeds"].as_u64(), r["traced"]["points"].as_u64()), (Some(5), Some(20)), "{r}");
assert!(s.execute("results.streamlines", &json!({"show": false})).unwrap()["streamlines"].is_null());
assert_eq!(s.execute("results.surface", &json!({"show": false})).unwrap()["surface"], false);
assert!(s.execute("results.animate", &json!({})).is_err(), "nothing deforms");
assert_eq!((s.journal.len(), s.revision()), (journal, revision), "display commands are not edits");
// Structural steps have none of it.
let mut t = solved();
for id in ["results.slice", "results.surface", "results.streamlines"] {
assert!(t.execute(id, &json!({})).is_err(), "{id}");
assert!(t.execute(id, &json!({"step": "Bend"})).is_err(), "{id}");
}
}
#[test]
fn temperature_views_require_thermal_frames_and_follow_the_selected_frame() {
let mut r = crate::flow::tests::channel();
r.frames[0].temperature = vec![20.0; r.grid.len()];
r.frames[1].temperature = vec![70.0; r.grid.len()];
let mut s = Session::default();
s.store_result(crate::StepResult::Fluid(r));
for command in ["results.slice", "results.surface", "results.streamlines", "results.contour"] {
let report = s.execute(command, &json!({"field": "temperature"})).unwrap();
assert_eq!(report["contour"]["field"], "temperature", "{command}: {report}");
assert_eq!(report["contour"]["range"]["max"]["value"], 70.0);
}
let report = s.execute("results.frame", &json!({"frame": 0})).unwrap();
assert_eq!(report["contour"]["range"]["max"]["value"], 20.0);
assert!(Field::available(s.latest_result("Flow").unwrap().0).any(|f| f == Field::Temperature));
let before = s.results_view.clone();
for command in ["results.slice", "results.surface", "results.streamlines"] {
for params in [json!({"field": "temperature", "frame": u64::MAX}), json!({"field": ["temperature"]})] {
assert!(s.execute(command, &params).is_err());
assert_eq!(s.results_view, before);
}
}
let mut incomplete = crate::flow::tests::channel();
incomplete.frames[0].temperature = vec![20.0; incomplete.grid.len()];
incomplete.frames[1].temperature = vec![70.0; incomplete.grid.len().saturating_sub(1)];
let incomplete = crate::StepResult::Fluid(incomplete);
assert!(!Field::available(&incomplete).any(|f| f == Field::Temperature));
let mut cold = fluid();
let before = cold.results_view.clone();
for command in ["results.slice", "results.surface", "results.streamlines", "results.contour"] {
let error = cold.execute(command, &json!({"field": "temperature"})).unwrap_err().to_string();
assert!(error.contains("isothermal"), "{command}: {error}");
assert_eq!(cold.results_view, before);
}
assert!(!Field::available(cold.latest_result("Flow").unwrap().0).any(|f| f == Field::Temperature));
}
#[test]
fn fluid_display_hostile_params_never_panic() {
let s = fluid();
let point = json!([0.5, 0.5, 0.5]);
let hostile = [
json!(null),
json!([]),
json!({"step": 7}),
json!({"step": "Nope"}),
json!({"field": "nope"}),
json!({"field": 3}),
json!({"axis": "w"}),
json!({"axis": 1}),
json!({"axis": ""}),
json!({"position": 1e308}),
json!({"position": -1e308}),
json!({"position": "middle"}),
json!({"position": [1]}),
json!({"axis": "z", "position": -0.001}),
json!({"frame": -1}),
json!({"frame": 2}),
json!({"frame": 1e300}),
json!({"frame": "last"}),
json!({"show": "yes"}),
json!({"count": 0}),
json!({"count": 2001}),
json!({"count": -5}),
json!({"count": 1e300}),
json!({"count": "many"}),
json!({"max_steps": 0}),
json!({"max_steps": 100_001}),
json!({"max_steps": u64::MAX}),
json!({"seeds": []}),
json!({"seeds": [[1, 2]]}),
json!({"seeds": [[1e300, 0, 0]]}),
json!({"seeds": [[1, "x", 0]]}),
json!({"seeds": "here"}),
json!({"seeds": vec![point.clone(); 2001]}),
json!({"seeds": [point], "line": {"from": [0, 0, 0], "to": [1, 1, 1]}}),
json!({"line": {"from": [0, 0, 0]}}),
json!({"line": 5}),
json!({"line": {"from": [0, 0, 0], "to": [1e13, 0, 0]}}),
json!({"count": 2000, "max_steps": 100_000}),
json!({"seeds": vec![point; 2000], "max_steps": 100_000}),
];
for id in
["results.slice", "results.surface", "results.streamlines", "results.frame", "results.contour", "results.deformed", "results.animate"]
{
for p in &hostile {
let mut t = s.clone();
let _ = t.execute(id, p);
assert_eq!(t.model, s.model);
// Whatever was accepted still draws.
let _ = crate::view::scene(&t, &crate::view::Palette::LIGHT);
}
}
}
#[test]
fn results_hostile_params_never_panic() {
let s = solved();
+149 -5
View File
@@ -100,7 +100,7 @@ pub const HISTORY: CommandSpec = CommandSpec {
pub const FLUID: CommandSpec = CommandSpec {
id: "solve.fluid",
label: "Solve Fluid Step",
params: r#"{"step": "Flow", "max_cells": 16000000, "max_steps": 10000000, "max_cell_updates": 1e12, "max_frame_bytes": 2e9, "threads": 8} — runs a fluid step with the lattice Boltzmann solver (the only fluid step when `step` is left out): obstacles are triangulated from the open design or taken from imported surfaces; returns the time step, relaxation time, Mach number, mass and the final force on every wall; the caps may only be lowered; `threads` as for solve.static"#,
params: r#"{"step": "Flow", "backend": "cpu", "max_cells": 16000000, "max_steps": 10000000, "max_cell_updates": 1e12, "max_frame_bytes": 2e9, "threads": 8} — runs a fluid step with the lattice Boltzmann solver (the only fluid step when `step` is left out): obstacles are triangulated from the open design or taken from imported surfaces; returns the time step, relaxation time, Mach number, mass, the final force on every wall and, with heat, the thermal relaxation time, the temperature range and the heat into every wall; `backend` is `cpu` (the default: double precision, the same result on every machine) or `gpu` (the step on the GPU `FIELDCRAFT_GPU` picks, single precision, within the backend's stated tolerance of the CPU; an error when the build has none); the caps may only be lowered; `threads` as for solve.static"#,
form: Some(super::forms::SOLVE_FLUID),
kind: CommandKind::Model,
requires: &[Requirement::FluidDomain, Requirement::Step],
@@ -110,7 +110,7 @@ pub const FLUID: CommandSpec = CommandSpec {
pub const PROBE: CommandSpec = CommandSpec {
id: "solve.probe",
label: "Probe Results",
params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity, pressure and (with heat) temperature in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories and {"step": "Flow", "heat": ["y_min"]} the heat into them over time (power, positive into the wall)"#,
form: Some(super::forms::SOLVE_PROBE),
kind: CommandKind::Model,
requires: &[Requirement::Result],
@@ -487,10 +487,15 @@ fn run_fluid(s: &mut Session, p: &Value) -> Result<Value> {
max_frame_bytes: cap(p, "max_frame_bytes", defaults.max_frame_bytes)?,
};
let step = opt::<String>(p, "step")?;
let backend = match opt::<String>(p, "backend")? {
None => fieldcraft_lbm::Choice::Cpu,
Some(b) => fieldcraft_lbm::Choice::parse(&b)
.ok_or_else(|| EngineError::Params(format!("`backend` must be `cpu` or `gpu`, not `{}`", b.chars().take(40).collect::<String>())))?,
};
let (_, domain) = fieldcraft_lbm::fluid_step(&s.model, step.as_deref())?;
let obstacles = obstacles(s, domain)?;
let model = &s.model;
let r = with_threads(threads(s, p)?, || fieldcraft_lbm::solve_fluid(model, step.as_deref(), &obstacles, &limits))?;
let r = with_threads(threads(s, p)?, || fieldcraft_lbm::solve_fluid_on(model, step.as_deref(), &obstacles, &limits, backend))?;
let summary = fluid_summary(&r);
s.store_result(StepResult::Fluid(r));
Ok(summary)
@@ -515,8 +520,24 @@ fn fluid_summary(r: &FluidResult) -> Value {
})
.collect();
let (m0, m1) = (r.frames.first().map_or(0.0, |f| f.mass), last.map_or(0.0, |f| f.mass));
let heat: Vec<Value> = r
.heat
.iter()
.filter(|h| h.power.iter().any(|v| *v != 0.0))
.map(|h| {
let recent = h.power.get(h.power.len().saturating_sub(tail)..).unwrap_or_default();
let mean = recent.iter().sum::<f64>() / recent.len().max(1) as f64;
json!({"name": h.name, "body": h.body, "final": h.power.last(), "mean": mean})
})
.collect();
let temperature = last.filter(|f| !f.temperature.is_empty()).map(|f| {
let fluid = f.temperature.iter().zip(&r.solid).filter(|(_, solid)| !**solid).map(|(t, _)| f64::from(*t));
let (lo, hi) = fluid.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), t| (lo.min(t), hi.max(t)));
json!({"min": lo, "max": hi})
});
json!({
"step": r.step,
"backend": r.stats.backend,
"cells": r.stats.cells,
"fluid_cells": r.stats.fluid_cells,
"steps": r.stats.steps,
@@ -532,6 +553,9 @@ fn fluid_summary(r: &FluidResult) -> Value {
"max_speed": max_speed,
"mass": {"first": m0, "last": m1, "relative_change": if m0 != 0.0 { (m1 - m0) / m0 } else { 0.0 }},
"forces": forces,
"tau_thermal": r.stats.tau_thermal,
"temperature": temperature,
"heat": heat,
"warnings": r.warnings,
})
}
@@ -553,6 +577,25 @@ fn probe_fluid(r: &FluidResult, p: &Value) -> Result<Value> {
.collect::<Result<_>>()?;
return Ok(json!({"step": r.step, "times": r.history_times, "forces": out}));
}
if let Some(names) = opt::<Vec<String>>(p, "heat")? {
if names.len() > MAX_PROBE_FORCES {
return Err(EngineError::Params(format!("`heat` lists at most {MAX_PROBE_FORCES} walls")));
}
if r.heat.is_empty() {
return Err(EngineError::Params(format!("step `{}` carries no heat: give its domain `thermal` settings", r.step)));
}
let out: Vec<Value> = names
.iter()
.map(|n| {
let h =
r.heat.iter().find(|h| &h.name == n || h.body.as_ref().is_some_and(|b| format!("{b}/{}", h.name) == *n)).ok_or_else(|| {
EngineError::Params(format!("no wall `{n}` (box faces x_min … z_max, or obstacle faces by name or body/name)"))
})?;
Ok(json!({"name": h.name, "body": h.body, "power": h.power}))
})
.collect::<Result<_>>()?;
return Ok(json!({"step": r.step, "times": r.history_times, "heat": out}));
}
let pts = points(p, "points", MAX_PROBE)?;
let frame = match opt::<usize>(p, "frame")? {
Some(k) if k < r.frames.len() => k,
@@ -563,8 +606,12 @@ fn probe_fluid(r: &FluidResult, p: &Value) -> Result<Value> {
let out: Vec<Value> = pts
.iter()
.map(|&pt| match r.probe(frame, pt) {
Some((cell, velocity, pressure)) => {
json!({"point": pt, "cell": cell, "solid": r.solid.get(cell), "velocity": velocity, "pressure": pressure})
Some((cell, velocity, pressure, temperature)) => {
let mut v = json!({"point": pt, "cell": cell, "solid": r.solid.get(cell), "velocity": velocity, "pressure": pressure});
if let (Some(t), Some(obj)) = (temperature, v.as_object_mut()) {
obj.insert("temperature".into(), json!(t));
}
v
}
None => json!({"point": pt, "cell": null}),
})
@@ -839,6 +886,7 @@ pub(crate) mod tests {
}
use super::*;
use fieldcraft_model::{BoxFace, FluidBoundary, ThermalCondition};
/// A cantilever 10 × 1 × 1 of ten hex8 elements with incompatible modes, clamped at x = 0,
/// a total force of 1 down on the free end; a static and a frequency step.
@@ -1104,6 +1152,95 @@ pub(crate) mod tests {
}
}
/// The Couette gap with heat: the sliding top wall held at 1, the bottom at 0, the fluid's
/// diffusivity k / (ρ c) = 0.1.
fn heated_couette(s: &mut Session) {
couette(s);
heat_the_couette(s);
}
fn heat_the_couette(s: &mut Session) {
s.execute(
"model.material.edit",
&json!({"name": "Oil", "density": 1.0, "fluid": {"type": "newtonian", "viscosity": 0.1},
"thermal": {"conductivity": 0.1, "specific_heat": 1.0}}),
)
.unwrap();
s.execute(
"model.fluid_domain.edit",
&json!({"name": "Gap", "material": "Oil", "min": [0, 0, 0], "max": [0.125, 1, 0.125], "spacing": 0.125,
"faces": {"x_min": {"type": "periodic"}, "x_max": {"type": "periodic"}, "z_min": {"type": "periodic"}, "z_max": {"type": "periodic"},
"y_max": {"type": "moving_wall", "velocity": [0.1, 0, 0]}},
"thermal": {"initial_temperature": 0.5, "faces": {"y_min": {"type": "temperature", "value": 0}}}}),
)
.unwrap();
s.execute("model.fluid_domain.thermal_boundary", &json!({"domain": "Gap", "at": "y_max", "condition": {"type": "temperature", "value": 1}}))
.unwrap();
}
#[test]
fn a_fluid_step_carries_heat_between_its_walls() {
let mut s = Session::default();
heated_couette(&mut s);
let r = s.execute("solve.fluid", &json!({})).unwrap();
assert!(r["tau_thermal"].as_f64().unwrap() > 0.5, "{r}");
assert!((r["temperature"]["min"].as_f64().unwrap() - 0.0625).abs() < 1e-6, "{r}");
assert!((r["temperature"]["max"].as_f64().unwrap() - 0.9375).abs() < 1e-6, "{r}");
// Conduction across the gap: k ΔT / H over the wall's area, out of the hot wall and
// into the cold one.
let expected = 0.1 * 1.0 / 1.0 * 0.125 * 0.125;
let heat = r["heat"].as_array().unwrap();
let cold = heat.iter().find(|h| h["name"] == "y_min").unwrap();
let hot = heat.iter().find(|h| h["name"] == "y_max").unwrap();
assert!((cold["mean"].as_f64().unwrap() / expected - 1.0).abs() < 1e-6, "{cold}");
assert!((hot["mean"].as_f64().unwrap() / expected + 1.0).abs() < 1e-6, "{hot}");
let probe = s.execute("solve.probe", &json!({"step": "Shear", "points": [[0.06, 0.5625, 0.06]]})).unwrap();
assert!((probe["points"][0]["temperature"].as_f64().unwrap() - 0.5625).abs() < 1e-6, "{probe}");
let h = s.execute("solve.probe", &json!({"step": "Shear", "heat": ["y_min", "y_max"]})).unwrap();
assert_eq!(h["heat"].as_array().unwrap().len(), 2);
assert_eq!(h["times"].as_array().unwrap().len(), h["heat"][0]["power"].as_array().unwrap().len());
assert!(s.execute("solve.probe", &json!({"step": "Shear", "heat": ["nope"]})).is_err());
// The thermal boundary needs the domain's heat settings; the material must keep its
// heat properties while a domain carries heat.
let isothermal = json!({"name": "Gap", "material": "Oil", "min": [0, 0, 0], "max": [0.125, 1, 0.125], "spacing": 0.125});
s.execute("model.fluid_domain.edit", &isothermal).unwrap();
assert!(
s.execute("model.fluid_domain.thermal_boundary", &json!({"domain": "Gap", "at": "y_max", "condition": {"type": "adiabatic"}})).is_err()
);
let r = s.execute("solve.fluid", &json!({})).unwrap();
assert!(r["temperature"].is_null() && r["heat"].as_array().unwrap().is_empty(), "{r}");
assert!(s.execute("solve.probe", &json!({"step": "Shear", "heat": ["y_min"]})).is_err());
heat_the_couette(&mut s);
assert!(s.execute("model.material.edit", &json!({"name": "Oil", "density": 1.0, "fluid": {"type": "newtonian", "viscosity": 0.1}})).is_err());
// JSON has no NaN (`json!(f64::NAN)` is `null`): the model refuses one directly.
let nan = ThermalCondition::Temperature { value: f64::NAN };
assert!(s.model.set_fluid_thermal_boundary("Gap", FluidBoundary::Box(BoxFace::YMax), nan).is_err());
let hostile = [
json!({"domain": "Gap", "at": "y_max", "condition": {"type": "temperature", "value": "nan"}}),
json!({"domain": "Gap", "at": "top", "condition": {"type": "temperature", "value": 1}}),
json!({"domain": "Gap", "at": {"body": "Nope", "face": "F1"}, "condition": {"type": "temperature", "value": 1}}),
json!({"domain": "Nope", "at": "y_max", "condition": {"type": "temperature", "value": 1}}),
json!({"domain": "Gap", "at": "y_max", "condition": {"type": "oven"}}),
json!({"domain": "Gap", "at": "y_max"}),
json!(null),
];
for p in &hostile {
let before = s.model.clone();
assert!(s.execute("model.fluid_domain.thermal_boundary", p).is_err(), "{p}");
assert_eq!(s.model, before, "{p}");
}
// A finite flux the lattice cannot hold, or one on a periodic face, is refused by the
// solve, not by the model.
for (at, value) in [("y_max", 1e308), ("x_min", 1.0)] {
let mut t = s.clone();
t.execute("model.fluid_domain.thermal_boundary", &json!({"domain": "Gap", "at": at, "condition": {"type": "flux", "value": value}}))
.unwrap();
assert!(t.execute("solve.fluid", &json!({})).is_err(), "{at}");
}
}
#[test]
fn a_fluid_domain_takes_its_obstacles_from_the_open_design() {
use fieldcraft_geometry::sample::{Plate, plate_design};
@@ -1171,10 +1308,17 @@ pub(crate) mod tests {
json!({"step": "Shear", "forces": ["nope"]}),
json!({"step": "Shear", "forces": vec!["y_min"; 1000]}),
json!({"step": "Shear", "forces": "y_min"}),
json!({"step": "Shear", "heat": ["y_min"]}),
json!({"step": "Shear", "heat": vec!["y_min"; 1000]}),
json!({"step": "Shear", "heat": 3}),
json!({"step": "Shear", "nodes": [1]}),
json!({"step": "Shear", "threads": 0}),
json!({"step": "Shear", "threads": 100000}),
json!({"step": "Shear", "threads": null}),
json!({"step": "Shear", "backend": "tpu"}),
json!({"step": "Shear", "backend": 7}),
// no device backend is registered in this test binary: an error, not a panic
json!({"step": "Shear", "backend": "gpu"}),
];
for id in ["solve.fluid", "solve.probe", "solve.static", "solve.frequency"] {
for p in &hostile {
+680
View File
@@ -0,0 +1,680 @@
//! The display of a fluid step's result: its fields per cell drawn as a coloured slice through the
//! domain, on the obstacles' surfaces beside the fluid, and as streamlines coloured by speed or temperature
//! ([`fieldcraft_render::flow`]), as the results view's [`FlowView`] asks. The headless pictures
//! ([`crate::view`]) and the desktop viewport draw the same [`FlowPlot`]; the part stays drawn
//! (a fluid step does not deform it).
//!
//! A fluid step is first shown with velocity magnitude on a slice through the middle of the
//! domain, normal to the horizontal axis across the flow (the slice holds the flow and the
//! vertical: Z is up), on the obstacles, and with streamlines seeded on the inlet face upstream
//! of the obstacles.
use std::sync::{Arc, Mutex, Weak};
use fieldcraft_lbm::{FluidResult, Frame};
use fieldcraft_render::contour::{Bands, banded, bands_color};
use fieldcraft_render::flow::{self, CellGrid, Slice, Streamline};
use fieldcraft_render::{Aabb3, Rgb, Scene, SceneLine, Vec3};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::results::{Field, Kind, Pick, ResultsView, magnitude};
use crate::{EngineError, Result, StepResult};
pub mod wall;
/// Streamlines traced when no count is given, and the steps (half a cell each) each may take.
pub const DEFAULT_STREAMLINES: u32 = 64;
pub const DEFAULT_STREAMLINE_STEPS: u32 = 4_000;
/// Most streamlines and most steps of one, as commands accept them.
pub const MAX_STREAMLINES: u32 = flow::MAX_STREAMLINES as u32;
pub const MAX_STREAMLINE_STEPS: u32 = flow::MAX_STREAMLINE_STEPS as u32;
/// A streamline's width, in points.
const STREAMLINE_WIDTH: f64 = 1.5;
/// A coordinate axis: the normal of a slice.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Axis {
X,
Y,
Z,
}
impl Axis {
pub const ALL: [Axis; 3] = [Axis::X, Axis::Y, Axis::Z];
/// 0, 1, 2.
pub fn index(self) -> usize {
match self {
Axis::X => 0,
Axis::Y => 1,
Axis::Z => 2,
}
}
pub fn id(self) -> &'static str {
match self {
Axis::X => "x",
Axis::Y => "y",
Axis::Z => "z",
}
}
pub fn parse(id: &str) -> Option<Axis> {
Axis::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(id.trim()))
}
}
/// A slice through the domain: normal to `axis` at `position` along it (`None`: the domain's
/// middle).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SliceView {
pub axis: Axis,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<f64>,
}
/// Streamlines: from `seeds`, or (`None`) `count` seeds on the inlet face upstream of the
/// obstacles; each at most `max_steps` steps of half a cell.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StreamlineView {
pub count: u32,
pub max_steps: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seeds: Option<Vec<[f64; 3]>>,
}
impl Default for StreamlineView {
fn default() -> Self {
StreamlineView { count: DEFAULT_STREAMLINES, max_steps: DEFAULT_STREAMLINE_STEPS, seeds: None }
}
}
/// What a fluid step's display shows besides the field's legend (the view's contour field:
/// velocity, pressure, temperature or wall heat flux; `None` draws no colours).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FlowView {
/// The slice, `None` for none.
pub slice: Option<SliceView>,
/// The obstacles' surfaces coloured by the field in the fluid beside them.
pub surface: bool,
pub streamlines: Option<StreamlineView>,
}
impl FlowView {
/// A fluid result's first look: a slice through the middle across the flow, the surfaces
/// coloured, streamlines from the inlet.
pub fn for_result(r: &FluidResult) -> FlowView {
let along = r.frames.last().map_or(Axis::X, |f| direction(r, f).0);
// The slice holds the flow and the vertical (Z is up); for a flow along y, it is normal to x.
let axis = if along == Axis::Y { Axis::X } else { Axis::Y };
FlowView { slice: Some(SliceView { axis, position: None }), surface: true, streamlines: Some(StreamlineView::default()) }
}
/// What `view` shows of fluid result `r`: its own settings, or the result's first look.
pub fn of<'a>(view: &'a ResultsView, r: &FluidResult) -> std::borrow::Cow<'a, FlowView> {
match &view.flow {
Some(f) => std::borrow::Cow::Borrowed(f),
None => std::borrow::Cow::Owned(FlowView::for_result(r)),
}
}
}
/// The result's grid of cells.
pub fn grid(r: &FluidResult) -> CellGrid {
CellGrid { origin: r.grid.origin, spacing: r.grid.spacing, cells: r.grid.cells }
}
/// The index of the frame of a fluid result `pick` asks for (`None`: the last).
pub fn frame_index(r: &FluidResult, pick: Pick) -> Result<usize> {
let last = r.frames.len().checked_sub(1).ok_or_else(|| EngineError::Params(format!("step `{}` has no frames", r.step)))?;
let k = pick.frame.unwrap_or(last);
if k > last {
return Err(EngineError::Params(format!("step `{}` has frames 0 to {last}; there is no frame {k}", r.step)));
}
Ok(k)
}
/// The frame of a fluid result `pick` asks for (`None`: the last).
pub fn frame(r: &FluidResult, pick: Pick) -> Result<&Frame> {
let k = frame_index(r, pick)?;
r.frames.get(k).ok_or_else(|| EngineError::Params(format!("step `{}` has no frame {k}", r.step)))
}
/// The axis the flow mostly runs along in a frame (by its mean velocity over the fluid cells) and
/// whether it runs toward its high end; along +x when the fluid is at rest.
pub fn direction(r: &FluidResult, f: &Frame) -> (Axis, bool) {
let mut sum = [0.0f64; 3];
for (k, v) in f.velocity.iter().enumerate() {
if !r.solid.get(k).copied().unwrap_or(false) {
for (s, c) in sum.iter_mut().zip(v) {
if c.is_finite() {
*s += f64::from(*c);
}
}
}
}
let axis = Axis::ALL.into_iter().fold(Axis::X, |best, a| if sum[a.index()].abs() > sum[best.index()].abs() { a } else { best });
(axis, sum[axis.index()] >= 0.0)
}
/// A cell field's value in every cell of a frame: velocity magnitude, pressure or temperature, NaN in the
/// solid cells.
pub fn cell_values(r: &FluidResult, field: Field, f: &Frame) -> Result<Vec<f64>> {
let solid = |k: usize| r.solid.get(k).copied().unwrap_or(false);
let values: Vec<f64> = match field {
Field::Velocity => f.velocity.iter().map(|v| magnitude(v.map(f64::from))).collect(),
Field::Pressure => f.pressure.iter().map(|p| f64::from(*p)).collect(),
Field::Temperature if f.temperature.len() == r.grid.len() && !f.temperature.is_empty() => {
f.temperature.iter().map(|t| f64::from(*t)).collect()
}
Field::Temperature => {
return Err(EngineError::Params(format!(
"temperature is not available for fluid step `{}`: this frame has no complete temperature field (isothermal flow)",
r.step
)));
}
_ => {
return Err(EngineError::Params(format!(
"{} is not available for fluid step `{}` (velocity, pressure or temperature)",
field.label(),
r.step
)));
}
};
Ok(values.into_iter().enumerate().map(|(k, v)| if solid(k) || !v.is_finite() { f64::NAN } else { v }).collect())
}
/// The smallest and largest value, with their cells.
pub fn cell_range(values: &[f64]) -> Option<((usize, f64), (usize, f64))> {
let mut out: Option<((usize, f64), (usize, f64))> = None;
for (k, &v) in values.iter().enumerate().filter(|(_, v)| v.is_finite()) {
out = Some(match out {
None => ((k, v), (k, v)),
Some((lo, hi)) => (if v < lo.1 { (k, v) } else { lo }, if v > hi.1 { (k, v) } else { hi }),
});
}
out
}
/// The centre of cell `k` of a result's grid.
pub fn cell_centre(r: &FluidResult, k: usize) -> [f64; 3] {
let g = grid(r);
let c = g.centre(g.coords(k));
[c.x, c.y, c.z]
}
/// Where a slice lies: its position, or the middle of the domain, held inside it.
pub fn slice_position(r: &FluidResult, s: &SliceView) -> f64 {
let g = grid(r);
let a = s.axis.index();
let (lo, hi) = (g.start(a), g.end(a));
s.position.filter(|p| p.is_finite()).unwrap_or(0.5 * (lo + hi)).clamp(lo.min(hi), hi.max(lo))
}
/// The seeds streamlines start from: the given ones, or `count` on the inlet face (where the
/// flow enters the domain) upstream of the obstacles.
pub fn seeds(r: &FluidResult, f: &Frame, s: &StreamlineView) -> Vec<Vec3> {
match &s.seeds {
Some(points) => points.iter().map(|p| Vec3::from(*p)).collect(),
None => {
let (axis, toward_high) = direction(r, f);
flow::face_seeds(&grid(r), &r.solid, axis.index(), !toward_high, s.count as usize)
}
}
}
/// The streamlines a view asks for in a frame, and how many seeds they started from.
pub fn trace(r: &FluidResult, f: &Frame, s: &StreamlineView) -> (usize, Vec<Streamline>) {
let seeds = seeds(r, f, s);
(seeds.len(), flow::streamlines(&grid(r), &f.velocity, &r.solid, &seeds, s.max_steps as usize))
}
/// Streamlines traced once and shared: `results.streamlines` traces them for its reply, and the
/// plot drawn right after takes them from here instead of tracing them again (at the caps, a
/// trace is 2 000 000 points). One entry: the lines of one result, frame and settings, kept
/// while that result lives.
#[derive(Debug, Default)]
pub struct TraceCache(Mutex<Option<Traced>>);
#[derive(Clone, Debug)]
struct Traced {
result: Weak<StepResult>,
frame: usize,
view: StreamlineView,
seeds: usize,
lines: Arc<Vec<Streamline>>,
}
impl Clone for TraceCache {
fn clone(&self) -> Self {
TraceCache(Mutex::new(self.lock().clone()))
}
}
impl TraceCache {
fn lock(&self) -> std::sync::MutexGuard<'_, Option<Traced>> {
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The streamlines `s` asks for in frame `k` of the fluid result `result`, and how many
/// seeds they started from: the kept ones when they are those, else traced now and kept.
pub fn trace(&self, result: &Arc<StepResult>, k: usize, s: &StreamlineView) -> Result<(usize, Arc<Vec<Streamline>>)> {
let Kind::Fluid(r) = result.kind() else { return Err(EngineError::Params("streamlines are traced in fluid steps".into())) };
let f = r.frames.get(k).ok_or_else(|| EngineError::Params(format!("step `{}` has no frame {k}", r.step)))?;
if let Some(t) = self.lock().as_ref()
&& t.frame == k
&& t.view == *s
&& t.result.upgrade().is_some_and(|a| Arc::ptr_eq(&a, result))
{
return Ok((t.seeds, t.lines.clone()));
}
let (seeds, lines) = trace(r, f, s);
let lines = Arc::new(lines);
*self.lock() = Some(Traced { result: Arc::downgrade(result), frame: k, view: s.clone(), seeds, lines: lines.clone() });
Ok((seeds, lines))
}
}
/// A cell field ready to colour: its values, bands and extremes (with their cells).
#[derive(Clone, Debug, PartialEq)]
pub struct CellContour {
pub field: Field,
pub values: Vec<f64>,
pub bands: Bands,
pub min: (usize, f64),
pub max: (usize, f64),
}
/// A fluid step's display, ready to draw ([`draw`]).
#[derive(Clone, Debug, PartialEq)]
pub struct FlowPlot {
/// What it shows: the view's settings, or the result's first look.
pub view: FlowView,
pub grid: CellGrid,
/// The field coloured, when there is one.
pub contour: Option<CellContour>,
/// The slice (with its normal axis and position), when there is one and a field to colour it.
pub slice: Option<(Axis, f64, Slice)>,
pub surface: bool,
/// Shared with the [`TraceCache`] they came from.
pub streamlines: Arc<Vec<Streamline>>,
/// The bands streamlines are coloured by speed over: the legend's when it shows velocity;
/// `None` draws them in one colour (the legend shows another field).
pub speed: Option<Bands>,
/// Face-averaged heat values paired with the displayed geometry triangles.
pub wall_values: Vec<([Vec3; 3], Option<f64>)>,
/// A point on each recorded wall face for the legend's extreme markers.
pub wall_positions: Vec<Option<Vec3>>,
}
impl FlowPlot {
/// The box around what is drawn of the flow: the domain, with a slice or streamlines.
pub fn bounds(&self) -> Aabb3 {
if self.slice.is_some() || !self.streamlines.is_empty() { self.grid.bounds() } else { Aabb3::EMPTY }
}
}
/// The display of the fluid result `result` the view asks for, its streamlines from `traced`.
pub fn plot(view: &ResultsView, result: &Arc<StepResult>, traced: &TraceCache) -> Result<FlowPlot> {
let Kind::Fluid(r) = result.kind() else { return Err(EngineError::Params("the flow display shows fluid steps".into())) };
let k = frame_index(r, view.pick())?;
let f = frame(r, view.pick())?;
let fv = FlowView::of(view, r).into_owned();
let contour = match view.contour.filter(|c| matches!(c, Field::Velocity | Field::Pressure | Field::Temperature)) {
Some(field) => {
let values = cell_values(r, field, f)?;
cell_range(&values).map(|(min, max)| CellContour { field, bands: view.bands(min.1, max.1), min, max, values })
}
None => None,
};
let g = grid(r);
let slice = match (&fv.slice, &contour) {
(Some(s), Some(c)) => {
let at = slice_position(r, s);
flow::slice(&g, &c.values, s.axis.index(), at).map(|sl| (s.axis, at, sl))
}
_ => None,
};
let streamlines = match &fv.streamlines {
Some(s) => traced.trace(result, k, s)?.1,
None => Arc::default(),
};
let speed = contour.as_ref().filter(|c| c.field == Field::Velocity).map(|c| c.bands);
Ok(FlowPlot { grid: g, contour, slice, surface: fv.surface, streamlines, speed, view: fv, wall_values: Vec::new(), wall_positions: Vec::new() })
}
/// Add the obstacle face mapping shared by the desktop and headless views.
pub fn plot_with_surfaces(
view: &ResultsView,
result: &Arc<StepResult>,
traced: &TraceCache,
session: &crate::Session,
base: &fieldcraft_render::DisplayMesh,
bodies: &[&str],
) -> Result<FlowPlot> {
let mut out = plot(view, result, traced)?;
if view.contour == Some(Field::WallHeatFlux) {
let Kind::Fluid(r) = result.kind() else { return Ok(out) };
let faces = wall::faces(session, r, view.pick())?;
let positions: std::collections::BTreeMap<_, _> = base
.surface_tris
.iter()
.filter_map(|t| bodies.get(t.body as usize).map(|name| ((*name, t.face), (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0))))
.collect();
out.wall_positions = faces.iter().map(|f| positions.get(&(f.body.as_str(), f.index)).copied()).collect();
let values: Vec<f64> = faces.iter().map(|f| f.flux.unwrap_or(f64::NAN)).collect();
out.contour =
cell_range(&values).map(|(min, max)| CellContour { field: Field::WallHeatFlux, bands: view.bands(min.1, max.1), min, max, values });
out.wall_values = base.surface_tris.iter().zip(wall::triangle_values(&faces, base, bodies)).map(|(t, v)| (t.p, v)).collect();
}
Ok(out)
}
/// The colours a flow plot is drawn in.
#[derive(Clone, Copy, Debug)]
pub struct FlowColors<'a> {
/// The contour scale, low to high, and values above a clamped legend's maximum.
pub stops: &'a [Rgb],
pub over: Rgb,
/// Streamlines when the legend shows neither velocity nor temperature.
pub plain: Rgb,
}
/// Draw `plot` into `scene`, which holds the model as drawn: its faces beside the fluid
/// coloured by the field (when the surface is shown), then the slice and the streamlines.
pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) {
if let Some(c) = &plot.contour {
let color = |band: Option<u32>| band.map_or(colors.plain, |k| bands_color(colors.stops, colors.over, &c.bands, k));
if plot.surface && c.field == Field::WallHeatFlux {
// Only tagged geometry triangles can receive wall heat. A structural mesh or
// hidden geometry must not accidentally inherit a face's value by index.
if scene.tris.len() == plot.wall_values.len() {
for (tri, (points, value)) in scene.tris.iter_mut().zip(&plot.wall_values) {
if tri.p == *points
&& let Some(v) = value
{
tri.color = color(c.bands.band(*v));
}
}
}
} else if plot.surface {
let model = std::mem::take(&mut scene.tris);
for t in model {
let v = t.p.map(|p| flow::sample(&plot.grid, &c.values, p));
let (sum, known) = v.iter().filter(|x| !x.is_nan()).fold((0.0, 0u32), |(s, n), x| (s + x, n + 1));
if known == 0 {
scene.tris.push(t);
continue;
}
let mean = sum / f64::from(known);
banded(t.p, v.map(|x| if x.is_nan() { mean } else { x }), t.normal, &c.bands, &color, &mut scene.tris);
}
}
if let Some((_, _, s)) = &plot.slice {
for t in &s.tris {
banded(t.p, t.v, s.normal, &c.bands, &color, &mut scene.tris);
}
}
}
for l in plot.streamlines.iter() {
for (k, w) in l.points.windows(2).enumerate() {
let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue };
let speed = 0.5 * (l.speed.get(k).copied().unwrap_or(0.0) + l.speed.get(k + 1).copied().unwrap_or(0.0));
let temperature = plot.contour.as_ref().filter(|c| c.field == Field::Temperature);
let color = if let Some(c) = temperature {
let value = flow::sample(&plot.grid, &c.values, (a + b) * 0.5);
c.bands.band(value).map(|k| bands_color(colors.stops, colors.over, &c.bands, k))
} else {
plot.speed.as_ref().and_then(|b| b.band(speed).map(|k| bands_color(colors.stops, colors.over, b, k)))
}
.unwrap_or(colors.plain);
scene.lines.push(SceneLine { a, b, color, width: STREAMLINE_WIDTH });
}
}
}
/// What a fluid step's display shows, for the `results.*` replies: the field's range with the
/// cells (and their centres) of its extremes, the slice and the streamlines' settings.
pub fn report(view: &ResultsView, r: &FluidResult, units: fieldcraft_model::UnitSystem) -> Value {
let mut out = json!({});
let Ok(f) = frame(r, view.pick()) else { return out };
out["time"] = json!(f.time);
if let Some(field) = view.contour
&& let Ok(values) = cell_values(r, field, f)
{
let end = |(k, v): (usize, f64)| json!({"cell": k, "at": cell_centre(r, k), "value": v});
let range = cell_range(&values).map(|(lo, hi)| json!({"min": end(lo), "max": end(hi)}));
let unit = field.quantity().unit(units);
out["contour"] = json!({"field": field, "label": field.label(), "unit": unit, "range": range});
if view.range_min.is_some() || view.range_max.is_some() {
out["contour"]["legend"] = json!({"min": view.range_min, "max": view.range_max});
}
}
let fv = FlowView::of(view, r);
out["slice"] = match fv.slice.as_ref().filter(|_| view.contour != Some(Field::WallHeatFlux)) {
Some(s) => json!({"axis": s.axis, "position": slice_position(r, s)}),
None => Value::Null,
};
out["surface"] = json!(fv.surface);
out["streamlines"] = match &fv.streamlines {
Some(s) => {
let mut v = json!({"count": s.count, "max_steps": s.max_steps});
match &s.seeds {
Some(points) => v["seeds"] = json!(points.len()),
None => {
let (axis, high) = direction(r, f);
v["inlet"] = json!(format!("{}_{}", axis.id(), if high { "min" } else { "max" }));
}
}
v
}
None => Value::Null,
};
out
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use fieldcraft_lbm::{FluidStats, Grid};
/// A synthetic fluid result: a 10 × 4 × 4 grid of unit cells, a solid 2 × 2 × 2 block at
/// x, y, z in [5, 7] × [1, 3] × [1, 3], the fluid moving along +x at the speed of its x and
/// the pressure falling along x.
pub(crate) fn channel() -> FluidResult {
let grid = Grid { origin: [0.0; 3], spacing: 1.0, cells: [10, 4, 4] };
let n = grid.len();
let solid: Vec<bool> = (0..n).map(|c| matches!(grid.coords(c), [5 | 6, 1 | 2, 1 | 2])).collect();
let velocity: Vec<[f32; 3]> = (0..n).map(|c| if solid[c] { [0.0; 3] } else { [grid.coords(c)[0] as f32 + 1.0, 0.0, 0.0] }).collect();
let pressure: Vec<f32> = (0..n).map(|c| if solid[c] { 0.0 } else { 10.0 - grid.coords(c)[0] as f32 }).collect();
let at_rest = Frame { time: 0.0, step: 0, velocity: vec![[0.0; 3]; n], pressure: vec![0.0; n], temperature: Vec::new(), mass: 1.0 };
let last = Frame { time: 1.0, step: 10, velocity, pressure, temperature: Vec::new(), mass: 1.0 };
let stats = FluidStats {
cells: n,
fluid_cells: n - 8,
steps: 10,
time_step: 0.1,
end_time: 1.0,
tau_plus: 1.0,
tau_minus: 1.0,
magic: 0.25,
velocity: 1.0,
lattice_velocity: 0.1,
mach: 0.1,
cell_updates: 0,
backend: "cpu".into(),
tau_thermal: None,
};
FluidResult {
step: "Flow".into(),
grid,
solid,
frames: vec![at_rest, last],
history_times: Vec::new(),
forces: Vec::new(),
heat: Vec::new(),
stats,
warnings: Vec::new(),
}
}
/// A 40 × 30 × 10 mm plate (the geometry sample) in a tank 20 mm larger on every side, of
/// 5 mm cells, a liquid flowing along x past it: solved, so its result is shown.
pub(crate) fn plate_in_a_tank() -> crate::Session {
use fieldcraft_geometry::sample::{Plate, plate_design};
let dir = std::env::temp_dir().join(format!("fieldcraft-flow-view-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("plate.solidcraft");
std::fs::write(&path, plate_design(&Plate::default()).unwrap()).unwrap();
let mut s = crate::Session::default();
s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let script = json!([
{"command": "model.material.create", "params": {"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}}},
{"command": "model.fluid_domain.create", "params": {"name": "Tank", "material": "Water", "min": [-20, -20, -20], "max": [60, 50, 30], "spacing": 5, "obstacles": ["Body1"],
"faces": {"x_min": {"type": "velocity", "velocity": [10, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}}}},
{"command": "model.step.create", "params": {"name": "Flow", "procedure": {"type": "fluid", "domain": "Tank", "steps": 60, "frames": 2}}},
]);
s.run_script(&script).unwrap();
s.execute("solve.fluid", &json!({})).unwrap();
s
}
#[test]
fn a_fluid_result_is_first_shown_across_the_flow() {
let r = channel();
assert_eq!(direction(&r, &r.frames[1]), (Axis::X, true));
let v = FlowView::for_result(&r);
assert_eq!(v.slice, Some(SliceView { axis: Axis::Y, position: None }));
assert!(v.surface && v.streamlines.is_some());
assert_eq!(slice_position(&r, v.slice.as_ref().unwrap()), 2.0);
assert_eq!(slice_position(&r, &SliceView { axis: Axis::Z, position: Some(99.0) }), 4.0);
assert_eq!(slice_position(&r, &SliceView { axis: Axis::Z, position: Some(f64::NAN) }), 2.0);
// A flow along -y is sliced normal to x and seeded on the y_max face.
let mut r = channel();
for v in &mut r.frames[1].velocity {
*v = [0.0, -v[0], 0.0];
}
assert_eq!(direction(&r, &r.frames[1]), (Axis::Y, false));
assert_eq!(FlowView::for_result(&r).slice.unwrap().axis, Axis::X);
let s = seeds(&r, &r.frames[1], &StreamlineView { count: 4, ..StreamlineView::default() });
assert!(s.iter().all(|p| p.y == 3.5), "{s:?}");
}
#[test]
fn temperature_colours_cells_and_streamlines_and_masks_solids() {
let mut r = channel();
for f in &mut r.frames {
f.temperature = (0..r.grid.len()).map(|k| 20.0 + 2.0 * r.grid.coords(k)[0] as f32).collect();
}
let values = cell_values(&r, Field::Temperature, &r.frames[1]).unwrap();
assert_eq!(values.iter().filter(|v| v.is_nan()).count(), 8);
assert_eq!(cell_range(&values).map(|(a, b)| (a.1, b.1)), Some((20.0, 38.0)));
let result = Arc::new(crate::StepResult::Fluid(r));
let mut view = ResultsView::for_result(&result);
view.contour = Some(Field::Temperature);
let p = plot(&view, &result, &TraceCache::default()).unwrap();
let mut scene = Scene::default();
let colors = FlowColors { stops: &[Rgb(0, 0, 255), Rgb(255, 0, 0)], over: Rgb(255, 255, 255), plain: Rgb(7, 7, 7) };
draw(&p, &mut scene, &colors);
assert!(!scene.tris.is_empty());
assert!(!scene.lines.is_empty());
assert!(scene.lines.iter().all(|l| l.color != colors.plain));
assert!(scene.lines.windows(2).any(|l| l[0].color != l[1].color));
let cold = channel();
assert!(cell_values(&cold, Field::Temperature, &cold.frames[1]).unwrap_err().to_string().contains("isothermal"));
}
#[test]
fn cell_fields_leave_the_solid_out() {
let r = channel();
let f = &r.frames[1];
let speed = cell_values(&r, Field::Velocity, f).unwrap();
assert_eq!(speed.iter().filter(|v| v.is_nan()).count(), 8);
let ((lo, a), (hi, b)) = cell_range(&speed).unwrap();
assert_eq!((a, b, r.grid.coords(lo)[0], r.grid.coords(hi)[0]), (1.0, 10.0, 0, 9));
assert_eq!(cell_centre(&r, hi), [9.5, 0.5, 0.5]);
let p = cell_values(&r, Field::Pressure, f).unwrap();
assert_eq!(cell_range(&p).map(|(lo, hi)| (lo.1, hi.1)), Some((1.0, 10.0)));
assert!(cell_values(&r, Field::VonMises, f).is_err());
assert_eq!(cell_range(&[f64::NAN]), None);
assert!(frame(&r, Pick { mode: 1, frame: Some(2) }).is_err());
assert_eq!(frame(&r, Pick::default()).unwrap().time, 1.0);
}
/// A trace is kept for its result, frame and settings: the reply and the plot share one;
/// another frame, other settings or another result trace anew.
#[test]
fn a_trace_is_shared_until_its_result_frame_or_settings_change() {
let result = Arc::new(crate::StepResult::Fluid(channel()));
let view = ResultsView::for_result(&result);
let traced = TraceCache::default();
let s = StreamlineView::default();
let (seeds, a) = traced.trace(&result, 1, &s).unwrap();
assert!(seeds > 0 && !a.is_empty());
let (_, b) = traced.trace(&result, 1, &s).unwrap();
assert!(Arc::ptr_eq(&a, &b), "the same trace again");
let p = plot(&view, &result, &traced).unwrap();
assert!(Arc::ptr_eq(&a, &p.streamlines), "the plot draws the kept trace");
let (_, c) = traced.trace(&result, 0, &s).unwrap();
assert!(!Arc::ptr_eq(&a, &c), "another frame: another trace");
let (_, d) = traced.trace(&result, 1, &StreamlineView { count: 3, ..s.clone() }).unwrap();
assert!(!Arc::ptr_eq(&a, &d) && d.len() < a.len(), "other settings: another trace ({} lines, then {})", a.len(), d.len());
let other = Arc::new(crate::StepResult::Fluid(channel()));
let (_, e) = traced.trace(&other, 1, &StreamlineView { count: 3, ..s.clone() }).unwrap();
assert!(!Arc::ptr_eq(&d, &e), "another result");
assert!(traced.trace(&result, 2, &s).is_err(), "no frame 2");
let cloned = traced.clone();
assert!(Arc::ptr_eq(&cloned.trace(&other, 1, &StreamlineView { count: 3, ..s }).unwrap().1, &e), "a clone keeps the trace");
let solid = Arc::new(crate::StepResult::Fluid(channel()));
let mut v = view.clone();
v.step = "Other".into();
assert!(plot(&v, &solid, &traced).is_ok());
}
/// The plot colours the slice, the faces beside the fluid and the streamlines by speed.
#[test]
fn the_plot_draws_the_slice_surface_and_streamlines() {
let r = channel();
let result = Arc::new(crate::StepResult::Fluid(r.clone()));
let mut view = ResultsView::for_result(&result);
assert_eq!(view.contour, Some(Field::Velocity));
let traced = TraceCache::default();
let p = plot(&view, &result, &traced).unwrap();
let (axis, at, slice) = p.slice.as_ref().unwrap();
assert_eq!((*axis, *at), (Axis::Y, 2.0));
assert!(!slice.tris.is_empty());
assert!(!p.streamlines.is_empty() && p.speed.is_some());
assert_eq!(p.bounds(), p.grid.bounds());
// A face of the block, at x = 5, and one far off the grid.
let tri = |x: f64| fieldcraft_render::SceneTri {
p: [Vec3::new(x, 1.0, 1.0), Vec3::new(x, 3.0, 1.0), Vec3::new(x, 1.0, 3.0)],
normal: Vec3::X,
color: Rgb(1, 2, 3),
};
let mut scene = Scene { tris: vec![tri(5.0), tri(500.0)], ..Scene::default() };
let stops = [Rgb(0, 0, 255), Rgb(255, 0, 0)];
let colors = FlowColors { stops: &stops, over: Rgb(9, 9, 9), plain: Rgb(7, 7, 7) };
draw(&p, &mut scene, &colors);
assert!(scene.tris.iter().any(|t| t.color == Rgb(1, 2, 3)), "the face off the grid keeps its colour");
let coloured = scene.tris.iter().filter(|t| t.color != Rgb(1, 2, 3)).count();
assert!(coloured > slice.tris.len(), "the slice and the block's face are coloured");
assert_eq!(scene.lines.len(), p.streamlines.iter().map(|l| l.points.len() - 1).sum::<usize>());
assert!(scene.lines.iter().all(|l| l.color != colors.plain));
// Pressure: streamlines in the plain colour; no field: no slice, no colours.
view.contour = Some(Field::Pressure);
let p = plot(&view, &result, &traced).unwrap();
let mut scene = Scene::default();
draw(&p, &mut scene, &colors);
assert!(scene.lines.iter().all(|l| l.color == colors.plain));
view.contour = None;
let p = plot(&view, &result, &traced).unwrap();
assert!(p.slice.is_none() && p.contour.is_none() && !p.streamlines.is_empty());
view.flow = Some(FlowView { slice: None, surface: false, streamlines: None });
view.contour = Some(Field::Velocity);
let p = plot(&view, &result, &traced).unwrap();
assert!(p.slice.is_none() && p.streamlines.is_empty() && p.bounds().is_empty());
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Face-averaged heat received by obstacle walls, in the model's consistent units.
//! The history is interpolated only between recorded samples: frame zero predates it.
use std::collections::BTreeMap;
use fieldcraft_lbm::FluidResult;
use fieldcraft_render::{DisplayMesh, Vec3};
use serde::Serialize;
use crate::results::Pick;
use crate::{EngineError, Result, Session};
#[derive(Clone, Debug, Serialize)]
pub struct FaceHeat {
pub body: String,
pub face: String,
pub area: f64,
/// Positive into the wall. None before the first recorded history sample.
pub power: Option<f64>,
pub flux: Option<f64>,
#[serde(skip)]
pub index: u32,
}
fn interpolate(times: &[f64], values: &[f64], time: f64) -> Option<f64> {
if !time.is_finite()
|| times.len() != values.len()
|| times.is_empty()
|| times.iter().chain(values).any(|v| !v.is_finite())
|| times.windows(2).any(|p| p[0] >= p[1])
{
return None;
}
let k = times.partition_point(|t| *t < time);
if times.get(k) == Some(&time) {
return values.get(k).copied();
}
let lo = k.checked_sub(1)?;
let (a, b) = (*times.get(lo)?, *times.get(k)?);
let weight = (time - a) / (b - a);
let value = (1.0 - weight) * values.get(lo)? + weight * values.get(k)?;
value.is_finite().then_some(value)
}
/// All recorded obstacle faces, using their complete tessellated geometric area (not the
/// visible triangle area or a lattice-link count). Missing geometry is an error, never zero.
pub fn faces(s: &Session, r: &FluidResult, pick: Pick) -> Result<Vec<FaceHeat>> {
if s.latest_result(&r.step).is_some_and(|(_, current)| !current) {
return Err(EngineError::Params("wall heat requires current geometry: solve the edited model again before dividing by face area".into()));
}
let time = super::frame(r, pick)?.time;
let mut areas = BTreeMap::new();
let mut output = Vec::new();
for h in r.heat.iter().filter(|h| h.body.is_some()) {
let Some(name) = h.body.as_deref() else { continue };
let body = s
.design
.iter()
.flat_map(|d| &d.bodies)
.find(|b| b.name == name)
.ok_or_else(|| EngineError::Params(format!("wall heat: obstacle `{name}` is no longer in the design")))?;
let index = body
.face_index(&h.name)
.ok_or_else(|| EngineError::Params(format!("wall heat: face `{}` of `{name}` is no longer in the design", h.name)))?;
if let std::collections::btree_map::Entry::Vacant(entry) = areas.entry(name.to_owned()) {
let mesh = body.tessellate((body.size()? * 0.001).max(1e-4))?;
let mut by_face = BTreeMap::<u32, f64>::new();
for (tri, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
let point = |k: u32| mesh.positions.get(k as usize).copied().map(Vec3::from);
let (Some(a), Some(b), Some(c)) = (point(tri[0]), point(tri[1]), point(tri[2])) else { continue };
*by_face.entry(*face).or_default() += 0.5 * (b - a).cross(c - a).len();
}
entry.insert(by_face);
}
let area = areas
.get(name)
.and_then(|a| a.get(&index))
.copied()
.filter(|a| a.is_finite() && *a > 0.0)
.ok_or_else(|| EngineError::Params(format!("wall heat: face `{}` of `{name}` has no finite positive area", h.name)))?;
let power = interpolate(&r.history_times, &h.power, time);
let flux = power.map(|q| q / area).filter(|q| q.is_finite());
output.push(FaceHeat { body: name.into(), face: h.name.clone(), index, area, power, flux });
}
Ok(output)
}
/// One optional value per displayed surface triangle. Imported/unrecorded faces remain plain.
pub fn triangle_values(faces: &[FaceHeat], base: &DisplayMesh, bodies: &[&str]) -> Vec<Option<f64>> {
let by_face: BTreeMap<_, _> = faces.iter().map(|f| ((f.body.as_str(), f.index), f.flux)).collect();
base.surface_tris
.iter()
.map(|t| {
let body = bodies.get(t.body as usize)?;
by_face.get(&(*body, t.face)).copied().flatten()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn wall_heat_uses_face_identity_area_sign_and_frame_in_both_views() {
let mut session = crate::flow::tests::plate_in_a_tank();
let crate::StepResult::Fluid(mut r) = session.latest_result("Flow").unwrap().0.as_ref().clone() else { panic!() };
let names: Vec<String> = session.design.as_ref().unwrap().bodies[0].faces().iter().map(|f| f.name.clone()).collect();
let end = r.frames.last().unwrap().time;
r.history_times = vec![end * 0.5, end];
r.heat = names
.iter()
.enumerate()
.map(|(k, name)| fieldcraft_lbm::HeatHistory {
name: name.clone(),
body: Some("Body1".into()),
power: vec![0.0, if k == 0 { -120.0 } else { 0.0 }],
})
.collect();
for f in &mut r.frames {
f.temperature = vec![20.0; r.grid.len()];
}
let face_values = faces(&session, &r, Pick::default()).unwrap();
assert!(face_values.iter().all(|f| f.area > 0.0));
assert!(face_values.iter().any(|f| (f.area - 400.0).abs() < 1e-8), "40 by 10 mm outer face");
assert!(face_values.iter().any(|f| (f.area - 300.0).abs() < 1e-8), "30 by 10 mm outer face");
assert!(face_values[0].flux.unwrap() < 0.0);
assert!((face_values.iter().map(|f| f.flux.unwrap() * f.area).sum::<f64>() + 120.0).abs() < 1e-10);
assert!(face_values[1..].iter().all(|f| f.flux == Some(0.0)), "adiabatic faces are zero");
session.store_result(crate::StepResult::Fluid(r));
let report = session.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
assert_eq!(report["wall_heat"]["positive"], "into wall");
assert_eq!(report["wall_heat"]["faces"][0]["power"], -120.0);
assert!(report["slice"].is_null(), "wall heat is a surface-only field");
session.execute("results.streamlines", &json!({"show": false})).unwrap();
let (scene, _, shown) = crate::view::scene(&session, &crate::view::Palette::LIGHT);
assert!(shown.contour.as_ref().is_some_and(|c| c.0 == "wall_heat_flux"));
assert!(scene.tris.windows(2).any(|t| t[0].color != t[1].color));
let report = session.execute("results.frame", &json!({"frame": 0})).unwrap();
assert!(report["wall_heat"]["faces"].as_array().unwrap().iter().all(|f| f["flux"].is_null()));
assert!(session.execute("results.slice", &json!({"field": "wall_heat_flux"})).is_err());
assert!(session.execute("results.streamlines", &json!({"field": "wall_heat_flux"})).is_err());
}
#[test]
fn histories_interpolate_without_fabricating_initial_heat() {
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 2.0), Some(2.0));
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 1.0), Some(-4.0));
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 0.0), None);
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 4.0), None);
assert_eq!(interpolate(&[1.0, 1.0], &[0.0, 1.0], 1.0), None);
assert_eq!(interpolate(&[1.0], &[f64::NAN], 1.0), None);
assert_eq!(interpolate(&[1.0], &[], 1.0), None);
}
}
+5
View File
@@ -17,6 +17,7 @@ pub mod bug_report;
pub mod catalogue;
pub mod cmd;
pub mod crash;
pub mod flow;
pub mod logfile;
pub use cmd::analytic_surfaces;
@@ -86,6 +87,7 @@ impl EngineError {
matches!(
self,
EngineError::Model(Cancelled)
| EngineError::Geometry(fieldcraft_geometry::Error::Model(Cancelled))
| EngineError::Mesh(fieldcraft_mesh::Error::Model(Cancelled))
| EngineError::Solver(fieldcraft_solver::Error::Model(Cancelled))
| EngineError::Explicit(fieldcraft_explicit::Error::Model(Cancelled))
@@ -298,6 +300,9 @@ pub struct Session {
/// What the last `geometry.check` found, drawn as markers while the design it checked is
/// open ([`view::geometry_marks`]). Not an edit: neither journalled, saved nor undone.
pub geometry_marks: Option<Arc<view::GeometryMarks>>,
/// The streamlines last traced for a fluid step's display ([`flow::TraceCache`]): the
/// `results.streamlines` reply and the plot share them. Not an edit either.
pub traced: flow::TraceCache,
}
impl Session {
+64 -9
View File
@@ -1,6 +1,7 @@
//! What the results display shows ([`ResultsView`], set by the `results.*` commands) and the
//! nodal values it draws: displacements, mode shapes, explicit field frames and stress fields,
//! read from a solved step's [`StepResult`]. An explicit frame's one-point element stresses are
//! read from a solved step's [`StepResult`]; a fluid step's cell fields are drawn by
//! [`crate::flow`]. An explicit frame's one-point element stresses are
//! averaged to the nodes (unweighted, like the implicit solver's nodal stresses).
//!
//! The view is session state, like the camera: changing it is not an edit, so it is neither
@@ -21,7 +22,7 @@ pub enum Kind<'a> {
Static(&'a StaticResult),
Frequency(&'a FrequencyResult),
Explicit(&'a ExplicitResult),
/// Per cell, not per node: no nodal fields to display yet.
/// Per cell, not per node: drawn by [`crate::flow`].
Fluid(&'a fieldcraft_lbm::FluidResult),
Heat(&'a HeatResult),
}
@@ -72,8 +73,10 @@ pub enum Field {
DisplacementX,
DisplacementY,
DisplacementZ,
/// Velocity magnitude (explicit steps).
/// Velocity magnitude (explicit steps; per cell in fluid steps).
Velocity,
/// Gauge pressure (per cell, fluid steps).
Pressure,
VonMises,
MaxPrincipal,
MidPrincipal,
@@ -88,6 +91,8 @@ pub enum Field {
Temperature,
/// Heat flux magnitude (heat transfer steps, at the end of the step).
HeatFlux,
/// Signed fluid heat received by an obstacle face per geometric area.
WallHeatFlux,
/// Contact pressure (nonlinear static steps with contact: 0 off the contacts' secondary
/// surfaces).
ContactPressure,
@@ -103,12 +108,13 @@ pub enum Field {
}
impl Field {
pub const ALL: [Field; 22] = [
pub const ALL: [Field; 24] = [
Field::Displacement,
Field::DisplacementX,
Field::DisplacementY,
Field::DisplacementZ,
Field::Velocity,
Field::Pressure,
Field::VonMises,
Field::MaxPrincipal,
Field::MidPrincipal,
@@ -121,6 +127,7 @@ impl Field {
Field::StressYz,
Field::Temperature,
Field::HeatFlux,
Field::WallHeatFlux,
Field::ContactPressure,
Field::ContactGap,
Field::ContactStatus,
@@ -136,6 +143,7 @@ impl Field {
Field::DisplacementY => "displacement_y",
Field::DisplacementZ => "displacement_z",
Field::Velocity => "velocity",
Field::Pressure => "pressure",
Field::VonMises => "von_mises",
Field::MaxPrincipal => "max_principal",
Field::MidPrincipal => "mid_principal",
@@ -148,6 +156,7 @@ impl Field {
Field::StressYz => "stress_yz",
Field::Temperature => "temperature",
Field::HeatFlux => "heat_flux",
Field::WallHeatFlux => "wall_heat_flux",
Field::ContactPressure => "contact_pressure",
Field::ContactGap => "contact_gap",
Field::ContactStatus => "contact_status",
@@ -163,6 +172,7 @@ impl Field {
Field::DisplacementY => "Displacement Y",
Field::DisplacementZ => "Displacement Z",
Field::Velocity => "Velocity magnitude",
Field::Pressure => "Pressure (gauge)",
Field::VonMises => "Von Mises stress",
Field::MaxPrincipal => "Max. principal stress",
Field::MidPrincipal => "Mid. principal stress",
@@ -175,6 +185,7 @@ impl Field {
Field::StressYz => "Stress YZ",
Field::Temperature => "Temperature",
Field::HeatFlux => "Heat flux magnitude",
Field::WallHeatFlux => "Wall heat flux (into wall)",
Field::ContactPressure => "Contact pressure",
Field::ContactGap => "Contact gap",
Field::ContactStatus => "Contact status (0 open, 1 closed or stick, 2 slip)",
@@ -188,8 +199,9 @@ impl Field {
match self {
Field::Displacement | Field::DisplacementX | Field::DisplacementY | Field::DisplacementZ | Field::ContactSlip => Quantity::Length,
Field::Velocity => Quantity::Velocity,
Field::Pressure => Quantity::Stress,
Field::Temperature => Quantity::Temperature,
Field::HeatFlux => Quantity::HeatFlux,
Field::HeatFlux | Field::WallHeatFlux => Quantity::HeatFlux,
Field::ContactGap | Field::ContactStatus => Quantity::None,
Field::ContactPressure
| Field::ContactShear
@@ -239,8 +251,10 @@ impl Field {
| Field::DisplacementY
| Field::DisplacementZ
| Field::Velocity
| Field::Pressure
| Field::Temperature
| Field::HeatFlux
| Field::WallHeatFlux
) && !self.is_contact()
}
@@ -264,14 +278,25 @@ impl Field {
Kind::Explicit(e) => {
e.frames.last().map_or((false, false, false, false), |f| (!f.stress.is_empty(), !f.velocity.is_empty(), false, false))
}
// Cell fields have their own display (M12 streamlines); no nodal field.
Kind::Fluid(_) => return Vec::new().into_iter(),
// Cell fields, drawn by `crate::flow`.
Kind::Fluid(f) => {
let mut fields = vec![Field::Velocity, Field::Pressure];
if !f.frames.is_empty() && f.frames.iter().all(|frame| !frame.temperature.is_empty() && frame.temperature.len() == f.grid.len()) {
fields.push(Field::Temperature);
if f.heat.iter().any(|h| h.body.is_some()) {
fields.push(Field::WallHeatFlux);
}
}
return fields.into_iter();
}
Kind::Heat(_) => return Field::ALL.into_iter().filter(|f| f.is_thermal()).collect::<Vec<_>>().into_iter(),
};
Field::ALL
.into_iter()
.filter(move |f| match f {
Field::Velocity => velocity,
Field::Pressure => false,
Field::WallHeatFlux => false,
Field::Temperature => temperature,
Field::HeatFlux => flux,
f if f.is_contact() => contact,
@@ -385,6 +410,9 @@ pub struct ResultsView {
pub frame: Option<usize>,
/// Animate the deformation (a mode swings through ±, a static step grows from 0).
pub animate: bool,
/// A fluid step's slice, surface colours and streamlines.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow: Option<crate::flow::FlowView>,
}
impl ResultsView {
@@ -393,11 +421,18 @@ impl ResultsView {
/// shape, at the automatic scale); a frequency step's first mode deformed with displacement
/// contours (a mode shape is a deformation); an explicit step at its last frame and a
/// nonlinear static step at its last increment, deformed to true scale ([`true_scale`];
/// with von Mises contours when they kept stresses); a heat step undeformed.
/// with von Mises contours when they kept stresses); a heat step undeformed; a fluid step's
/// velocity magnitude on a slice, the obstacles and streamlines ([`crate::flow::FlowView`]).
pub fn for_result(r: &StepResult) -> ResultsView {
let heat = matches!(r.kind(), Kind::Heat(_));
let flow = match r.kind() {
Kind::Fluid(f) => Some(crate::flow::FlowView::for_result(f)),
_ => None,
};
let contour = if heat {
Field::Temperature
} else if flow.is_some() {
Field::Velocity
} else if Field::available(r).any(|f| f == Field::VonMises) {
Field::VonMises
} else {
@@ -415,6 +450,7 @@ impl ResultsView {
mode: 1,
frame: None,
animate: false,
flow,
}
}
@@ -515,6 +551,8 @@ pub fn field_values(model: &Model, r: &StepResult, field: Field, pick: Pick) ->
}
Kind::Static(_) | Kind::Frequency(_) | Kind::Fluid(_) | Kind::Heat(_) => Err(unavailable()),
},
// Per cell (crate::flow::cell_values), not per node.
Field::Pressure | Field::WallHeatFlux => Err(unavailable()),
Field::Temperature => match r.kind() {
Kind::Heat(h) => Ok(heat_frame(h, pick)?.1.to_vec()),
Kind::Static(StaticResult { temperature: Some(t), .. }) => Ok(t.clone()),
@@ -679,6 +717,23 @@ impl Session {
}
Kind::Fluid(f) => {
out["frames"] = f.frames.iter().map(|f| f.time).collect();
if let Value::Object(flow) = crate::flow::report(v, f, self.model.units()) {
for (k, x) in flow {
out[k.as_str()] = x;
}
}
if v.contour == Some(Field::WallHeatFlux)
&& let Ok(faces) = crate::flow::wall::faces(self, f, v.pick())
{
let values: Vec<f64> = faces.iter().map(|f| f.flux.unwrap_or(f64::NAN)).collect();
let end = |(k, value): (usize, f64)| json!({"body": faces.get(k).map(|f| &f.body), "face": faces.get(k).map(|f| &f.face), "value": value});
let range = crate::flow::cell_range(&values).map(|(a, b)| json!({"min": end(a), "max": end(b)}));
out["contour"] = json!({"field": Field::WallHeatFlux, "label": Field::WallHeatFlux.label(), "unit": Quantity::HeatFlux.unit(self.model.units()), "range": range});
if v.range_min.is_some() || v.range_max.is_some() {
out["contour"]["legend"] = json!({"min": v.range_min, "max": v.range_max});
}
out["wall_heat"] = json!({"faces": faces, "positive": "into wall", "history": "linear interpolation between recorded samples; no extrapolation", "unrecorded": "uncoloured"});
}
}
Kind::Heat(h) => {
out["heat_flows"] = heat_flows(h);
@@ -746,7 +801,7 @@ mod tests {
}
let (modes, _) = s.latest_result("Modes").unwrap();
assert_eq!(Field::available(modes).count(), 4);
assert_eq!(Field::available(bend).count(), Field::ALL.len() - 8, "all but velocity, the thermal and the contact fields");
assert_eq!(Field::available(bend).count(), Field::ALL.len() - 10, "all but velocity, pressure, the thermal and the contact fields");
assert!(field_values(&s.model, modes, Field::VonMises, Pick { mode: 1, frame: None }).is_err());
assert!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 9, frame: None }).is_err());
assert_eq!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 2, frame: None }).unwrap().len(), modes.nodes().len());
+10
View File
@@ -18,6 +18,8 @@
use fieldcraft_geometry::Shape;
use serde_json::{Value, json};
mod heated_flow;
/// A sample the app offers (Help › Samples, the empty viewport, `file.sample`, `--sample NAME`).
pub struct Sample {
pub name: &'static str,
@@ -92,6 +94,14 @@ pub const SAMPLES: &[Sample] = &[
prepare: None,
script: |_| flow_past(),
},
Sample {
name: "heated-flow",
label: "Heated Flow Past a Bracket",
about: "Cool air passing an 80 °C bracket: temperature slices and signed wall heat, a small transient demonstration",
design: Some(UPRIGHT_BRACKET),
prepare: None,
script: heated_flow::script,
},
Sample {
name: "heated-bracket",
label: "Heated Bracket",
+58
View File
@@ -0,0 +1,58 @@
//! A small transient heated-flow demonstration using the original upright bracket.
//! Air at 20 °C passes an 80 °C bracket; no buoyancy or conjugate solid conduction.
use fieldcraft_geometry::Shape;
use serde_json::{Value, json};
pub(super) fn script(bodies: &[Shape]) -> Value {
let walls: Vec<Value> = bodies
.iter()
.filter(|b| b.name == "Bracket")
.flat_map(|b| {
b.faces().iter().map(move |f| {
json!({
"face": {"body": b.name, "face": f.name},
"condition": {"type": "temperature", "value": 80.0}
})
})
})
.collect();
let [density, viscosity] = super::AIR;
json!([
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
{"command": "model.material.create", "params": {"name": "Air", "density": density,
"fluid": {"type": "newtonian", "viscosity": viscosity},
"thermal": {"conductivity": 0.026, "specific_heat": 1.005e9}}},
{"command": "model.fluid_domain.create", "params": {
"name": "WarmAir", "material": "Air", "min": [-30, -20, 0], "max": [100, 60, 70], "spacing": 5,
"faces": {"x_min": {"type": "velocity", "velocity": [1, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0},
"y_min": {"type": "slip"}, "y_max": {"type": "slip"}, "z_min": {"type": "wall"}, "z_max": {"type": "slip"}},
"obstacles": ["Bracket"], "initial_velocity": [1, 0, 0],
"thermal": {"initial_temperature": 20, "surfaces": walls,
"faces": {"x_min": {"type": "temperature", "value": 20}}}
}},
{"command": "model.step.create", "params": {"name": "HeatedFlow", "procedure": {
"type": "fluid", "domain": "WarmAir", "steps": 180, "frames": 6
}}}
])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heated_flow_sample_solves_and_exposes_temperature_and_wall_heat() {
let mut s = crate::Session::default();
s.execute("file.sample", &json!({"name": "heated-flow"})).unwrap();
s.execute("solve.fluid", &json!({"backend": "cpu"})).unwrap();
let report = s.execute("results.slice", &json!({"field": "temperature"})).unwrap();
let max = report["contour"]["range"]["max"]["value"].as_f64().unwrap();
let min = report["contour"]["range"]["min"]["value"].as_f64().unwrap();
assert!(max > 20.1 && max <= 80.01 && min >= 19.99, "{report}");
let report = s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
assert_eq!(report["contour"]["unit"], "mW/mm²");
assert!(report["wall_heat"]["faces"].as_array().unwrap().iter().any(|f| f["power"].as_f64().unwrap_or(0.0) < 0.0));
assert_eq!(report["frames"].as_array().unwrap().len(), 7);
}
}
+57 -13
View File
@@ -5,19 +5,20 @@
//! The picture follows the desktop viewport's conventions: geometry draws as faces with their
//! boundaries while the model has no element faces, then as boundaries only; elements in their
//! kind's colour with their edges; a results view ([`crate::results::ResultsView`]) draws the
//! deformed shape (automatic or chosen scale) with its contour bands. Animation is not drawn: a
//! deformed shape (automatic or chosen scale) with its contour bands, or a fluid step's slice,
//! coloured obstacles and streamlines over the undeformed part ([`crate::flow`]). Animation is not drawn: a
//! picture shows the full deformation (a mode at its positive shape). The analysis setup's
//! symbols ([`fieldcraft_render::glyphs`]) are drawn over the model as the viewport draws them,
//! muted over a results plot (and only those its step used), at their default size on screen
//! once the camera is known.
use fieldcraft_model::ElementKind;
use fieldcraft_render::contour::{bands_color, split_triangle};
use fieldcraft_render::contour::{banded, bands_color};
use fieldcraft_render::glyphs::{self, GlyphColors, GlyphShow, GlyphView, Glyphs};
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Orientation, Rgb, Scene, SceneLine, ScenePoint, SceneTri, Vec3};
use serde_json::Value;
use crate::results::{auto_scale, displacements, field_values, range};
use crate::results::{Kind, auto_scale, displacements, field_values, range};
use crate::{EngineError, Result, Session};
/// The colours of a picture.
@@ -32,6 +33,9 @@ pub struct Palette {
pub shell: Rgb,
pub line_element: Rgb,
pub mesh_edge: Rgb,
/// Streamlines when the legend shows another field than velocity: stands out from the
/// background.
pub streamline: Rgb,
/// Nodes, when they are shown.
pub node: Rgb,
pub geometry: Rgb,
@@ -66,6 +70,7 @@ impl Palette {
shell: Rgb(0xc0, 0x8c, 0x78),
line_element: Rgb(0xc8, 0x8c, 0x1e),
mesh_edge: Rgb(0x1e, 0x23, 0x2a),
streamline: Rgb(0x28, 0x2e, 0x38),
node: Rgb(0xb4, 0x3c, 0x8c),
geometry: Rgb(176, 186, 198),
geometry_edge: Rgb(40, 44, 52),
@@ -303,7 +308,8 @@ pub struct Shown {
pub step: Option<String>,
/// The deformation scale factor drawn (0: undeformed).
pub scale: f64,
/// The contour field drawn, with its smallest and largest value and their nodes.
/// The contour field drawn, with its smallest and largest value and their nodes (a fluid
/// step's: their cells).
pub contour: Option<(String, (u32, f64), (u32, f64))>,
/// Bodies that could not be tessellated for display.
pub failed: Vec<String>,
@@ -500,6 +506,22 @@ fn picture(s: &Session, p: &Palette, section: Option<Section>, toward: Vec3) ->
return Picture { scene, drawn: base, bounds, shown, glyphs, colors: p.glyphs, datums, bodies };
};
shown.step = Some(view.step.clone());
let colors = p.glyphs.over_results(p.background.1);
if matches!(result.kind(), Kind::Fluid(_)) {
// The part as it is, with the flow drawn through and around it.
let mut scene = model_scene(&base, p);
let mut bounds = base.bounds;
let names: Vec<&str> = bodies.iter().map(String::as_str).collect();
if let Ok(fp) = crate::flow::plot_with_surfaces(view, result, &s.traced, s, &base, &names) {
if let Some(c) = &fp.contour {
let cell = |(k, v): (usize, f64)| (u32::try_from(k).unwrap_or(u32::MAX), v);
shown.contour = Some((c.field.id().to_string(), cell(c.min), cell(c.max)));
}
crate::flow::draw(&fp, &mut scene, &crate::flow::FlowColors { stops: &p.contour, over: p.contour_over, plain: p.streamline });
bounds.union(fp.bounds());
}
return Picture { scene, drawn: base, bounds, shown, glyphs, colors, datums: Aabb3::EMPTY, bodies };
}
let pick = view.pick();
let nodes = result.nodes();
let u = displacements(result, pick).unwrap_or(&[]);
@@ -519,21 +541,15 @@ fn picture(s: &Session, p: &Palette, section: Option<Section>, toward: Vec3) ->
shown.contour = Some((field.id().to_string(), min, max));
let bands = view.bands(min.1, max.1);
let at = |label: u32| nodes.binary_search(&label).ok().and_then(|i| values.get(i).copied()).unwrap_or(f64::NAN);
let color = |band: Option<u32>| band.map_or(p.no_result, |k| bands_color(&p.contour, p.contour_over, &bands, k));
scene.tris.clear();
for tri in &drawn.tris {
split_triangle(tri.p, tri.nodes.map(at), &bands, &mut |piece, band| {
let color = band.map_or(p.no_result, |k| bands_color(&p.contour, p.contour_over, &bands, k));
for k in 1..piece.len().saturating_sub(1) {
if let (Some(&a), Some(&b), Some(&c)) = (piece.first(), piece.get(k), piece.get(k + 1)) {
scene.tris.push(SceneTri { p: [a, b, c], normal: tri.normal, color });
}
}
});
banded(tri.p, tri.nodes.map(at), tri.normal, &bands, &color, &mut scene.tris);
}
}
let mut bounds = base.bounds;
bounds.union(drawn.bounds);
Picture { scene, drawn, bounds, shown, glyphs, colors: p.glyphs.over_results(p.background.1), datums: Aabb3::EMPTY, bodies }
Picture { scene, drawn, bounds, shown, glyphs, colors, datums: Aabb3::EMPTY, bodies }
}
/// The scene of the session (the model, or the results view's plot) with the setup's symbols
@@ -1070,6 +1086,34 @@ mod tests {
}
}
/// A fluid step draws the part with its faces coloured by the flow beside them, a coloured
/// slice through the domain and streamlines (#193: the part vanished and nothing was drawn).
#[test]
fn a_fluid_step_draws_its_flow_around_the_part() {
let mut s = crate::flow::tests::plate_in_a_tank();
let p = Palette::LIGHT;
let (base, ..) = display(&s);
assert!(!base.surface_tris.is_empty());
let part: Vec<Rgb> = model_scene(&base, &p).tris.iter().map(|t| t.color).collect();
let (scene, bounds, shown) = super::scene(&s, &p);
let (field, min, max) = shown.contour.clone().unwrap();
assert!(field == "velocity" && max.1 > min.1, "{shown:?}");
assert!(scene.tris.len() > base.surface_tris.len(), "the slice is drawn");
assert!(!scene.lines.is_empty(), "streamlines are drawn");
let tank = Aabb3 { min: Vec3::new(-20.0, -20.0, -20.0), max: Vec3::new(60.0, 50.0, 30.0) };
assert_eq!(bounds, tank, "the picture frames the tank");
// The part is coloured by the flow; in its own colours without the surface colours.
let in_part_colour = |scene: &Scene| scene.tris.iter().filter(|t| part.contains(&t.color)).count();
assert!(in_part_colour(&scene) < base.surface_tris.len());
s.execute("results.surface", &json!({"show": false})).unwrap();
s.execute("results.slice", &json!({"axis": "none"})).unwrap();
s.execute("results.streamlines", &json!({"show": false})).unwrap();
let (scene, ..) = super::scene(&s, &p);
assert_eq!((in_part_colour(&scene), scene.tris.len()), (base.surface_tris.len(), base.surface_tris.len()), "the part alone");
let (png, _) = render_png(&s, &Orientation::of(StandardView::Iso), 160, 120, &p).unwrap();
assert!(count_color(&png, p.background.0) < 160 * 120, "the part is in the picture");
}
/// Pixels of colour `c` (within a few levels) in a PNG.
fn count_color(png: &[u8], c: Rgb) -> usize {
let d = png::Decoder::new(std::io::Cursor::new(png));
+3
View File
@@ -46,6 +46,9 @@ pub enum Error {
Invalid(String),
#[error("the geometry operation failed: {0}")]
Failed(String),
/// From the model layer: a cancelled job ([`fieldcraft_model::progress::check`]).
#[error(transparent)]
Model(#[from] fieldcraft_model::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
+7 -1
View File
@@ -103,7 +103,12 @@ impl Design {
let mut bodies: Vec<Shape> = self.source.as_ref().clone();
let mut errors: Vec<FeatureError> = self.errors.iter().filter(|e| !crate::virtual_faces::derived(&e.feature)).cloned().collect();
let mut reports = Vec::with_capacity(cuts.len());
for (cut, (plane, n)) in cuts.iter().zip(&planes) {
// Each cut is a kernel boolean per body it reaches, seconds each on a large part: the
// job's progress counts them, and a cancelled job stops between them.
fieldcraft_model::progress::stage("Partition cuts", Some(cuts.len() as f64), "cuts");
for (k, (cut, (plane, n))) in cuts.iter().zip(&planes).enumerate() {
fieldcraft_model::progress::count(k as f64);
fieldcraft_model::progress::check()?;
let feature = format!("partition {}", cut.name);
// A listed name is the body of that name, or the cells earlier partitions cut it into.
let names = |name: &str, b: &str| name == b || name.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'));
@@ -122,6 +127,7 @@ impl Design {
next.push(body);
continue;
}
fieldcraft_model::progress::check()?;
match split(&body, plane, *n, cut)? {
Split::Cells(cells, faces, warnings) => {
for &i in &listed {
+30 -1
View File
@@ -8,6 +8,11 @@
//! Backends are Metal on macOS, DX12 and Vulkan on Windows, Vulkan and GL elsewhere;
//! `WGPU_BACKEND` narrows them. A discrete GPU outranks an integrated one, then a virtual one,
//! then any other; a CPU device is the software tier.
//!
//! wgpu's backend options follow its environment variables: on DX12, `WGPU_DX12_COMPILER`
//! (`fxc` or `dxc`; unset, DXC when `dxcompiler.dll` is on the DLL search path, else FXC, see
//! #561). `staticdxc` is refused: it needs wgpu's `static-dxc` feature, which FieldCraft does not
//! build, and wgpu would panic on it.
use crate::{GpuError, Result};
@@ -100,7 +105,11 @@ pub fn adapter(policy: Policy) -> Result<Option<(wgpu::Adapter, Tier)>> {
if policy == Policy::Cpu {
return Ok(None);
}
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: backends(), ..wgpu::InstanceDescriptor::new_without_display_handle() });
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: backends(),
backend_options: backend_options(wgpu::BackendOptions::from_env_or_default())?,
..wgpu::InstanceDescriptor::new_without_display_handle()
});
let mut adapters = pollster::block_on(instance.enumerate_adapters(backends()));
let types: Vec<_> = adapters.iter().map(|a| a.get_info().device_type).collect();
let Some(i) = pick(&types, policy)? else { return Ok(None) };
@@ -108,12 +117,32 @@ pub fn adapter(policy: Policy) -> Result<Option<(wgpu::Adapter, Tier)>> {
Ok((i < adapters.len()).then(|| (adapters.swap_remove(i), tier)))
}
/// `options`, unless they ask for a DX12 compiler this build does not have.
fn backend_options(options: wgpu::BackendOptions) -> Result<wgpu::BackendOptions> {
if matches!(options.dx12.shader_compiler, wgpu::Dx12Compiler::StaticDxc) {
return Err(GpuError::Device("WGPU_DX12_COMPILER=staticdxc is not built in: use dxc with dxcompiler.dll, or fxc".into()));
}
Ok(options)
}
#[cfg(test)]
mod tests {
use wgpu::DeviceType::*;
use super::*;
#[test]
fn a_dx12_compiler_that_is_not_built_in_is_refused() {
let with = |shader_compiler| {
let mut o = wgpu::BackendOptions::default();
o.dx12.shader_compiler = shader_compiler;
backend_options(o)
};
assert!(with(wgpu::Dx12Compiler::Fxc).is_ok());
assert!(with(wgpu::Dx12Compiler::Auto).is_ok());
assert!(with(wgpu::Dx12Compiler::StaticDxc).is_err());
}
#[test]
fn policies_walk_the_tiers() {
assert_eq!(pick(&[Cpu, IntegratedGpu, DiscreteGpu, VirtualGpu], Policy::Auto), Ok(Some(2)));
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "fieldcraft-lbm-gpu"
description = "FieldCraft's lattice Boltzmann solver on the GPU: the CPU scheme's step as wgpu compute shaders, driven through fieldcraft-lbm's Backend trait and checked against it"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
fieldcraft-lbm = { workspace = true }
fieldcraft-model = { workspace = true }
fieldcraft-gpu = { workspace = true }
wgpu = { workspace = true }
pollster = { workspace = true }
log = { workspace = true }
[dev-dependencies]
fieldcraft-mesh = { workspace = true }
+654
View File
@@ -0,0 +1,654 @@
// The lattice Boltzmann step of fieldcraft_lbm::flow on the GPU: the same D3Q19 two-relaxation-
// time collision with Guo forcing, pull streaming with the per-link rules the CPU prepared
// (bounce-back with Ladd's moving-wall term, specular reflection, open placeholders), the Zou–He
// open boundaries, and the momentum exchange per force group. Populations are stored direction-
// major. The default AA pattern uses one population buffer (Bailey et al., ICPP 2009;
// Wittmann et al., Comput. Math. Appl. 65 (2013), 924–935). Even steps read a cell and
// write its opposite directions; odd steps read opposite directions at the upstream
// neighbours and write each outgoing direction at the downstream neighbour. Each thread
// writes exactly the addresses it read, so distinct cells never race. Physical coordinates
// wrap; the existing per-link rules still supply walls, reflection and open placeholders.
// A gather pass saves those boundary values before a scatter pass overrides their targets:
// overwriting in the gather itself could destroy another link's post-collision source.
// The optional A–B diagnostic path retains the original two-buffer collide/pull scheme.
//
// Single precision, with every population stored as its deviation from the weight, h_q = f_q − w_q
// (Krüger et al. 2017, §13.3.4: the deviations are small, so f32 keeps their digits instead of
// the weight's): ρ = 1 + Σ h, momentum Σ h c (Σ w c = 0), and every rule below is written for h.
// The CPU scheme (double precision, fixed sums) is the reference; see the crate's tests for the
// tolerances.
//
// With heat, a D3Q7 scalar lattice (the rest direction and the six axes of D3Q19, weights ¼ and
// ⅛) rides on the flow in the buffers t and t2, stored as deviations from w_q T₀ (T₀ the initial
// temperature) so that f32 keeps the digits of the differences: θ = T − T₀ = Σ t. It collides
// (BGK) with the velocity the momentum collision left in `moments`, streams with the links' rules
// read through the group's thermal condition (anti-bounce-back for a held temperature, bounce-
// back for an adiabatic face, a flux added to the bounced population), takes its inlet
// temperatures and outlet neighbours, and gives each group the heat its links exchanged (in
// contrib.w and forces.w). The temperature feeds back as the buoyant acceleration −β g θ.
override AA_MODE: bool = true;
const Q: u32 = 19u;
const QT: u32 = 7u;
const WG: u32 = 256u;
const KIND_PULL: u32 = 0u;
const KIND_BOUNCE: u32 = 1u;
const KIND_REFLECT: u32 = 2u;
const KIND_OPEN: u32 = 3u;
struct Params {
n: u32,
nx: u32,
ny: u32,
nz: u32,
groups_x: u32,
links: u32,
open: u32,
groups: u32,
omega_plus: f32,
omega_minus: f32,
s_plus: f32,
s_minus: f32,
// The body acceleration (xyz) and whether there is one (w).
acc: vec4<f32>,
// The buoyancy −β g in lattice units (xyz) and whether there is any (w).
buoy: vec4<f32>,
// The scalar lattice: its relaxation rate (x), T₀ (y), whether there is heat (z).
therm: vec4<f32>,
// Current AA orientation (x: 0 normal, 1 opposite directions at upstream neighbours).
phase: vec4<u32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read> fluid: array<u32>;
@group(0) @binding(2) var<storage, read_write> f: array<f32>;
@group(0) @binding(3) var<storage, read_write> g: array<f32>;
// Per link: (q | kind << 8 | q2 << 16, from, group, 0) and the moving-wall term.
@group(0) @binding(5) var<storage, read> link_u: array<vec4<u32>>;
@group(0) @binding(6) var<storage, read> link_f: array<f32>;
// Per open cell: (cell, axis, kind: 0 velocity / 1 density, 0) and (target xyz or density in x, inward sign in w).
@group(0) @binding(7) var<storage, read> open_u: array<vec4<u32>>;
@group(0) @binding(8) var<storage, read> open_f: array<vec4<f32>>;
// Per cell: velocity (with the half force) and density, from the moments kernel.
@group(0) @binding(9) var<storage, read_write> moments: array<vec4<f32>>;
// [0]: the largest squared lattice speed of the chunk (as f32 bits); [1]: a density was not positive.
@group(0) @binding(10) var<storage, read_write> stats: array<atomic<u32>>;
// Per link, the momentum it transferred to its group in the last step.
@group(0) @binding(11) var<storage, read_write> contrib: array<vec4<f32>>;
// Link indices ordered by group, and each group's range in that order (groups + 1 entries).
@group(0) @binding(12) var<storage, read> by_group: array<u32>;
@group(0) @binding(13) var<storage, read> group_start: array<u32>;
@group(0) @binding(14) var<storage, read_write> forces: array<vec4<f32>>;
// Per workgroup, the mass of its cells.
@group(0) @binding(15) var<storage, read_write> partials: array<f32>;
// The scalar populations remain A–B (t[q n + cell]; see the notes above).
@group(0) @binding(16) var<storage, read_write> t: array<f32>;
@group(0) @binding(17) var<storage, read_write> t2: array<f32>;
// Per force group: its thermal condition (x: 0 adiabatic, 1 temperature, 2 flux) and the value
// (y: the held temperature − T₀, or the flux as a temperature per step).
@group(0) @binding(18) var<storage, read> group_t: array<vec4<f32>>;
// Per open cell: whether it holds a temperature (x) and that temperature − T₀ (y).
@group(0) @binding(19) var<storage, read> open_t: array<vec4<f32>>;
var<workgroup> red: array<vec4<f32>, 256>;
// The largest squared speed of a workgroup's cells (collide): one atomic per workgroup, not
// per cell, which serialise on a single address.
var<workgroup> speed: array<f32, 256>;
fn c_of(q: u32) -> vec3<i32> {
switch q {
case 0u: { return vec3<i32>(0, 0, 0); }
case 1u: { return vec3<i32>(1, 0, 0); }
case 2u: { return vec3<i32>(-1, 0, 0); }
case 3u: { return vec3<i32>(0, 1, 0); }
case 4u: { return vec3<i32>(0, -1, 0); }
case 5u: { return vec3<i32>(0, 0, 1); }
case 6u: { return vec3<i32>(0, 0, -1); }
case 7u: { return vec3<i32>(1, 1, 0); }
case 8u: { return vec3<i32>(-1, -1, 0); }
case 9u: { return vec3<i32>(1, -1, 0); }
case 10u: { return vec3<i32>(-1, 1, 0); }
case 11u: { return vec3<i32>(1, 0, 1); }
case 12u: { return vec3<i32>(-1, 0, -1); }
case 13u: { return vec3<i32>(1, 0, -1); }
case 14u: { return vec3<i32>(-1, 0, 1); }
case 15u: { return vec3<i32>(0, 1, 1); }
case 16u: { return vec3<i32>(0, -1, -1); }
case 17u: { return vec3<i32>(0, 1, -1); }
default: { return vec3<i32>(0, -1, 1); }
}
}
fn opp(q: u32) -> u32 {
if q == 0u { return 0u; }
if (q & 1u) == 1u { return q + 1u; }
return q - 1u;
}
fn w_of(q: u32) -> f32 {
if q == 0u { return 1.0 / 3.0; }
if q < 7u { return 1.0 / 18.0; }
return 1.0 / 36.0;
}
fn wt_of(q: u32) -> f32 {
if q == 0u { return 0.25; }
return 0.125;
}
fn has_heat() -> bool {
return params.therm.z > 0.5;
}
// The temperature of a cell above T₀: the sum of its scalar populations.
fn theta_of(cell: u32) -> f32 {
var s = 0.0;
for (var q = 0u; q < QT; q = q + 1u) {
s = s + t[q * params.n + cell];
}
return s;
}
// The body acceleration of a cell: the constant one plus the buoyant one.
fn acc_of(cell: u32) -> vec3<f32> {
var a = params.acc.xyz;
if params.buoy.w > 0.5 { a = a + params.buoy.xyz * theta_of(cell); }
return a;
}
fn forced() -> bool {
return params.acc.w > 0.5 || params.buoy.w > 0.5;
}
// The scalar equilibrium w_q T (1 + 4 c_q·u), shifted by w_q T₀ like the populations.
fn eq_t(q: u32, theta: f32, u: vec3<f32>) -> f32 {
let cu = 4.0 * dot(vec3<f32>(c_of(q)), u);
return wt_of(q) * (theta * (1.0 + cu) + params.therm.y * cu);
}
fn cell_of(lid: u32, wg: vec3<u32>) -> u32 {
return (wg.y * params.groups_x + wg.x) * WG + lid;
}
fn is_fluid(cell: u32) -> bool {
return fluid[cell] != 0u;
}
// A cell's grid coordinates (integer divisions once per cell, not once per direction).
fn coords(cell: u32) -> vec3<i32> {
let plane = params.nx * params.ny;
return vec3<i32>(i32(cell % params.nx), i32((cell % plane) / params.nx), i32(cell / plane));
}
// The cell at `p`, wrapped periodically. A lattice step moves at most one cell per axis, so a
// compare and an add per axis replace the remainder.
fn wrapped(p: vec3<i32>) -> u32 {
let size = vec3<i32>(i32(params.nx), i32(params.ny), i32(params.nz));
var w = select(p, p + size, p < vec3<i32>(0));
w = select(w, w - size, w >= size);
return u32((w.z * size.y + w.y) * size.x + w.x);
}
// Where population q of the cell at `xyz` is stored in the given AA orientation: in place when
// even, reversed at the upstream neighbour when odd.
fn population_index(q: u32, cell: u32, xyz: vec3<i32>, phase: u32) -> u32 {
if phase == 0u { return q * params.n + cell; }
return opp(q) * params.n + wrapped(xyz - c_of(q));
}
fn population(q: u32, cell: u32, xyz: vec3<i32>) -> f32 {
if AA_MODE { return f[population_index(q, cell, xyz, params.phase.x)]; }
return f[q * params.n + cell];
}
// Collision's output at its source cell, before the boundary rules.
fn post_index(q: u32, cell: u32, xyz: vec3<i32>) -> u32 {
if !AA_MODE { return q * params.n + cell; }
if params.phase.x == 0u { return opp(q) * params.n + cell; }
return q * params.n + wrapped(xyz + c_of(q));
}
fn post(q: u32, cell: u32) -> f32 {
return f[post_index(q, cell, aa_coords(cell))];
}
fn streamed(q: u32, cell: u32, xyz: vec3<i32>) -> f32 {
if AA_MODE { return f[population_index(q, cell, xyz, params.phase.x ^ 1u)]; }
return g[q * params.n + cell];
}
fn store_streamed(q: u32, cell: u32, xyz: vec3<i32>, v: f32) {
if AA_MODE { f[population_index(q, cell, xyz, params.phase.x ^ 1u)] = v; }
else { g[q * params.n + cell] = v; }
}
// The coordinates the AA addressing needs; none in the two-buffer layout.
fn aa_coords(cell: u32) -> vec3<i32> {
if AA_MODE { return coords(cell); }
return vec3<i32>(0);
}
// Two-relaxation-time collision with Guo forcing, in place in f (Ginzburg et al. 2008; Guo et
// al. 2002; Krüger et al. 2017 ch. 10), and the chunk's largest speed.
@compute @workgroup_size(256)
fn collide(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let cell = cell_of(lid, wg);
speed[lid] = 0.0;
if cell < params.n && is_fluid(cell) {
collide_cell(cell, lid);
}
workgroupBarrier();
for (var k = 128u; k > 0u; k = k >> 1u) {
if lid < k { speed[lid] = max(speed[lid], speed[lid + k]); }
workgroupBarrier();
}
if lid == 0u { atomicMax(&stats[0], bitcast<u32>(speed[0])); }
}
fn collide_cell(cell: u32, lid: u32) {
let n = params.n;
// Only the odd AA orientation addresses neighbours.
var xyz = vec3<i32>(0);
if AA_MODE && params.phase.x == 1u { xyz = coords(cell); }
var fq: array<f32, 19>;
var drho = 0.0;
var j = vec3<f32>(0.0);
for (var q = 0u; q < Q; q = q + 1u) {
let v = population(q, cell, xyz);
fq[q] = v;
drho = drho + v;
j = j + vec3<f32>(c_of(q)) * v;
}
let rho = 1.0 + drho;
if !(rho > 0.0) || rho != rho || abs(rho) > 1e30 {
atomicStore(&stats[1], 1u);
return;
}
let a = acc_of(cell);
let force = rho * a;
let u = (j + 0.5 * force) / rho;
let uu = dot(u, u);
speed[lid] = uu;
if has_heat() { moments[cell] = vec4<f32>(u, rho); }
let uf = dot(u, force);
let forced = forced();
let wp = params.omega_plus;
let wm = params.omega_minus;
let sp = params.s_plus;
let sm = params.s_minus;
// Rest population: symmetric only. Equilibria are shifted by the weight like the populations.
let eq0 = w_of(0u) * (drho - 1.5 * rho * uu);
var out0 = fq[0] - wp * (fq[0] - eq0);
if forced { out0 = out0 + sp * w_of(0u) * (-3.0 * uf); }
f[post_index(0u, cell, xyz)] = out0;
for (var q = 1u; q < Q; q = q + 2u) {
let o = q + 1u;
let cv = vec3<f32>(c_of(q));
let cu = dot(cv, u);
let w = w_of(q);
let eq_plus = w * (drho + rho * (4.5 * cu * cu - 1.5 * uu));
let eq_minus = w * rho * 3.0 * cu;
let plus = 0.5 * (fq[q] + fq[o]);
let minus = 0.5 * (fq[q] - fq[o]);
let relax_plus = wp * (plus - eq_plus);
let relax_minus = wm * (minus - eq_minus);
var s_plus = 0.0;
var s_minus = 0.0;
if forced {
let cf = dot(cv, force);
s_plus = sp * w * (9.0 * cu * cf - 3.0 * uf);
s_minus = sm * w * 3.0 * cf;
}
f[post_index(q, cell, xyz)] = fq[q] - relax_plus - relax_minus + s_plus + s_minus;
f[post_index(o, cell, xyz)] = fq[o] - relax_plus + relax_minus + s_plus - s_minus;
}
}
// Pull streaming of f into g, first pass: every population from the neighbour at −c_q
// (wrapped; a wall or an open face overrides it in the second pass).
@compute @workgroup_size(256)
fn stream(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let cell = cell_of(lid, wg);
if cell >= params.n { return; }
if !is_fluid(cell) { return; }
let n = params.n;
let nx = i32(params.nx);
let ny = i32(params.ny);
let nz = i32(params.nz);
let i = i32(cell % params.nx);
let jj = i32((cell / params.nx) % params.ny);
let k = i32(cell / (params.nx * params.ny));
for (var q = 0u; q < Q; q = q + 1u) {
let c = c_of(q);
let si = (i - c.x + nx) % nx;
let sj = (jj - c.y + ny) % ny;
let sk = (k - c.z + nz) % nz;
let src = u32((sk * ny + sj) * nx + si);
g[q * n + cell] = f[q * n + src];
}
}
// Second pass, one thread per link: the populations with a rule (a periodic image, a wall, a
// slip face, an open face). Every (cell, direction) has at most one link, so no two threads
// write the same population.
@compute @workgroup_size(256)
fn stream_links(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
gather_link(cell_of(lid, wg), false);
}
// The same on the step whose wall forces are read back (a chunk's last): the AA layout
// overwrites the post-collision sources in the scatter, so the momentum exchange is taken here.
@compute @workgroup_size(256)
fn stream_links_exchange(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
gather_link(cell_of(lid, wg), true);
}
fn gather_link(l: u32, exchange: bool) {
if l >= params.links { return; }
let n = params.n;
let lu = link_u[l];
let q = lu.x & 0xffu;
let kind = (lu.x >> 8u) & 0xffu;
let q2 = (lu.x >> 16u) & 0xffu;
let cell = lu.w;
var v = 0.0;
if kind == KIND_PULL {
v = post(q, lu.y);
} else if kind == KIND_BOUNCE {
v = post(opp(q), cell) + link_f[l];
} else if kind == KIND_REFLECT {
// The mirrored direction has the same weight, so h reflects like f.
v = post(q2, lu.y);
} else {
v = post(opp(q), cell);
}
if AA_MODE {
g[l] = v;
if exchange { contrib[l] = vec4<f32>(momentum_exchange(l, lu, q, kind, q2), 0.0); }
} else {
g[q * n + cell] = v;
}
}
// The gather above must complete before any AA boundary population is overwritten.
@compute @workgroup_size(256)
fn scatter_links(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let l = cell_of(lid, wg);
if l >= params.links { return; }
let lu = link_u[l];
store_streamed(lu.x & 0xffu, lu.w, aa_coords(lu.w), g[l]);
}
// Zou–He velocity and pressure boundaries on the streamed populations in g (Zou & He 1997;
// Hecht & Harting 2010 for the D3Q19 transverse correction).
@compute @workgroup_size(256)
fn open_boundaries(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let o = cell_of(lid, wg);
if o >= params.open { return; }
let n = params.n;
let ou = open_u[o];
let ot = open_f[o];
let cell = ou.x;
let axis = ou.y;
let sign = ot.w;
let a_half = 0.5 * acc_of(cell);
let xyz = aa_coords(cell);
// The sums of h over the tangential (c_n = 0) and outgoing (c_n < 0) directions. The sums of
// f add the weights' (2/3 and 1/6 for D3Q19, so tangential + 2 outgoing gains exactly 1): the
// weight is added after the deviations are summed, not before, which in single precision would
// round away the deviations' last digits at low speed.
var tangential = 0.0;
var outgoing = 0.0;
for (var q = 0u; q < Q; q = q + 1u) {
let v = streamed(q, cell, xyz);
let cn = f32(c_of(q)[axis]) * sign;
if cn == 0.0 {
tangential = tangential + v;
} else if cn < 0.0 {
outgoing = outgoing + v;
}
}
var rho = 0.0;
var bare = vec3<f32>(0.0);
if ou.z == 0u {
bare = ot.xyz - a_half;
let un = sign * bare[axis];
rho = (1.0 + (tangential + 2.0 * outgoing)) / (1.0 - un);
} else {
rho = ot.x;
let un = ((rho - 1.0) - (tangential + 2.0 * outgoing)) / rho;
// Not `bare[axis] = …`: DX12's shader compiler (FXC) refuses a write through a
// dynamic index into a local vector (X3500).
bare = select(-a_half, vec3<f32>(sign * un), vec3<u32>(0u, 1u, 2u) == vec3<u32>(axis));
}
for (var q = 1u; q < Q; q = q + 1u) {
let c = c_of(q);
if f32(c[axis]) * sign > 0.0 {
let cu = dot(vec3<f32>(c), bare);
store_streamed(q, cell, xyz, streamed(opp(q), cell, xyz) + 6.0 * w_of(q) * rho * cu);
}
}
for (var t = 0u; t < 3u; t = t + 1u) {
if t == axis { continue; }
var m = -rho * bare[t];
for (var q = 0u; q < Q; q = q + 1u) {
m = m + streamed(q, cell, xyz) * f32(c_of(q)[t]);
}
for (var q = 1u; q < Q; q = q + 1u) {
let c = c_of(q);
if f32(c[axis]) * sign > 0.0 && c[t] != 0 {
store_streamed(q, cell, xyz, streamed(q, cell, xyz) - 0.5 * m * f32(c[t]));
}
}
}
}
fn momentum_exchange(l: u32, lu: vec4<u32>, q: u32, kind: u32, q2: u32) -> vec3<f32> {
var v = vec3<f32>(0.0);
if kind == KIND_BOUNCE {
let out = post(opp(q), lu.w);
let back = out + link_f[l];
v = -vec3<f32>(c_of(q)) * (out + back);
} else if kind == KIND_REFLECT {
let out = post(q2, lu.y);
v = vec3<f32>(c_of(q2)) * out - vec3<f32>(c_of(q)) * out;
}
return v;
}
// The momentum each link transferred to its group in the last step, from the post-collision
// populations in f (Ladd 1994; Mei et al. 2002): c_out f* − c_in f'.
@compute @workgroup_size(256)
fn link_forces(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let l = cell_of(lid, wg);
if l >= params.links { return; }
let n = params.n;
let lu = link_u[l];
let q = lu.x & 0xffu;
let kind = (lu.x >> 8u) & 0xffu;
let q2 = (lu.x >> 16u) & 0xffu;
let cell = lu.w;
// The deviations only: the weights' share of the exchange (2 w_q c_q per bounced link, which
// cancels between a wall's links in exact arithmetic but is 10⁴ times the shear they carry)
// is added on the host in double precision, so the sum keeps the deviations' digits.
var v = contrib[l].xyz;
if !AA_MODE { v = momentum_exchange(l, lu, q, kind, q2); }
var heat = 0.0;
// The heat the link gave its wall: what left the fluid minus what came back.
if has_heat() && q < QT && (kind == KIND_BOUNCE || kind == KIND_REFLECT) {
heat = t[opp(q) * n + cell] - thermal_rule(lu, q, kind, cell);
}
contrib[l] = vec4<f32>(v, heat);
}
// ---- The scalar lattice ----
// BGK collision of the scalar lattice, in place in t, with the velocity of the momentum
// collision.
@compute @workgroup_size(256)
fn collide_thermal(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let cell = cell_of(lid, wg);
if cell >= params.n { return; }
if !is_fluid(cell) { return; }
let n = params.n;
var tq: array<f32, 7>;
var theta = 0.0;
for (var q = 0u; q < QT; q = q + 1u) {
let v = t[q * n + cell];
tq[q] = v;
theta = theta + v;
}
let u = moments[cell].xyz;
let omega = params.therm.x;
for (var q = 0u; q < QT; q = q + 1u) {
t[q * n + cell] = tq[q] - omega * (tq[q] - eq_t(q, theta, u));
}
}
// Pull streaming of t into t2, first pass (as `stream`).
@compute @workgroup_size(256)
fn stream_thermal(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let cell = cell_of(lid, wg);
if cell >= params.n { return; }
if !is_fluid(cell) { return; }
let n = params.n;
let nx = i32(params.nx);
let ny = i32(params.ny);
let nz = i32(params.nz);
let i = i32(cell % params.nx);
let jj = i32((cell / params.nx) % params.ny);
let k = i32(cell / (params.nx * params.ny));
for (var q = 0u; q < QT; q = q + 1u) {
let c = c_of(q);
let si = (i - c.x + nx) % nx;
let sj = (jj - c.y + ny) % ny;
let sk = (k - c.z + nz) % nz;
let src = u32((sk * ny + sj) * nx + si);
t2[q * n + cell] = t[q * n + src];
}
}
// The scalar population a link's rule gives, from t: a pull as the flow's; at a wall or a slip
// face the group's thermal condition on the bounced population; an open placeholder.
fn thermal_rule(lu: vec4<u32>, q: u32, kind: u32, cell: u32) -> f32 {
let n = params.n;
if kind == KIND_PULL { return t[q * n + lu.y]; }
let back = t[opp(q) * n + cell];
if kind == KIND_OPEN { return back; }
let cond = group_t[lu.z];
let k = u32(cond.x);
if k == 1u { return -back + 2.0 * wt_of(q) * cond.y; }
if k == 2u { return back + cond.y; }
return back;
}
// Second pass, one thread per link of the first seven directions.
@compute @workgroup_size(256)
fn stream_links_thermal(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let l = cell_of(lid, wg);
if l >= params.links { return; }
let lu = link_u[l];
let q = lu.x & 0xffu;
if q >= QT { return; }
let kind = (lu.x >> 8u) & 0xffu;
let cell = lu.w;
t2[q * params.n + cell] = thermal_rule(lu, q, kind, cell);
}
// Inlets hold their temperature (the equilibrium at the cell's velocity); outlets take the
// streamed populations of the neighbour one cell inward.
@compute @workgroup_size(256)
fn open_thermal(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let o = cell_of(lid, wg);
if o >= params.open { return; }
let n = params.n;
let ou = open_u[o];
let cell = ou.x;
let held = open_t[o];
if held.x > 0.5 {
let u = moments[cell].xyz;
for (var q = 0u; q < QT; q = q + 1u) {
t2[q * n + cell] = eq_t(q, held.y, u);
}
} else {
var step = 1;
if ou.y == 1u { step = i32(params.nx); }
if ou.y == 2u { step = i32(params.nx * params.ny); }
let src = u32(i32(cell) + i32(open_f[o].w) * step);
for (var q = 0u; q < QT; q = q + 1u) {
t2[q * n + cell] = t2[q * n + src];
}
}
}
// Flags a temperature that stopped being a number.
@compute @workgroup_size(256)
fn thermal_check(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let cell = cell_of(lid, wg);
if cell >= params.n { return; }
if !is_fluid(cell) { return; }
let theta = theta_of(cell);
if theta != theta || abs(theta) > 1e30 { atomicStore(&stats[2], 1u); }
}
// One workgroup per group: the sum of its links' contributions.
@compute @workgroup_size(256)
fn group_forces(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let group = wg.y * params.groups_x + wg.x;
if group >= params.groups { return; }
let lo = group_start[group];
let hi = group_start[group + 1u];
var s = vec4<f32>(0.0);
for (var i = lo + lid; i < hi; i = i + WG) {
s = s + contrib[by_group[i]];
}
red[lid] = s;
workgroupBarrier();
for (var k = 128u; k > 0u; k = k >> 1u) {
if lid < k { red[lid] = red[lid] + red[lid + k]; }
workgroupBarrier();
}
if lid == 0u { forces[group] = red[0]; }
}
// The density and velocity (with the half force) of every cell from f; zero in solid cells.
@compute @workgroup_size(256)
fn cell_moments(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let cell = cell_of(lid, wg);
if cell >= params.n { return; }
if !is_fluid(cell) {
moments[cell] = vec4<f32>(0.0);
return;
}
let n = params.n;
var drho = 0.0;
var j = vec3<f32>(0.0);
let xyz = aa_coords(cell);
for (var q = 0u; q < Q; q = q + 1u) {
let v = population(q, cell, xyz);
drho = drho + v;
j = j + vec3<f32>(c_of(q)) * v;
}
let rho = 1.0 + drho;
let u = (j + 0.5 * rho * acc_of(cell)) / rho;
moments[cell] = vec4<f32>(u, rho);
}
// Per workgroup, the sum of its cells' densities (the mass, since every cell has unit volume).
@compute @workgroup_size(256)
fn mass(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let group = wg.y * params.groups_x + wg.x;
let cell = group * WG + lid;
var v = 0.0;
if cell < params.n && is_fluid(cell) {
v = moments[cell].w;
}
red[lid] = vec4<f32>(v, 0.0, 0.0, 0.0);
workgroupBarrier();
for (var k = 128u; k > 0u; k = k >> 1u) {
if lid < k { red[lid] = red[lid] + red[lid + k]; }
workgroupBarrier();
}
if lid == 0u { partials[group] = red[0].x; }
}
+26
View File
@@ -0,0 +1,26 @@
//! The lattice Boltzmann solver on the GPU: [`GpuFlow`] steps a flow that
//! [`fieldcraft_lbm::Flow`] prepared (grid, links, open cells, initial state) with the compute
//! shaders of `lbm.wgsl`, which are the CPU scheme's collision, streaming rules, open boundaries
//! and momentum exchange, and with heat its temperature lattice, written for the device; `fieldcraft_lbm::run` drives it through the
//! [`Backend`] trait in chunks of steps, so populations stay on the device and only frames,
//! force histories and the stability check come back.
//!
//! Single precision: the CPU scheme is the reference, and this backend is deterministic on its
//! device and agrees with it within the tolerances its tests state (`tests/verification.rs`).
//! The device is the one `fieldcraft_gpu`'s adapter policy picks (`FIELDCRAFT_GPU`).
//!
//! Sources: the same as `fieldcraft_lbm` (Krüger et al. 2017; Ginzburg et al. 2008; Guo et al.
//! 2002; Ladd 1994; Mei et al. 2002; Zou & He 1997; Hecht & Harting 2010). No code of any
//! lattice Boltzmann package was read.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(not(target_arch = "wasm32"))]
mod native;
#[cfg(not(target_arch = "wasm32"))]
pub use native::{GpuFlow, KernelTime, Options, Streaming, register};
/// In the browser there is no device backend yet (WebGPU later): registering is a no-op and
/// `solve.fluid` with `backend: "gpu"` reports that none is available.
#[cfg(target_arch = "wasm32")]
pub fn register() {}
+827
View File
@@ -0,0 +1,827 @@
//! The device backend (native targets).
use std::collections::HashMap;
use fieldcraft_gpu::select::{self, Policy};
use fieldcraft_lbm::backend::{Backend, Fields};
use fieldcraft_lbm::lattice::{C, Q, W};
use fieldcraft_lbm::setup::MAX_LATTICE_SPEED;
use fieldcraft_lbm::{Error, Flow, Group, Result, Rule, Target};
use fieldcraft_model::ThermalCondition;
use wgpu::util::DeviceExt;
const SHADER: &str = include_str!("lbm.wgsl");
const WG: u32 = 256;
const MAX_GROUPS_X: u32 = 32768;
const KIND_PULL: u32 = 0;
const KIND_BOUNCE: u32 = 1;
const KIND_REFLECT: u32 = 2;
const KIND_OPEN: u32 = 3;
/// The scalar lattice's directions (the first seven of D3Q19) and weights.
const QT: usize = 7;
const WT: [f64; QT] = [0.25, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125];
fn gpu_error(e: impl std::fmt::Display) -> Error {
Error::Invalid(format!("GPU: {e}"))
}
/// Steps per submission: the stability check and a cancel take effect between submissions.
const MAX_CHUNK: u64 = 64;
/// How long one wait for the device may take before the solve gives up on it.
const DEVICE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
/// A bounded chunk's dispatches, shared by the normal and timestamped paths.
type Dispatch = (usize, &'static str, (u32, u32));
/// A vector of `len` copies of `value`, or [`Error::Limit`] when memory runs out (the host
/// arrays are sized by the grid: 16 M cells is over a gigabyte of populations).
fn filled<T: Clone>(len: usize, value: T, what: &str) -> Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(len).map_err(|_| Error::Limit(format!("not enough memory for the {what} ({len} entries)")))?;
v.resize(len, value);
Ok(v)
}
/// An empty vector with room for `len`, or [`Error::Limit`].
fn reserved<T>(len: usize, what: &str) -> Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(len).map_err(|_| Error::Limit(format!("not enough memory for the {what} ({len} entries)")))?;
Ok(v)
}
/// Workgroup counts (x, y) covering `count` items.
fn groups(count: usize) -> Result<(u32, u32)> {
let total = u32::try_from(count.div_ceil(WG as usize).max(1)).map_err(|_| Error::Limit("too many cells for one dispatch".into()))?;
let gx = total.min(MAX_GROUPS_X);
let gy = total.div_ceil(gx);
if gy > 65535 {
return Err(Error::Limit("too many cells for one dispatch".into()));
}
Ok((gx, gy))
}
fn bytes_u32(v: &[u32]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
fn bytes_f32(v: &[f32]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
/// Register this backend with `fieldcraft_lbm` (an application does this once at start), so
/// `solve.fluid` with `backend: "gpu"` steps on the device `FIELDCRAFT_GPU` picks.
pub fn register() {
fieldcraft_lbm::register_backend(Box::new(|flow, _setup| GpuFlow::from_env(flow).map(|g| Box::new(g) as Box<dyn Backend>)));
}
/// How the momentum populations stream (see `lbm.wgsl`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Streaming {
/// In place: measured faster on every device tried (RTX 4090, GB10, M4 Max; #563).
#[default]
Auto,
/// One population buffer, collided and streamed in place (the AA pattern).
InPlace,
/// Two population buffers: collide in place, then pull into the other (A–B).
TwoBuffers,
}
/// How a [`GpuFlow`] runs; the defaults are what a solve uses.
#[derive(Clone, Copy, Debug, Default)]
pub struct Options {
pub streaming: Streaming,
/// Time every kernel with timestamp queries ([`GpuFlow::kernel_times`]). Each dispatch then
/// gets its own pass and every chunk waits for its timestamps: for measurements, not solves.
pub profile: bool,
}
/// GPU time per kernel, summed over the dispatches since the flow was created (with
/// [`Options::profile`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct KernelTime {
pub dispatches: u64,
pub seconds: f64,
}
/// A prepared flow on the device.
pub struct GpuFlow {
device: wgpu::Device,
queue: wgpu::Queue,
/// With [`Options::profile`]: the GPU time of each kernel so far.
kernel_times: Option<std::cell::RefCell<std::collections::BTreeMap<&'static str, KernelTime>>>,
/// How long creating the compute pipelines (compiling the shader) took.
pipeline_seconds: f64,
aa: bool,
adapter: String,
pipelines: HashMap<&'static str, wgpu::ComputePipeline>,
/// AB swaps populations; AA keeps one population buffer and changes its orientation.
/// Both modes swap the separate scalar lattice buffers.
bind: [wgpu::BindGroup; 2],
/// Which bind group's `f` holds the state.
current: usize,
n: usize,
cells: (u32, u32),
link_count: usize,
links_groups: (u32, u32),
open_count: usize,
open_groups: (u32, u32),
group_count: usize,
force_groups: (u32, u32),
stats: wgpu::Buffer,
moments: wgpu::Buffer,
forces: wgpu::Buffer,
partials: wgpu::Buffer,
partial_count: usize,
/// The scalar populations remain A–B with heat; and T₀ they are shifted by.
thermal: Option<[wgpu::Buffer; 2]>,
initial_temperature: f64,
fluid: Vec<bool>,
fluid_cells: usize,
groups: Vec<Group>,
empty_obstacles: Vec<String>,
steps_done: u64,
/// The momentum (xyz) and heat (w) each group received in the last step, once read back
/// (zero before the first step).
exchange_cache: Option<Vec<[f64; 4]>>,
/// Each group's share of the momentum from the populations' weights, which the kernel leaves
/// out (`link_forces` sums the deviations): added to what it reads back.
weight_forces: Vec<[f64; 3]>,
}
impl GpuFlow {
/// `flow` on the device `FIELDCRAFT_GPU` picks.
pub fn from_env(flow: &Flow) -> Result<GpuFlow> {
let policy = Policy::from_env().map_err(gpu_error)?;
GpuFlow::new(flow, policy)
}
/// `flow` (its grid, rules and initial state: everything the step needs) on the device
/// `policy` picks.
pub fn new(flow: &Flow, policy: Policy) -> Result<GpuFlow> {
GpuFlow::with_options(flow, policy, Options::default())
}
/// [`GpuFlow::new`] with `options`.
pub fn with_options(flow: &Flow, policy: Policy, options: Options) -> Result<GpuFlow> {
let Some((adapter, tier)) = select::adapter(policy).map_err(gpu_error)? else {
return Err(Error::Invalid(format!("{}=cpu: no GPU device; solve with `backend: \"cpu\"`", select::ENV)));
};
let info = adapter.get_info();
let profile = options.profile;
let features = if profile { wgpu::Features::TIMESTAMP_QUERY } else { wgpu::Features::empty() };
if !adapter.features().contains(features) {
return Err(gpu_error("profiling needs timestamp queries, which this adapter does not offer"));
}
let have = adapter.limits();
let mut limits = wgpu::Limits::default().using_resolution(have.clone());
limits.max_buffer_size = have.max_buffer_size;
limits.max_storage_buffer_binding_size = have.max_storage_buffer_binding_size;
limits.max_storage_buffers_per_shader_stage = have.max_storage_buffers_per_shader_stage.max(limits.max_storage_buffers_per_shader_stage);
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("fieldcraft lbm"),
required_features: features,
required_limits: limits,
..Default::default()
}))
.map_err(gpu_error)?;
let adapter_name = format!("{}{}", info.name, if tier == select::Tier::Software { " (software)" } else { "" });
let aa = match options.streaming {
Streaming::Auto | Streaming::InPlace => true,
Streaming::TwoBuffers => false,
};
let n = flow.grid().len();
let elem = 4u64;
let pop_bytes = elem * (Q * n) as u64;
let cap = device.limits().max_storage_buffer_binding_size.min(device.limits().max_buffer_size);
if pop_bytes > cap {
return Err(Error::Limit(format!(
"{n} cells need {} MB of populations per buffer; this GPU allows {} MB per buffer: coarsen the spacing",
pop_bytes >> 20,
cap >> 20
)));
}
// The state and the flags.
// Populations as deviations from their weights (see lbm.wgsl); solid cells stay zero.
let mut f = filled(Q * n, 0.0f32, "populations")?;
for q in 0..Q {
let w = fieldcraft_lbm::lattice::W.get(q).copied().unwrap_or(0.0);
for cell in 0..n {
if let (Some(slot), true) = (f.get_mut(q * n + cell), flow.is_fluid(cell)) {
*slot = (flow.population(q, cell) - w) as f32;
}
}
}
// The scalar populations as deviations from w_q T₀ (see lbm.wgsl).
let thermal = flow.thermal();
let t0 = thermal.map_or(0.0, |t| t.initial());
let mut tpop = filled(if thermal.is_some() { QT * n } else { 0 }, 0.0f32, "temperature populations")?;
if thermal.is_some() {
for q in 0..QT {
let w = WT.get(q).copied().unwrap_or(0.0) * t0;
for cell in 0..n {
if let (Some(slot), true) = (tpop.get_mut(q * n + cell), flow.is_fluid(cell)) {
*slot = (flow.thermal_population(q, cell) - w) as f32;
}
}
}
}
let mut fluid = filled(n, false, "cell flags")?;
let mut fluid_u32 = filled(n, 0u32, "cell flags")?;
for cell in 0..n {
if let (Some(b), Some(u)) = (fluid.get_mut(cell), fluid_u32.get_mut(cell)) {
*b = flow.is_fluid(cell);
*u = u32::from(*b);
}
}
// The links, by cell, and the ones with a force by group.
let links = flow.links();
let mut link_u = reserved(4 * links.len(), "links")?;
let mut link_f = reserved(links.len(), "links")?;
let mut by_group: Vec<(u32, u32)> = reserved(links.len(), "links by group")?;
let group_count = flow.groups().len();
let mut weight_forces = filled(group_count, [0.0f64; 3], "force groups")?;
let c = |q: u32| C.get(q as usize).copied().unwrap_or([0; 3]).map(f64::from);
let w = |q: u32| W.get(q as usize).copied().unwrap_or(0.0);
for (l, link) in links.iter().enumerate() {
let (kind, from, group, q2, du) = match link.rule {
Rule::Pull { from } => (KIND_PULL, from, 0, 0, 0.0),
Rule::Bounce { group, du } => (KIND_BOUNCE, 0, group, 0, du),
Rule::Reflect { from, q, group } => (KIND_REFLECT, from, group, u32::from(q), 0.0),
Rule::Open => (KIND_OPEN, 0, 0, 0, 0.0),
};
// What the weights add to the link's exchange (see `link_forces`).
let weight = match link.rule {
Rule::Bounce { .. } => c(u32::from(link.q)).map(|x| -2.0 * w(u32::from(link.q)) * x),
Rule::Reflect { .. } => std::array::from_fn(|a| (c(q2)[a] - c(u32::from(link.q))[a]) * w(q2)),
_ => [0.0; 3],
};
if matches!(link.rule, Rule::Bounce { .. } | Rule::Reflect { .. }) {
by_group.push((group, l as u32));
if let Some(g) = weight_forces.get_mut(group as usize) {
for (s, x) in g.iter_mut().zip(weight) {
*s += x;
}
}
}
link_u.extend_from_slice(&[u32::from(link.q) | (kind << 8) | (q2 << 16), from, group, link.cell]);
link_f.push(du as f32);
}
by_group.sort();
let mut group_start = filled(group_count + 1, 0u32, "force groups")?;
for (group, _) in &by_group {
if let Some(next) = group_start.get_mut(*group as usize + 1) {
*next += 1;
}
}
for g in 0..group_count {
let prev = group_start.get(g).copied().unwrap_or(0);
if let Some(next) = group_start.get_mut(g + 1) {
*next += prev;
}
}
let by_group: Vec<u32> = by_group.iter().map(|(_, l)| *l).collect();
// Each group's thermal condition (held temperatures shifted by T₀).
let mut group_t = reserved(4 * group_count, "thermal conditions")?;
for g in 0..group_count {
let (kind, value) = match thermal.and_then(|t| t.groups().get(g)).copied().unwrap_or_default() {
ThermalCondition::Adiabatic {} => (0.0, 0.0),
ThermalCondition::Temperature { value } => (1.0, (value - t0) as f32),
ThermalCondition::Flux { value } => (2.0, value as f32),
};
group_t.extend_from_slice(&[kind, value, 0.0, 0.0]);
}
// The open cells.
let open = flow.open_cells();
let mut open_u = reserved(4 * open.len(), "open cells")?;
let mut open_f = reserved(4 * open.len(), "open cells")?;
for oc in open {
let (kind, target) = match oc.target {
Target::Velocity(u) => (0u32, [u[0] as f32, u[1] as f32, u[2] as f32]),
Target::Density(rho) => (1u32, [rho as f32, 0.0, 0.0]),
};
open_u.extend_from_slice(&[oc.cell, oc.axis as u32, kind, 0]);
open_f.extend_from_slice(&[target[0], target[1], target[2], oc.inward as f32]);
}
let mut open_t = reserved(4 * open.len(), "open cell temperatures")?;
for k in 0..open.len() {
match thermal.and_then(|t| t.open().get(k)).copied().flatten() {
Some(value) => open_t.extend_from_slice(&[1.0, (value - t0) as f32, 0.0, 0.0]),
None => open_t.extend_from_slice(&[0.0; 4]),
}
}
let (omega_plus, omega_minus) = flow.omegas();
let acc = flow.acceleration();
let forced = acc != [0.0; 3];
let cells = groups(n)?;
let links_groups = groups(links.len())?;
let open_groups = groups(open.len())?;
let force_groups = groups((group_count * WG as usize).max(1))?;
let partial_count = (cells.0 as usize) * (cells.1 as usize);
let mut params = Vec::with_capacity(64);
let u = |v: usize| u32::try_from(v).map_err(|_| Error::Limit("too many cells for the GPU solver".into()));
for v in [
u(n)?,
u(flow.grid().cells[0])?,
u(flow.grid().cells[1])?,
u(flow.grid().cells[2])?,
cells.0,
u(links.len())?,
u(open.len())?,
u(group_count)?,
] {
params.extend_from_slice(&v.to_le_bytes());
}
for v in [
omega_plus as f32,
omega_minus as f32,
(1.0 - 0.5 * omega_plus) as f32,
(1.0 - 0.5 * omega_minus) as f32,
acc[0] as f32,
acc[1] as f32,
acc[2] as f32,
if forced { 1.0 } else { 0.0 },
] {
params.extend_from_slice(&v.to_le_bytes());
}
let buoyancy = thermal.map_or([0.0; 3], |t| t.buoyancy());
for v in [
buoyancy[0] as f32,
buoyancy[1] as f32,
buoyancy[2] as f32,
if buoyancy != [0.0; 3] { 1.0 } else { 0.0 },
thermal.map_or(0.0, |t| t.omega()) as f32,
t0 as f32,
if thermal.is_some() { 1.0 } else { 0.0 },
0.0,
] {
params.extend_from_slice(&v.to_le_bytes());
}
// Buffers, with the device's out-of-memory errors caught.
let scopes = [wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f));
let storage = |label: &str, bytes: &[u8]| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents: if bytes.is_empty() { &[0u8; 16][..] } else { bytes },
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
})
};
let empty = |label: &str, size: u64| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: size.max(16),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
let params = [0u32, 1].map(|phase| {
let mut bytes = params.clone();
bytes.extend_from_slice(&bytes_u32(&[phase, 0, 0, 0]));
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("lbm params"),
contents: &bytes,
usage: wgpu::BufferUsages::UNIFORM,
})
});
let fluid_buf = storage("lbm fluid", &bytes_u32(&fluid_u32));
// AA reuses binding g for boundary scratch only, never another full population field.
let pops = [storage("lbm f", &bytes_f32(&f)), empty("lbm g / AA boundary scratch", if aa { 4 * links.len() as u64 } else { pop_bytes })];
let link_u = storage("lbm links", &bytes_u32(&link_u));
let link_f = storage("lbm link du", &bytes_f32(&link_f));
let open_u = storage("lbm open cells", &bytes_u32(&open_u));
let open_f = storage("lbm open targets", &bytes_f32(&open_f));
let moments = empty("lbm moments", 16 * n as u64);
let stats = storage("lbm stats", &bytes_u32(&[0, 0, 0, 0]));
let contrib = empty("lbm link forces", 16 * links.len() as u64);
let by_group = storage("lbm links by group", &bytes_u32(&by_group));
let group_start = storage("lbm group start", &bytes_u32(&group_start));
let forces = empty("lbm forces", 16 * group_count as u64);
let partials = empty("lbm partials", 4 * partial_count as u64);
let therm_bytes = elem * (QT * n) as u64;
let therm = if thermal.is_some() {
[storage("lbm t", &bytes_f32(&tpop)), empty("lbm t2", therm_bytes)]
} else {
[empty("lbm t", 16), empty("lbm t2", 16)]
};
let group_t = storage("lbm thermal conditions", &bytes_f32(&group_t));
let open_t = storage("lbm open temperatures", &bytes_f32(&open_t));
let module = device
.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("fieldcraft lbm"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
let entry = |binding: u32, ty: wgpu::BufferBindingType| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer { ty, has_dynamic_offset: false, min_binding_size: None },
count: None,
};
let read = wgpu::BufferBindingType::Storage { read_only: true };
let write = wgpu::BufferBindingType::Storage { read_only: false };
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("fieldcraft lbm"),
entries: &[
entry(0, wgpu::BufferBindingType::Uniform),
entry(1, read),
entry(2, write),
entry(3, write),
entry(5, read),
entry(6, read),
entry(7, read),
entry(8, read),
entry(9, write),
entry(10, write),
entry(11, write),
entry(12, read),
entry(13, read),
entry(14, write),
entry(15, write),
entry(16, write),
entry(17, write),
entry(18, read),
entry(19, read),
],
});
let bind = [0usize, 1].map(|k| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("fieldcraft lbm"),
layout: &layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: params[k].as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: fluid_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 2, resource: pops[if aa { 0 } else { k }].as_entire_binding() },
wgpu::BindGroupEntry { binding: 3, resource: pops[if aa { 1 } else { 1 - k }].as_entire_binding() },
wgpu::BindGroupEntry { binding: 5, resource: link_u.as_entire_binding() },
wgpu::BindGroupEntry { binding: 6, resource: link_f.as_entire_binding() },
wgpu::BindGroupEntry { binding: 7, resource: open_u.as_entire_binding() },
wgpu::BindGroupEntry { binding: 8, resource: open_f.as_entire_binding() },
wgpu::BindGroupEntry { binding: 9, resource: moments.as_entire_binding() },
wgpu::BindGroupEntry { binding: 10, resource: stats.as_entire_binding() },
wgpu::BindGroupEntry { binding: 11, resource: contrib.as_entire_binding() },
wgpu::BindGroupEntry { binding: 12, resource: by_group.as_entire_binding() },
wgpu::BindGroupEntry { binding: 13, resource: group_start.as_entire_binding() },
wgpu::BindGroupEntry { binding: 14, resource: forces.as_entire_binding() },
wgpu::BindGroupEntry { binding: 15, resource: partials.as_entire_binding() },
wgpu::BindGroupEntry { binding: 16, resource: therm[k].as_entire_binding() },
wgpu::BindGroupEntry { binding: 17, resource: therm[1 - k].as_entire_binding() },
wgpu::BindGroupEntry { binding: 18, resource: group_t.as_entire_binding() },
wgpu::BindGroupEntry { binding: 19, resource: open_t.as_entire_binding() },
],
})
});
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("fieldcraft lbm"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let mut pipelines = HashMap::new();
let pipeline_start = std::time::Instant::now();
for name in [
"collide",
"stream",
"stream_links",
"stream_links_exchange",
"scatter_links",
"open_boundaries",
"link_forces",
"group_forces",
"cell_moments",
"mass",
"collide_thermal",
"stream_thermal",
"stream_links_thermal",
"open_thermal",
"thermal_check",
] {
let p = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(name),
layout: Some(&pl),
module: &module,
entry_point: Some(name),
compilation_options: wgpu::PipelineCompilationOptions { constants: &[("AA_MODE", if aa { 1.0 } else { 0.0 })], ..Default::default() },
cache: None,
});
pipelines.insert(name, p);
}
for scope in scopes.into_iter().rev() {
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(gpu_error(e));
}
}
let pipeline_seconds = pipeline_start.elapsed().as_secs_f64();
Ok(GpuFlow {
device,
queue,
kernel_times: profile.then(Default::default),
pipeline_seconds,
aa,
adapter: adapter_name,
pipelines,
bind,
current: 0,
n,
cells,
link_count: links.len(),
links_groups,
open_count: open.len(),
open_groups,
group_count,
force_groups,
stats,
moments,
forces,
partials,
partial_count,
thermal: thermal.map(|_| therm),
initial_temperature: t0,
fluid_cells: flow.fluid_cells(),
fluid,
groups: flow.groups().to_vec(),
empty_obstacles: flow.empty_obstacles().to_vec(),
steps_done: 0,
exchange_cache: Some(vec![[0.0; 4]; group_count]),
weight_forces,
})
}
/// The adapter's name.
pub fn adapter_name(&self) -> &str {
&self.adapter
}
/// Whether the populations stream in place (see [`Streaming`]).
pub fn streams_in_place(&self) -> bool {
self.aa
}
/// How long creating the compute pipelines took, in seconds.
pub fn pipeline_seconds(&self) -> f64 {
self.pipeline_seconds
}
/// With [`Options::profile`], the GPU time of each kernel so far, by kernel name.
pub fn kernel_times(&self) -> Option<std::collections::BTreeMap<&'static str, KernelTime>> {
self.kernel_times.as_ref().map(|t| t.borrow().clone())
}
fn pipeline(&self, name: &str) -> Result<&wgpu::ComputePipeline> {
self.pipelines.get(name).ok_or_else(|| Error::Invalid(format!("GPU: no kernel {name}")))
}
fn dispatch(&self, pass: &mut wgpu::ComputePass<'_>, bind: usize, name: &str, (gx, gy): (u32, u32)) -> Result<()> {
pass.set_bind_group(0, self.bind.get(bind).ok_or_else(|| Error::Invalid("GPU: no bind group".into()))?, &[]);
pass.set_pipeline(self.pipeline(name)?);
pass.dispatch_workgroups(gx, gy, 1);
Ok(())
}
/// Timestamped passes are diagnostic: their extra pass boundaries and readback are not
/// used for throughput measurements. Normally every chunk remains one compute pass.
fn submit(&self, label: &str, commands: &[Dispatch]) -> Result<()> {
let mut enc = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some(label) });
let Some(times) = &self.kernel_times else {
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(label), timestamp_writes: None });
for &(bind, name, groups) in commands {
self.dispatch(&mut pass, bind, name, groups)?;
}
}
self.queue.submit([enc.finish()]);
return Ok(());
};
let count = u32::try_from(commands.len() * 2).map_err(|_| gpu_error("too many timestamp queries"))?;
if count == 0 || count > wgpu::QUERY_SET_MAX_QUERIES {
return Err(gpu_error("timestamp query count is outside the device limit"));
}
let query = self.device.create_query_set(&wgpu::QuerySetDescriptor { label: Some(label), ty: wgpu::QueryType::Timestamp, count });
let result = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("lbm timestamps"),
size: u64::from(count) * 8,
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
for (i, &(bind, name, groups)) in commands.iter().enumerate() {
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some(name),
timestamp_writes: Some(wgpu::ComputePassTimestampWrites {
query_set: &query,
beginning_of_pass_write_index: Some(2 * i as u32),
end_of_pass_write_index: Some(2 * i as u32 + 1),
}),
});
self.dispatch(&mut pass, bind, name, groups)?;
}
enc.resolve_query_set(&query, 0..count, &result, 0);
self.queue.submit([enc.finish()]);
let bytes = self.read(&result, u64::from(count) * 8)?;
let period = f64::from(self.queue.get_timestamp_period());
let mut totals = times.borrow_mut();
for ((_, name, _), pair) in commands.iter().zip(bytes.as_chunks::<16>().0) {
let [begin, end] = pair.as_chunks::<8>().0 else { return Err(gpu_error("invalid timestamp pair")) };
let ticks = u64::from_le_bytes(*end).checked_sub(u64::from_le_bytes(*begin)).ok_or_else(|| gpu_error("invalid GPU timestamps"))?;
let total = totals.entry(name).or_default();
total.dispatches += 1;
total.seconds += ticks as f64 * period * 1e-9;
}
Ok(())
}
/// Copy `size` bytes of `src` back to the host.
fn read(&self, src: &wgpu::Buffer, size: u64) -> Result<Vec<u8>> {
let size = size.max(16);
let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("lbm readback"),
size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("lbm readback") });
enc.copy_buffer_to_buffer(src, 0, &staging, 0, size);
self.queue.submit([enc.finish()]);
let slice = staging.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
// A bounded wait: a hung or lost device is an error, never a hang.
match self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(DEVICE_TIMEOUT) }) {
Ok(_) => {}
Err(e) => return Err(gpu_error(format!("the device did not answer within {} s: {e}", DEVICE_TIMEOUT.as_secs()))),
}
rx.try_recv().map_err(|_| gpu_error("the readback did not complete"))?.map_err(gpu_error)?;
let bytes = slice.get_mapped_range().map_err(gpu_error)?.to_vec();
staging.unmap();
Ok(bytes)
}
fn read_f32(&self, src: &wgpu::Buffer, count: usize) -> Result<Vec<f32>> {
let bytes = self.read(src, 4 * count as u64)?;
Ok(bytes.as_chunks::<4>().0.iter().take(count).map(|b| f32::from_le_bytes(*b)).collect())
}
/// The momentum (xyz) and heat (w) each group received in the last step.
fn exchange(&mut self) -> Result<&[[f64; 4]]> {
if self.exchange_cache.is_none() {
let v = self.read_f32(&self.forces, 4 * self.group_count)?;
self.exchange_cache = Some(
v.as_chunks::<4>()
.0
.iter()
.zip(&self.weight_forces)
.map(|(g, w)| [f64::from(g[0]) + w[0], f64::from(g[1]) + w[1], f64::from(g[2]) + w[2], f64::from(g[3])])
.collect(),
);
}
Ok(self.exchange_cache.as_deref().unwrap_or_default())
}
/// The moments of the current state into the moments buffer.
fn compute_moments(&self) -> Result<()> {
self.submit("lbm moments", &[(self.current, "cell_moments", self.cells)])
}
}
impl GpuFlow {
/// One submission of `steps` (≤ MAX_CHUNK) time steps, then the stability check.
fn advance_chunk(&mut self, steps: u64) -> Result<()> {
self.queue.write_buffer(&self.stats, 0, &bytes_u32(&[0, 0, 0, 0]));
let mut commands = Vec::with_capacity(8 * MAX_CHUNK as usize + 3);
for step in 0..steps {
commands.push((self.current, "collide", self.cells));
if !self.aa {
commands.push((self.current, "stream", self.cells));
}
if self.link_count > 0 {
// The last step's momentum exchange is the one read back.
let exchange = self.aa && step + 1 == steps;
commands.push((self.current, if exchange { "stream_links_exchange" } else { "stream_links" }, self.links_groups));
if self.aa {
commands.push((self.current, "scatter_links", self.links_groups));
}
}
if self.open_count > 0 {
commands.push((self.current, "open_boundaries", self.open_groups));
}
if self.thermal.is_some() {
commands.push((self.current, "collide_thermal", self.cells));
commands.push((self.current, "stream_thermal", self.cells));
if self.link_count > 0 {
commands.push((self.current, "stream_links_thermal", self.links_groups));
}
if self.open_count > 0 {
commands.push((self.current, "open_thermal", self.open_groups));
}
}
self.current ^= 1;
}
// The momentum (and heat) exchange of the last step, from its post-collision
// populations.
if self.link_count > 0 && self.group_count > 0 {
commands.push((self.current ^ 1, "link_forces", self.links_groups));
commands.push((self.current ^ 1, "group_forces", self.force_groups));
}
if self.thermal.is_some() {
commands.push((self.current, "thermal_check", self.cells));
}
self.submit("lbm steps", &commands)?;
self.steps_done += steps;
self.exchange_cache = None;
let stats = self.read(&self.stats, 16)?;
let word = |k: usize| stats.get(4 * k..4 * k + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes).unwrap_or(0);
let max_u2 = f32::from_bits(word(0));
if word(1) != 0 || !(f64::from(max_u2) <= MAX_LATTICE_SPEED * MAX_LATTICE_SPEED) {
return Err(Error::Unstable(format!(
"the flow went unstable by time step {}: the lattice speed reached {:.3} (limit {MAX_LATTICE_SPEED}) or a density stopped being a positive number; lower the time step, refine the spacing or check the boundary conditions",
self.steps_done,
max_u2.sqrt()
)));
}
if word(2) != 0 {
return Err(Error::Unstable(format!(
"the heat went unstable by time step {}: a temperature stopped being a number; lower the time step, refine the spacing or check the heat fluxes",
self.steps_done
)));
}
Ok(())
}
}
impl Backend for GpuFlow {
fn name(&self) -> String {
format!("gpu: {}, {} streaming", self.adapter, if self.aa { "in-place" } else { "two-buffer" })
}
fn advance(&mut self, steps: u64) -> Result<()> {
// Submissions of at most MAX_CHUNK steps: the stability check runs after each, so a
// run that blows up is caught within that many steps whatever the caller asks for.
let mut left = steps;
while left > 0 {
let chunk = left.min(MAX_CHUNK);
self.advance_chunk(chunk)?;
left -= chunk;
}
Ok(())
}
fn steps_done(&self) -> u64 {
self.steps_done
}
fn fields(&mut self) -> Result<Fields> {
self.compute_moments()?;
let m = self.read_f32(&self.moments, 4 * self.n)?;
let density = m.as_chunks::<4>().0.iter().map(|v| f64::from(v[3])).collect();
let velocity = m.as_chunks::<4>().0.iter().map(|v| [f64::from(v[0]), f64::from(v[1]), f64::from(v[2])]).collect();
// The temperature from the scalar populations, summed on the host: T₀ + Σ t (T₀ in
// solid cells, whose populations stay zero).
let temperature = match &self.thermal {
None => Vec::new(),
Some(therm) => {
let t = self.read_f32(therm.get(self.current).ok_or_else(|| Error::Invalid("GPU: no thermal buffer".into()))?, QT * self.n)?;
(0..self.n)
.map(|cell| self.initial_temperature + (0..QT).map(|q| t.get(q * self.n + cell).copied().map_or(0.0, f64::from)).sum::<f64>())
.collect()
}
};
Ok(Fields { density, velocity, temperature })
}
fn heat(&mut self) -> Result<Vec<f64>> {
if self.thermal.is_none() {
return Ok(Vec::new());
}
Ok(self.exchange()?.iter().map(|g| g[3]).collect())
}
fn mass(&mut self) -> Result<f64> {
self.compute_moments()?;
self.submit("lbm mass", &[(self.current, "mass", self.cells)])?;
let partials = self.read_f32(&self.partials, self.partial_count)?;
// Summed in a fixed order on the host, so the result is deterministic on this device.
Ok(partials.iter().map(|v| f64::from(*v)).sum())
}
fn forces(&mut self) -> Result<Vec<[f64; 3]>> {
Ok(self.exchange()?.iter().map(|g| [g[0], g[1], g[2]]).collect())
}
fn groups(&self) -> &[Group] {
&self.groups
}
fn fluid_cells(&self) -> usize {
self.fluid_cells
}
fn is_fluid(&self, cell: usize) -> bool {
self.fluid.get(cell).copied().unwrap_or(false)
}
fn empty_obstacles(&self) -> &[String] {
&self.empty_obstacles
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Throughput of the GPU backend (ignored; run with
//! `cargo test --release -p fieldcraft-lbm-gpu --test throughput -- --ignored --nocapture`),
//! on the cases of `fieldcraft-lbm`'s throughput test, streaming in place and with two buffers
//! (what `Streaming::Auto` picks on this device is marked). `FIELDCRAFT_LBM_GPU_PROFILE=1`
//! adds each kernel's GPU time (timestamp queries; the throughput is then not representative).
#[path = "../../lbm/tests/common/mod.rs"]
mod common;
use common::*;
use fieldcraft_gpu::select::Policy;
use fieldcraft_lbm::backend::Backend;
use fieldcraft_lbm_gpu::{GpuFlow, Options, Streaming};
use fieldcraft_model::{BoxFace, FaceCondition};
#[test]
#[ignore]
fn cell_updates_per_second() {
for (name, l, sphere_radius) in [("periodic box", 64usize, 0.0), ("periodic box", 128, 0.0), ("box with a sphere", 128, 32.0)] {
let side = l as f64;
let mut d = domain([0.0; 3], [side; 3], 1.0);
for f in BoxFace::ALL {
*d.faces.get_mut(f) = FaceCondition::Periodic {};
}
d.acceleration = [1e-6, 0.0, 0.0];
let mut obstacles = Vec::new();
if sphere_radius > 0.0 {
d.obstacles = vec!["Ball".into()];
obstacles.push(obstacle("Ball", sphere([side / 2.0; 3], sphere_radius, 4), &["skin"]));
}
let steps = 200;
let m = model(
1.0,
1.0 / 6.0,
d,
fieldcraft_model::Procedure::Fluid {
domain: "Flow".into(),
end_time: None,
steps: Some(steps),
time_step: Some(1.0),
frames: 1,
magic: None,
},
);
let (_, flow) = prepare(&m, &obstacles);
let policy = Policy::from_env().expect("FIELDCRAFT_GPU names a policy");
let profile = std::env::var("FIELDCRAFT_LBM_GPU_PROFILE").is_ok_and(|v| v == "1");
let Ok(auto) = GpuFlow::new(&flow, policy) else {
println!("skipping {name}, {l}³: no GPU or not enough memory");
continue;
};
let picked = auto.streams_in_place();
drop(auto);
for (streaming, in_place) in [(Streaming::InPlace, true), (Streaming::TwoBuffers, false)] {
let mut gpu = GpuFlow::with_options(&flow, policy, Options { streaming, profile }).unwrap();
// One warm-up chunk, then the timed run: the chunk's steps are one submission.
gpu.advance(1).unwrap();
let start = std::time::Instant::now();
gpu.advance(steps).unwrap();
let _ = gpu.mass().unwrap();
let s = start.elapsed().as_secs_f64();
println!(
"{name}, {l}³ on {}, {}{}: {:.1} M cell updates/s (pipelines {:.2} s)",
gpu.adapter_name(),
if in_place { "in place" } else { "two buffers" },
if in_place == picked { " (auto)" } else { "" },
(gpu.fluid_cells() as f64 * steps as f64) / s / 1e6,
gpu.pipeline_seconds()
);
for (kernel, t) in gpu.kernel_times().unwrap_or_default() {
println!(" {kernel}: {} dispatches, {:.3} ms each", t.dispatches, 1e3 * t.seconds / t.dispatches.max(1) as f64);
}
}
}
}
+451
View File
@@ -0,0 +1,451 @@
//! The GPU backend against the CPU scheme and the analytic cases of `fieldcraft-lbm`'s
//! verification suite. The stated tolerances of the backend, with populations stored as
//! deviations from their weights in single precision (measured on lavapipe at 2–5e-6, with
//! a margin): velocity fields within 2e-5 of the largest speed, densities within 1e-6, wall
//! forces within 5e-5 of their magnitude (a wall's shear within 5e-5 of itself), mass drift below
//! 1e-6; with heat, temperatures within 2e-5 of the field's range and wall heats within 1e-4 of
//! the largest (a heat is a difference of populations, so it keeps fewer digits). Skipped without
//! a GPU adapter unless `FIELDCRAFT_REQUIRE_GPU=1`, which makes a missing adapter a failure;
//! `FIELDCRAFT_GPU` picks the adapter (`hardware` refuses a software one).
#[path = "../../lbm/tests/common/mod.rs"]
mod common;
use common::*;
use fieldcraft_gpu::select::{Policy, gpu_required};
use fieldcraft_lbm::backend::Backend;
use fieldcraft_lbm::{Flow, Setup};
use fieldcraft_lbm_gpu::{GpuFlow, Options, Streaming};
use fieldcraft_model::{
BoxFace, Directional, FaceCondition, Fluid, FluidDomain, FluidThermal, Material, Model, Procedure, Profile, Step, Thermal, ThermalCondition,
};
const PERIODIC: FaceCondition = FaceCondition::Periodic {};
/// The flow on the GPU `FIELDCRAFT_GPU` picks, or `None` to skip (a failure when a GPU is
/// required).
fn gpu(flow: &Flow, streaming: Streaming) -> Option<GpuFlow> {
let policy = Policy::from_env().expect("FIELDCRAFT_GPU names a policy");
match GpuFlow::with_options(flow, policy, Options { streaming, profile: false }) {
Ok(g) => {
println!("GPU: {} ({streaming:?})", g.adapter_name());
Some(g)
}
Err(e) if gpu_required() => panic!("a GPU is required: {e}"),
// Only a missing device skips: a shader that does not compile is a failure anywhere.
Err(e) if e.to_string().contains("no GPU device") => {
println!("skipping: {e}");
None
}
Err(e) => panic!("{e}"),
}
}
/// Both backends through every step; the CPU's fields and the GPU's.
fn both(setup: &Setup, mut cpu: Flow, streaming: Streaming) -> Option<(Flow, GpuFlow)> {
let mut gpu = gpu(&cpu, streaming)?;
cpu.advance(setup.steps).unwrap();
gpu.advance(setup.steps).unwrap();
Some((cpu, gpu))
}
/// The largest difference of the velocity fields over the largest CPU speed, and of the density
/// fields.
fn field_difference(cpu: &mut Flow, gpu: &mut GpuFlow) -> (f64, f64) {
let (a, b) = (cpu.fields().unwrap(), gpu.fields().unwrap());
let scale = a.velocity.iter().map(|v| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()).fold(0.0, f64::max).max(1e-30);
let du = a
.velocity
.iter()
.zip(&b.velocity)
.map(|(x, y)| ((x[0] - y[0]).powi(2) + (x[1] - y[1]).powi(2) + (x[2] - y[2]).powi(2)).sqrt())
.fold(0.0, f64::max);
let drho = a.density.iter().zip(&b.density).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max);
(du / scale, drho)
}
#[test]
fn plane_poiseuille_matches_the_analytic_profile_and_the_cpu() {
// Both layouts: the default depends on the device (see `Streaming`).
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
let (h, nu, umax, n) = (1.0, 1.0 / 6.0, 0.1, 16usize);
let dx = h / n as f64;
let a = 8.0 * nu * umax / (h * h);
let mut d = domain([0.0; 3], [dx, h, dx], dx);
d.faces.x_min = PERIODIC;
d.faces.x_max = PERIODIC;
d.faces.z_min = PERIODIC;
d.faces.z_max = PERIODIC;
d.acceleration = [a, 0.0, 0.0];
let m = model(1.0, nu, d, step(4.0 * h * h / nu, dx * dx, Some(3.0 / 16.0)));
let (setup, flow) = prepare(&m, &[]);
let Some((mut cpu, mut gpu)) = both(&setup, flow, streaming) else { continue };
let f = gpu.fields().unwrap();
let mut err: f64 = 0.0;
for j in 0..n {
let y = (j as f64 + 0.5) * dx;
let exact = a / (2.0 * nu) * y * (h - y);
err = err.max((f.velocity[j][0] * setup.velocity_scale() - exact).abs() / umax);
}
let (du, drho) = field_difference(&mut cpu, &mut gpu);
println!("Poiseuille on the GPU: max error {err:.2e} of the peak; CPU/GPU velocity {du:.2e}, density {drho:.2e}");
assert!(err < 2e-5, "{err}");
assert!(du < 2e-5 && drho < 1e-6, "{du} {drho}");
}
}
#[test]
fn couette_wall_forces_match_the_cpu() {
// Both layouts: the default depends on the device (see `Streaming`).
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
let (h, nu, uw, n) = (1.0, 0.1, 0.05, 16usize);
let dx = h / n as f64;
let mut d = domain([0.0; 3], [dx, h, dx], dx);
d.faces.x_min = PERIODIC;
d.faces.x_max = PERIODIC;
d.faces.z_min = PERIODIC;
d.faces.z_max = PERIODIC;
d.faces.y_max = FaceCondition::MovingWall { velocity: [uw, 0.0, 0.0] };
let m = model(1.0, nu, d, step(3.0 * h * h / nu, 0.5 * dx * dx / (3.0 * nu), None));
let (setup, flow) = prepare(&m, &[]);
let Some((mut cpu, mut gpu)) = both(&setup, flow, streaming) else { continue };
let (fc, fg) = (Backend::forces(&mut cpu).unwrap(), gpu.forces().unwrap());
let bottom = BoxFace::ALL.iter().position(|f| f.name() == "y_min").unwrap();
let top = BoxFace::ALL.iter().position(|f| f.name() == "y_max").unwrap();
let tau = nu * uw / h / setup.force_scale() * dx * dx;
println!("Couette: wall force CPU {:?} / GPU {:?} (expected ±{tau:.3e} per step)", fc[bottom], fg[bottom]);
// The tangential force is the shear: a cancellation of the links' exchanges to 1e-6 per link,
// summed from the stored deviations alone (adding the weights back in single precision left
// it 1e-4 of itself on an RTX 4090; 3e-6 on lavapipe this way). The normal force is the
// pressure.
for g in [bottom, top] {
println!("Couette group {g}: CPU {:.9e}, GPU {:.9e}, shear parity {:.9e}", fc[g][0], fg[g][0], (fc[g][0] - fg[g][0]).abs() / tau.abs());
assert!((fc[g][0] - fg[g][0]).abs() <= 5e-5 * tau.abs(), "group {g} shear: {} vs {}", fc[g][0], fg[g][0]);
assert!((fc[g][1] - fg[g][1]).abs() <= 1e-6 * fc[g][1].abs(), "group {g} normal: {} vs {}", fc[g][1], fg[g][1]);
}
let (du, drho) = field_difference(&mut cpu, &mut gpu);
assert!(du < 2e-5 && drho < 1e-6, "{du} {drho}");
}
}
#[test]
fn taylor_green_decays_like_the_cpu_and_conserves_mass() {
// Both layouts: the default depends on the device (see `Streaming`).
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
let (nu, u0, l, n) = (0.1, 0.1, 2.0 * std::f64::consts::PI, 32usize);
let dx = l / n as f64;
let mut d = domain([0.0; 3], [l, l, dx], dx);
for f in BoxFace::ALL {
*d.faces.get_mut(f) = PERIODIC;
}
let dt = 0.3 * dx * dx / (3.0 * nu);
let half_life = std::f64::consts::LN_2 / (2.0 * nu);
let m = model(1.0, nu, d, step(half_life, dt, None));
let (setup, mut flow) = prepare(&m, &[]);
let grid = *flow.grid();
let to_lattice = 1.0 / setup.velocity_scale();
flow.set_equilibrium(|c| {
let [x, y, _] = grid.centre(c);
let p = -u0 * u0 / 4.0 * ((2.0 * x).cos() + (2.0 * y).cos());
(setup.lattice_density(p), [-x.cos() * y.sin() * u0 * to_lattice, x.sin() * y.cos() * u0 * to_lattice, 0.0])
});
let Some(mut gpu) = gpu(&flow, streaming) else { continue };
let mass0 = gpu.mass().unwrap();
let mut cpu = flow;
cpu.advance(setup.steps).unwrap();
gpu.advance(setup.steps).unwrap();
let drift = (gpu.mass().unwrap() - mass0).abs() / mass0;
let (du, drho) = field_difference(&mut cpu, &mut gpu);
let f = gpu.fields().unwrap();
let (mut num, mut den) = (0.0, 0.0);
for c in 0..grid.len() {
let [x, y, _] = grid.centre(c);
let mode = [-x.cos() * y.sin(), x.sin() * y.cos()];
num += f.velocity[c][0] * mode[0] + f.velocity[c][1] * mode[1];
den += mode[0] * mode[0] + mode[1] * mode[1];
}
let amplitude = num / den * setup.velocity_scale();
let exact = u0 * (-2.0 * nu * setup.steps as f64 * setup.time_step).exp();
let err = (amplitude / exact - 1.0).abs();
println!("Taylor–Green on the GPU: amplitude error {err:.2e}; CPU/GPU velocity {du:.2e}, density {drho:.2e}; mass drift {drift:.2e}");
// The amplitude error is the scheme's at 32 cells (the CPU test shows second order).
assert!(err < 5e-3, "{err}");
assert!(du < 2e-5 && drho < 1e-6, "{du} {drho}");
assert!(drift < 1e-6, "{drift}");
}
}
#[test]
fn flow_past_a_sphere_matches_the_cpu_fields_and_forces() {
// Both layouts: the default depends on the device (see `Streaming`).
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
let l = 24usize;
let side = l as f64;
let mut d = domain([0.0; 3], [side; 3], 1.0);
for f in BoxFace::ALL {
*d.faces.get_mut(f) = PERIODIC;
}
d.acceleration = [1e-5, 0.0, 0.0];
d.obstacles = vec!["Ball".into()];
let ball = obstacle("Ball", sphere([side / 2.0; 3], 6.0, 3), &["skin"]);
let m = model(
1.0,
1.0 / 6.0,
d,
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(300), time_step: Some(1.0), frames: 1, magic: None },
);
let (setup, flow) = prepare(&m, std::slice::from_ref(&ball));
let Some((mut cpu, mut gpu)) = both(&setup, flow, streaming) else { continue };
let (du, drho) = field_difference(&mut cpu, &mut gpu);
let (fc, fg) = (Backend::forces(&mut cpu).unwrap(), gpu.forces().unwrap());
let skin = cpu.groups().iter().position(|g| g.name == "skin").unwrap();
let scale = fc[skin].iter().map(|v| v.abs()).fold(0.0, f64::max);
let dforce = (0..3).map(|a| (fc[skin][a] - fg[skin][a]).abs()).fold(0.0, f64::max) / scale;
println!("Sphere: CPU/GPU velocity {du:.2e}, density {drho:.2e}; drag CPU {:?} GPU {:?} ({dforce:.2e})", fc[skin], fg[skin]);
assert!(du < 2e-5 && drho < 1e-6, "{du} {drho}");
assert!(dforce < 5e-5, "{dforce}");
}
}
#[test]
fn zou_he_channel_matches_the_cpu() {
// Both layouts: the default depends on the device (see `Streaming`).
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
let (h, l, umax, nu) = (1.0, 4.0, 0.1, 0.05);
let dx = h / 10.0;
let mut d = domain([0.0; 3], [l, h, dx], dx);
d.faces.x_min = FaceCondition::Velocity { velocity: [umax, 0.0, 0.0], profile: Profile::Parabolic };
d.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
d.faces.z_min = PERIODIC;
d.faces.z_max = PERIODIC;
let m = model(1.0, nu, d, step(2.0 * l / umax, 0.05 * dx / umax, None));
let (setup, flow) = prepare(&m, &[]);
let Some((mut cpu, mut gpu)) = both(&setup, flow, streaming) else { continue };
let (du, drho) = field_difference(&mut cpu, &mut gpu);
println!("Zou–He channel: CPU/GPU velocity {du:.2e}, density {drho:.2e}");
assert!(du < 2e-5 && drho < 1e-6, "{du} {drho}");
}
}
// ---- Heat ----
/// A fluid with heat: density ρ, kinematic viscosity ν, thermal diffusivity α (conductivity
/// ρ α, unit specific heat).
fn hot_model(density: f64, nu: f64, alpha: f64, domain: FluidDomain, procedure: Procedure) -> Model {
let mut m = Model::default();
m.add_material(
"Fluid",
Material {
density: Some(density),
fluid: Some(Fluid::Newtonian { viscosity: nu * density }),
thermal: Some(Thermal { conductivity: Some(Directional::Isotropic(density * alpha)), specific_heat: Some(1.0), ..Default::default() }),
..Material::default()
},
)
.unwrap();
m.add_fluid_domain("Flow", domain).unwrap();
m.add_step(Step::new("Run", procedure), None).unwrap();
m
}
/// The largest difference of the temperature fields over the CPU's temperature range, and of
/// the groups' heats over the largest CPU heat.
fn heat_difference(cpu: &mut Flow, gpu: &mut GpuFlow) -> (f64, f64) {
let (a, b) = (cpu.fields().unwrap(), gpu.fields().unwrap());
assert_eq!(a.temperature.len(), b.temperature.len());
let (lo, hi) = a.temperature.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), t| (lo.min(*t), hi.max(*t)));
let dt = a.temperature.iter().zip(&b.temperature).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max) / (hi - lo).max(1e-30);
let (ha, hb) = (cpu.heat().unwrap(), gpu.heat().unwrap());
assert_eq!(ha.len(), hb.len());
let scale = ha.iter().map(|h| h.abs()).fold(0.0, f64::max).max(1e-30);
let dh = ha.iter().zip(&hb).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max) / scale;
(dt, dh)
}
/// Plates held at 20 and 21 with the fluid at rest: the steady temperature is linear between
/// them (anti-bounce-back puts the walls halfway between cells), the heat through both walls
/// is k ΔT A / H, and the GPU agrees with the CPU although it stores the small differences
/// about T₀ = 20 in single precision.
#[test]
fn conduction_between_plates_matches_the_analytic_profile_and_the_cpu() {
// Both layouts: the default depends on the device (see `Streaming`).
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
let (h, alpha, n) = (1.0, 0.05, 16usize);
let dx = h / n as f64;
let mut d = domain([0.0; 3], [dx, h, dx], dx);
d.faces.x_min = PERIODIC;
d.faces.x_max = PERIODIC;
d.faces.z_min = PERIODIC;
d.faces.z_max = PERIODIC;
let mut t = FluidThermal { initial_temperature: 20.0, ..FluidThermal::default() };
t.faces.y_min = ThermalCondition::Temperature { value: 20.0 };
t.faces.y_max = ThermalCondition::Temperature { value: 21.0 };
d.thermal = Some(t);
let m = hot_model(1.0, 0.1, alpha, d, step(8.0 * h * h / alpha, dx * dx / (8.0 * alpha), None));
let (setup, cpu) = prepare(&m, &[]);
let Some((mut cpu, mut gpu)) = both(&setup, cpu, streaming) else { continue };
let f = gpu.fields().unwrap();
let grid = *cpu.grid();
let mut err: f64 = 0.0;
for j in 0..n {
let expected = 20.0 + (j as f64 + 0.5) / n as f64;
err = err.max((f.temperature[grid.index([0, j, 0])] - expected).abs());
}
let q = alpha * 1.0 / h * dx * dx;
let heat = gpu.heat().unwrap();
let cold = cpu.groups().iter().position(|g| g.name == "y_min").unwrap();
let hot = cpu.groups().iter().position(|g| g.name == "y_max").unwrap();
// lattice heat (temperature × faces per step) to model units: ρ c h³ / dt
let scale = setup.thermal.as_ref().unwrap().heat_scale;
let (dt, dh) = heat_difference(&mut cpu, &mut gpu);
println!(
"conduction: profile error {err:.2e}; heat {:.6e} / {:.6e} vs {q:.6e}; GPU vs CPU: temperature {dt:.2e} heat {dh:.2e}",
heat[cold] * scale,
heat[hot] * scale
);
assert!(err < 5e-5, "{err}");
assert!((heat[cold] * scale / q - 1.0).abs() < 1e-4 && (heat[hot] * scale / q + 1.0).abs() < 1e-4, "{heat:?}");
assert!(dt < 2e-5 && dh < 1e-4, "{dt} {dh}");
}
}
/// A heated channel: a velocity inlet carrying 1 in, a pressure outlet, a flux into the fluid
/// at the bottom wall, the top wall held at 0, periodic across. Every rule of the scalar
/// lattice (pulls, the three wall conditions, the inlet and the outlet) agrees with the CPU.
#[test]
fn a_heated_channel_matches_the_cpu() {
// Both layouts: the default depends on the device (see `Streaming`).
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
let (h, l, umax, nu, alpha) = (1.0, 2.0, 0.1, 0.05, 0.02);
let dx = h / 8.0;
let mut d = domain([0.0; 3], [l, h, dx], dx);
d.faces.x_min = FaceCondition::Velocity { velocity: [umax, 0.0, 0.0], profile: Profile::Parabolic };
d.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
d.faces.z_min = PERIODIC;
d.faces.z_max = PERIODIC;
let mut t = FluidThermal::default();
t.faces.x_min = ThermalCondition::Temperature { value: 1.0 };
t.faces.y_min = ThermalCondition::Flux { value: 0.05 };
t.faces.y_max = ThermalCondition::Temperature { value: 0.0 };
d.thermal = Some(t);
let m = hot_model(1.0, nu, alpha, d, step(2.0 * l / umax, 0.05 * dx / umax, None));
let (setup, cpu) = prepare(&m, &[]);
let Some((mut cpu, mut gpu)) = both(&setup, cpu, streaming) else { continue };
let (du, drho) = field_difference(&mut cpu, &mut gpu);
let (dt, dh) = heat_difference(&mut cpu, &mut gpu);
println!("heated channel: GPU vs CPU: velocity {du:.2e} density {drho:.2e} temperature {dt:.2e} heat {dh:.2e}");
assert!(du < 2e-5 && drho < 1e-6, "{du} {drho}");
assert!(dt < 2e-5 && dh < 1e-4, "{dt} {dh}");
}
}
/// A differentially heated cavity with buoyancy (the de Vahl Davis geometry at Ra 10³ on 16
/// cells, over the first diffusive time): the temperature drives the flow on both backends
/// alike.
#[test]
fn natural_convection_matches_the_cpu() {
// Both layouts: the default depends on the device (see `Streaming`).
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
let (h, pr, alpha, n, u_max) = (1.0, 0.71, 1.0, 16usize, 3.65);
let nu = pr * alpha;
let g_beta = 1e3 * nu * alpha / (h * h * h);
let dx = h / n as f64;
let mut d = domain([0.0; 3], [h, h, dx], dx);
d.faces.z_min = PERIODIC;
d.faces.z_max = PERIODIC;
let mut t = FluidThermal { initial_temperature: 0.5, gravity: [0.0, -g_beta, 0.0], expansion: Some(1.0), ..FluidThermal::default() };
t.faces.x_min = ThermalCondition::Temperature { value: 1.0 };
t.faces.x_max = ThermalCondition::Temperature { value: 0.0 };
d.thermal = Some(t);
let dt = (0.05 * dx / (u_max * alpha / h)).min(dx * dx / (4.0 * alpha));
let m = hot_model(1.0, nu, alpha, d, step(h * h / alpha, dt, None));
let (setup, cpu) = prepare(&m, &[]);
let Some((mut cpu, mut gpu)) = both(&setup, cpu, streaming) else { continue };
let (du, drho) = field_difference(&mut cpu, &mut gpu);
let (dt, dh) = heat_difference(&mut cpu, &mut gpu);
// the fields are in lattice units: the time step puts the cavity's fastest flow at 0.05
let speed = cpu.fields().unwrap().velocity.iter().map(|v| (v[0] * v[0] + v[1] * v[1]).sqrt()).fold(0.0, f64::max);
println!("cavity: fastest lattice speed {speed:.3e}; GPU vs CPU: velocity {du:.2e} density {drho:.2e} temperature {dt:.2e} heat {dh:.2e}");
assert!(speed > 0.01, "the buoyancy drives a flow: {speed}");
assert!(du < 2e-5 && drho < 1e-6, "{du} {drho}");
assert!(dt < 2e-5 && dh < 1e-4, "{dt} {dh}");
}
}
/// Readbacks and force caches must describe the same state on both AA orientations,
/// including calls that cross a 64-step submission boundary. The CPU defines the result.
fn compare_incremental(mut cpu: Flow, heated: bool, streaming: Streaming) {
let Some(mut gpu) = gpu(&cpu, streaming) else { return };
let mut total = 0;
for steps in [1, 1, 61, 1, 1, 64, 65] {
cpu.advance(steps).unwrap();
gpu.advance(steps).unwrap();
total += steps;
assert_eq!(gpu.steps_done(), total);
let (du, drho) = field_difference(&mut cpu, &mut gpu);
println!("incremental step {total}: velocity {du:.9e}, density {drho:.9e}");
assert!(du < 2e-5 && drho < 1e-6, "step {total}: {du} {drho}");
let mass = Backend::mass(&mut cpu).unwrap();
assert!((gpu.mass().unwrap() - mass).abs() < 1e-6 * mass.abs());
let a = Backend::forces(&mut cpu).unwrap();
let b = gpu.forces().unwrap();
let scale = a.iter().flatten().map(|v| v.abs()).fold(0.0, f64::max).max(1e-30);
let error = a.iter().flatten().zip(b.iter().flatten()).map(|(a, b)| (a - b).abs()).fold(0.0, f64::max);
assert!(error < 5e-5 * scale, "step {total}: force {error} / {scale}");
gpu.advance(0).unwrap();
assert_eq!(gpu.forces().unwrap(), b, "a zero-step advance preserves the force cache");
if heated {
let (dt, dh) = heat_difference(&mut cpu, &mut gpu);
assert!(dt < 2e-5 && dh < 1e-4, "step {total}: heat {dt} {dh}");
}
}
}
#[test]
fn odd_even_and_chunk_readbacks_match_the_cpu_with_heat_open_and_slip_faces() {
// Both layouts: the default depends on the device (see `Streaming`).
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
let mut d = domain([0.0; 3], [16.0, 8.0, 1.0], 1.0);
d.faces.x_min = FaceCondition::Velocity { velocity: [0.01, 0.0, 0.0], profile: Profile::Uniform };
d.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
d.faces.y_max = FaceCondition::Slip {};
d.faces.z_min = PERIODIC;
d.faces.z_max = PERIODIC;
let mut t = FluidThermal { initial_temperature: 0.5, ..FluidThermal::default() };
t.faces.x_min = ThermalCondition::Temperature { value: 1.0 };
t.faces.y_min = ThermalCondition::Flux { value: 0.001 };
t.faces.y_max = ThermalCondition::Temperature { value: 0.0 };
d.thermal = Some(t);
let m = hot_model(
1.0,
0.1,
0.05,
d,
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(194), time_step: Some(1.0), frames: 1, magic: None },
);
let (_, flow) = prepare(&m, &[]);
compare_incremental(flow, true, streaming);
}
}
#[test]
fn odd_even_and_chunk_readbacks_match_the_cpu_around_an_obstacle() {
// Both layouts: the default depends on the device (see `Streaming`).
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
let mut d = domain([0.0; 3], [12.0; 3], 1.0);
for face in BoxFace::ALL {
*d.faces.get_mut(face) = PERIODIC;
}
d.acceleration = [1e-5, 0.0, 0.0];
d.obstacles = vec!["Ball".into()];
let ball = obstacle("Ball", sphere([6.0; 3], 3.0, 2), &["skin"]);
let m = model(
1.0,
1.0 / 6.0,
d,
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(194), time_step: Some(1.0), frames: 1, magic: None },
);
let (_, flow) = prepare(&m, &[ball]);
compare_incremental(flow, false, streaming);
}
}
+96
View File
@@ -0,0 +1,96 @@
//! What steps a prepared flow: the CPU scheme in [`crate::flow::Flow`], or a device backend an
//! application registers (a GPU crate above this layer implements the trait and hands a factory
//! to [`register_backend`]; `solve.fluid` picks it when asked). Every backend takes its grid,
//! links and open cells from the [`Flow`] that prepared them, so voxelisation and every boundary
//! rule exist once, here.
//!
//! The CPU result is the reference: a device backend is deterministic on its device and agrees
//! with the CPU within a stated tolerance (its tests say which).
use std::sync::OnceLock;
use crate::flow::{Flow, Group};
use crate::setup::Setup;
use crate::{Error, Result};
/// The density and velocity of every cell (lattice units; zero in solid cells), and the
/// temperature (model units) when the flow carries heat (empty otherwise).
#[derive(Debug, Clone, PartialEq)]
pub struct Fields {
pub density: Vec<f64>,
pub velocity: Vec<[f64; 3]>,
pub temperature: Vec<f64>,
}
/// A stepper of one prepared flow.
pub trait Backend {
/// What steps the flow, for reports: `cpu`, `gpu: <adapter>`.
fn name(&self) -> String;
/// Advance `steps` time steps. Fails with [`Error::Unstable`] when a density stops being a
/// positive number or a lattice speed exceeds [`crate::setup::MAX_LATTICE_SPEED`] (a device
/// backend may notice at the end of the chunk rather than the step).
fn advance(&mut self, steps: u64) -> Result<()>;
fn steps_done(&self) -> u64;
/// The density and velocity (with the half force) of every cell.
fn fields(&mut self) -> Result<Fields>;
/// The total mass of the fluid cells (the sum of every population).
fn mass(&mut self) -> Result<f64>;
/// The momentum each force group received in the last step (lattice units: force per step).
fn forces(&mut self) -> Result<Vec<[f64; 3]>>;
/// The heat each force group received in the last step (lattice units: temperature times
/// cell faces per step); empty without heat.
fn heat(&mut self) -> Result<Vec<f64>>;
fn groups(&self) -> &[Group];
fn fluid_cells(&self) -> usize;
fn is_fluid(&self, cell: usize) -> bool;
/// Obstacles that cover no cell centre.
fn empty_obstacles(&self) -> &[String];
}
/// Makes a device backend from a prepared flow (its initial state included) and its setup.
pub type BackendFactory = dyn Fn(&Flow, &Setup) -> Result<Box<dyn Backend>> + Send + Sync;
static FACTORY: OnceLock<Box<BackendFactory>> = OnceLock::new();
/// Register the process's device backend (an application does this once at start; a second
/// registration is ignored).
pub fn register_backend(factory: Box<BackendFactory>) {
let _ = FACTORY.set(factory);
}
/// The registered device backend's factory, if an application registered one.
pub fn backend_factory() -> Option<&'static BackendFactory> {
FACTORY.get().map(|b| b.as_ref())
}
/// Which backend a solve asks for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Choice {
/// The CPU scheme: the reference, bit for bit reproducible.
#[default]
Cpu,
/// The registered device backend; an error when none is registered.
Device,
}
impl Choice {
/// `cpu` or `gpu`, exactly.
pub fn parse(s: &str) -> Option<Choice> {
match s {
"cpu" => Some(Choice::Cpu),
"gpu" => Some(Choice::Device),
_ => None,
}
}
}
/// The backend `choice` names for `flow`.
pub fn backend(choice: Choice, flow: Flow, setup: &Setup) -> Result<Box<dyn Backend>> {
match choice {
Choice::Cpu => Ok(Box::new(flow)),
Choice::Device => match backend_factory() {
Some(make) => make(&flow, setup),
None => Err(Error::Invalid("no GPU backend is available in this build: solve on the CPU (`backend: \"cpu\"`)".into())),
},
}
}
+503 -23
View File
@@ -27,6 +27,20 @@
//! 65:041203, 2002): a population `f*` leaving a fluid cell toward the wall and the one `f'`
//! coming back transfer `c_out f* − c_in f'` to the wall.
//!
//! # Heat
//! With thermal settings a second lattice carries the temperature: D3Q7 (the rest direction and
//! the six axis directions, the first seven of D3Q19), weights ¼ and ⅛, `c_s² = ¼`, equilibrium
//! `w_q T (1 + 4 c_q·u)` with the velocity the momentum lattice just collided to, BGK collision
//! with `τ_T = 4 α + ½` (Krüger et al. 2017, ch. 8: the passive-scalar double-distribution
//! model). Its streaming follows the momentum lattice's links: a wall held at a temperature is
//! anti-bounce-back (`g_q = −g*_q̄ + 2 w_q T_w`, which puts the temperature halfway between the
//! cells), an adiabatic wall bounces back, a heat flux through a wall face adds the flux to the
//! bounced population (one axis direction crosses each wall face, so the cell gains exactly that
//! heat), an inlet holds its temperature (equilibrium at the cell's velocity) and an outlet takes
//! its neighbour's. Buoyancy is the Boussinesq force per mass `−β (T − T₀) g`, added to the body
//! force of every cell (Guo forcing as for the constant acceleration). The heat each wall face
//! receives is summed over its links like the momentum exchange.
//!
//! # Layout and determinism
//! Two arrays of populations, structure of arrays (`f[q n + cell]`), A–B pattern. Streaming
//! pulls: every population of a cell comes from one source (a neighbour, a periodic image, the
@@ -36,7 +50,7 @@
//! only its own populations; the wall forces are summed in link order. There are no parallel
//! reductions, so the result is the same bit for bit on any number of threads and platform.
use fieldcraft_model::{BoxFace, FaceCondition, Profile};
use fieldcraft_model::{BoxFace, FaceCondition, Profile, ThermalCondition};
use fieldcraft_parallel as parallel;
@@ -45,9 +59,10 @@ use crate::setup::{MAX_LATTICE_SPEED, Obstacle, Setup};
use crate::voxel::{Grid, TriangleTree, voxelise};
use crate::{Error, Result};
/// Where a population of a cell next to a wall or box face comes from.
/// Where a population of a cell next to a wall or box face comes from (every other population
/// streams from the neighbour at `−c_q`). Public so a device backend can apply the same rules.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Rule {
pub enum Rule {
/// The same direction from another cell (a neighbour or a periodic image).
Pull { from: u32 },
/// Bounce-back at a wall of force group `group`; `du` is the moving-wall term.
@@ -59,25 +74,26 @@ enum Rule {
}
/// A population, direction `q` of `cell`, and where it comes from.
#[derive(Debug, Clone, Copy)]
struct Link {
cell: u32,
q: u8,
rule: Rule,
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Link {
pub cell: u32,
pub q: u8,
pub rule: Rule,
}
/// An inlet or outlet cell.
#[derive(Debug, Clone, Copy)]
struct OpenCell {
cell: u32,
axis: usize,
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct OpenCell {
pub cell: u32,
pub axis: usize,
/// +1 at a min face (the inward normal points up the axis), −1 at a max face.
inward: f64,
target: Target,
pub inward: f64,
pub target: Target,
}
#[derive(Debug, Clone, Copy)]
enum Target {
/// What an open cell holds.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Target {
/// The velocity (lattice units).
Velocity([f64; 3]),
/// The density.
@@ -93,6 +109,67 @@ pub struct Group {
pub body: Option<String>,
}
/// The scalar lattice: directions 0..7 of D3Q19 (rest and the axes).
const QT: usize = 7;
/// Its weights.
const WT: [f64; QT] = [0.25, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125];
/// The scalar equilibrium `w_q T (1 + c_q·u / c_s²)`, `c_s² = ¼`.
#[inline(always)]
fn equilibrium_t(q: usize, t: f64, u: [f64; 3]) -> f64 {
let cq = c(q);
WT.get(q).copied().unwrap_or(0.0) * t * (1.0 + 4.0 * (cq[0] * u[0] + cq[1] * u[1] + cq[2] * u[2]))
}
/// The temperature lattice and what it needs (see the module notes).
#[derive(Debug, Clone)]
pub struct ThermalState {
/// `g[q stride + cell]` and the streaming target, A–B like the momentum lattice.
g: Vec<f64>,
g2: Vec<f64>,
/// The temperature of every cell after the last step (the initial one in solid cells).
temperature: Vec<f64>,
omega: f64,
initial: f64,
/// `−β g` in lattice units; zero without buoyancy.
buoyancy: [f64; 3],
/// Each force group's thermal condition (fluxes in lattice units).
groups: Vec<ThermalCondition>,
/// Per open cell: the temperature it holds (an inlet), or `None` to take its neighbour's
/// (an outlet).
open: Vec<Option<f64>>,
/// The heat each group received in the last step (lattice units: temperature times cell
/// faces per step).
heat: Vec<f64>,
}
impl ThermalState {
/// The scalar relaxation rate 1 / τ_T.
pub fn omega(&self) -> f64 {
self.omega
}
/// The initial temperature (and the reference of the buoyancy).
pub fn initial(&self) -> f64 {
self.initial
}
/// `−β g` in lattice units; zero without buoyancy.
pub fn buoyancy(&self) -> [f64; 3] {
self.buoyancy
}
/// Each force group's thermal condition (fluxes in lattice units).
pub fn groups(&self) -> &[ThermalCondition] {
&self.groups
}
/// Per open cell, the temperature it holds, or `None` to take its neighbour's.
pub fn open(&self) -> &[Option<f64>] {
&self.open
}
}
/// The flow state.
#[derive(Debug, Clone)]
pub struct Flow {
@@ -118,6 +195,13 @@ pub struct Flow {
steps_done: u64,
/// Obstacles that cover no cell centre (smaller than the spacing, or outside the box).
empty_obstacles: Vec<String>,
/// The temperature lattice, with thermal settings.
thermal: Option<ThermalState>,
/// With buoyancy: every cell's body acceleration (the constant one plus the buoyant one),
/// refreshed from the temperature before each step.
cell_force: Vec<[f64; 3]>,
/// With heat: every cell's velocity from the last collision, for the scalar equilibrium.
cell_u: Vec<[f64; 3]>,
}
const NONE: u32 = u32::MAX;
@@ -326,6 +410,57 @@ impl Flow {
f[q * stride + cell] = if fluid[cell] { equilibrium(q, 1.0, setup.initial_velocity) } else { 0.0 };
}
}
// Heat: the scalar lattice at the initial temperature, each group's condition (the
// box faces, then every obstacle face), each open cell's temperature.
let (thermal, cell_force, cell_u) = match &setup.thermal {
None => (None, Vec::new(), Vec::new()),
Some(ts) => {
let mut group_conditions: Vec<ThermalCondition> = ts.faces.to_vec();
for faces in &setup.obstacles {
group_conditions.extend(faces.thermal.iter().copied());
}
group_conditions.resize(groups.len(), ThermalCondition::Adiabatic {});
let open_t: Vec<Option<f64>> = open
.iter()
.map(|oc| {
let face = BoxFace::of(oc.axis, oc.inward < 0.0);
match (oc.target, ts.faces.get(face.index()).copied().unwrap_or_default()) {
(_, ThermalCondition::Temperature { value }) => Some(value),
(Target::Velocity(_), _) => Some(ts.initial_temperature),
(Target::Density(_), _) => None,
}
})
.collect();
let t_n = stride.checked_mul(QT).ok_or_else(|| Error::Limit("the grid is too large".into()))?;
let mut tg = filled(t_n, 0.0, "temperature populations")?;
let tg2 = filled(t_n, 0.0, "temperature populations")?;
for cell in 0..n {
if fluid[cell] {
for q in 0..QT {
tg[q * stride + cell] = equilibrium_t(q, ts.initial_temperature, setup.initial_velocity);
}
}
}
let buoyant = ts.buoyancy != [0.0; 3];
let state = ThermalState {
g: tg,
g2: tg2,
temperature: filled(n, ts.initial_temperature, "temperature")?,
omega: 1.0 / ts.tau,
initial: ts.initial_temperature,
buoyancy: ts.buoyancy,
groups: group_conditions,
open: open_t,
heat: vec![0.0; groups.len()],
};
(
Some(state),
if buoyant { filled(n, setup.acceleration, "cell forces")? } else { Vec::new() },
filled(n, [0.0; 3], "cell velocities")?,
)
}
};
Ok(Flow {
grid,
n,
@@ -344,9 +479,68 @@ impl Flow {
fluid_cells,
steps_done: 0,
empty_obstacles,
thermal,
cell_force,
cell_u,
})
}
/// The body acceleration of a cell: the constant one, plus the buoyant one with heat.
#[inline(always)]
fn force_at(&self, cell: usize) -> [f64; 3] {
self.cell_force.get(cell).copied().unwrap_or(self.acceleration)
}
/// The temperature of a cell (model units), or `None` without heat.
pub fn temperature(&self, cell: usize) -> Option<f64> {
self.thermal.as_ref().and_then(|t| t.temperature.get(cell).copied())
}
/// Whether the flow carries heat.
pub fn has_heat(&self) -> bool {
self.thermal.is_some()
}
/// The temperature lattice and its settings, with heat.
pub fn thermal(&self) -> Option<&ThermalState> {
self.thermal.as_ref()
}
/// Scalar population `q` (0..7) of a cell; zero without heat.
pub fn thermal_population(&self, q: usize, cell: usize) -> f64 {
self.thermal.as_ref().and_then(|t| t.g.get(q * self.stride + cell).copied()).unwrap_or(0.0)
}
/// The heat each group received in the last step (lattice units), empty without heat.
pub fn heat(&self) -> &[f64] {
self.thermal.as_ref().map_or(&[], |t| &t.heat)
}
/// Set every fluid cell's temperature (and its populations to the equilibrium at its
/// velocity). Nothing without heat.
pub fn set_temperature(&mut self, temperature: impl Fn(usize) -> f64) {
let n = self.n;
let s = self.stride;
let velocities: Vec<[f64; 3]> = (0..n).map(|c| self.moments(c).map_or([0.0; 3], |m| m.1)).collect();
let fluid = &self.fluid;
let Some(t) = &mut self.thermal else { return };
for cell in 0..n {
if !fluid.get(cell).copied().unwrap_or(false) {
continue;
}
let value = temperature(cell);
if let Some(slot) = t.temperature.get_mut(cell) {
*slot = value;
}
let u = velocities.get(cell).copied().unwrap_or([0.0; 3]);
for q in 0..QT {
if let Some(g) = t.g.get_mut(q * s + cell) {
*g = equilibrium_t(q, value, u);
}
}
}
}
pub fn grid(&self) -> &Grid {
&self.grid
}
@@ -377,6 +571,36 @@ impl Flow {
self.steps_done
}
/// The links that do not stream from the neighbour at `−c_q`, in cell order.
pub fn links(&self) -> &[Link] {
&self.links
}
/// The inlet and outlet cells.
pub fn open_cells(&self) -> &[OpenCell] {
&self.open
}
/// Per direction, the index offset of the neighbour a population streams from.
pub fn offsets(&self) -> [isize; Q] {
self.offset
}
/// `1/τ⁺` and `1/τ⁻`.
pub fn omegas(&self) -> (f64, f64) {
(self.omega_plus, self.omega_minus)
}
/// The body acceleration in lattice units.
pub fn acceleration(&self) -> [f64; 3] {
self.acceleration
}
/// Population `q` of `cell` (zero in a solid cell).
pub fn population(&self, q: usize, cell: usize) -> f64 {
self.f.get(q * self.stride + cell).copied().unwrap_or(0.0)
}
/// Set every fluid cell to the equilibrium of `state(cell) = (density, velocity)` in lattice
/// units (velocity as defined with the half force).
pub fn set_equilibrium(&mut self, state: impl Fn(usize) -> (f64, [f64; 3])) {
@@ -387,7 +611,8 @@ impl Flow {
continue;
}
let (rho, u) = state(cell);
let bare = [0, 1, 2].map(|a| u[a] - 0.5 * self.acceleration[a]);
let force = self.force_at(cell);
let bare = [0, 1, 2].map(|a| u[a] - 0.5 * force[a]);
for q in 0..Q {
self.f[q * s + cell] = equilibrium(q, rho, bare);
}
@@ -409,7 +634,8 @@ impl Flow {
j[1] += fq * cq[1];
j[2] += fq * cq[2];
}
let u = [0, 1, 2].map(|a| (j[a] + 0.5 * rho * self.acceleration[a]) / rho);
let force = self.force_at(cell);
let u = [0, 1, 2].map(|a| (j[a] + 0.5 * rho * force[a]) / rho);
Some((rho, u))
}
@@ -429,6 +655,7 @@ impl Flow {
/// One time step. Fails with [`Error::Unstable`] when a density stops being a positive
/// number or a lattice speed exceeds [`MAX_LATTICE_SPEED`].
pub fn step(&mut self) -> Result<()> {
self.refresh_buoyancy();
let max_u2 = self.collide();
if !(max_u2 <= MAX_LATTICE_SPEED * MAX_LATTICE_SPEED) {
return Err(Error::Unstable(format!(
@@ -440,10 +667,177 @@ impl Flow {
self.stream();
self.open_boundaries();
std::mem::swap(&mut self.f, &mut self.g);
if self.thermal.is_some() {
self.collide_thermal();
self.stream_thermal();
self.open_boundaries_thermal();
if let Some(t) = &mut self.thermal {
std::mem::swap(&mut t.g, &mut t.g2);
}
if !self.update_temperature() {
return Err(Error::Unstable(format!(
"the heat went unstable at time step {}: a temperature stopped being a number; lower the time step, refine the spacing or check the heat fluxes",
self.steps_done + 1
)));
}
}
self.steps_done += 1;
Ok(())
}
/// With buoyancy: every cell's acceleration from its temperature.
fn refresh_buoyancy(&mut self) {
let Some(t) = &self.thermal else { return };
if self.cell_force.is_empty() {
return;
}
let (a, b, t0) = (self.acceleration, t.buoyancy, t.initial);
for (force, temp) in self.cell_force.iter_mut().zip(&t.temperature) {
let dt = temp - t0;
*force = [a[0] + b[0] * dt, a[1] + b[1] * dt, a[2] + b[2] * dt];
}
}
/// BGK collision of the scalar lattice, in place, with the velocity of the momentum
/// collision.
fn collide_thermal(&mut self) {
let n = self.n;
let s = self.stride;
let fluid = &self.fluid;
let cell_u = &self.cell_u;
let Some(t) = &mut self.thermal else { return };
let omega = t.omega;
let mut blocks = cell_blocks(&mut t.g, s, n);
parallel::for_each_mut(&mut blocks, |b, g| {
let len = g.first().map_or(0, |d| d.len());
for i in 0..len {
let cell = b * CELLS + i;
if !fluid.get(cell).copied().unwrap_or(false) {
continue;
}
let mut temp = 0.0;
for q in 0..QT {
temp += g.get(q).and_then(|d| d.get(i)).copied().unwrap_or(0.0);
}
let u = cell_u.get(cell).copied().unwrap_or([0.0; 3]);
for q in 0..QT {
if let Some(x) = g.get_mut(q).and_then(|d| d.get_mut(i)) {
*x -= omega * (*x - equilibrium_t(q, temp, u));
}
}
}
});
}
/// Pull streaming of the scalar lattice into `g2` with the wall rules of the links (the
/// first seven directions), then the heat received per group.
fn stream_thermal(&mut self) {
let n = self.n;
let s = self.stride;
let (links, offset) = (&self.links, &self.offset);
let Some(t) = &mut self.thermal else { return };
let (g, groups) = (&t.g, &t.groups);
let mut blocks = cell_blocks(&mut t.g2, s, n);
parallel::for_each_mut(&mut blocks, |b, out| {
let start = b * CELLS;
for (q, dst) in out.iter_mut().enumerate().take(QT) {
let off = offset.get(q).copied().unwrap_or(0);
let src = g.get(q * s..q * s + n).unwrap_or_default();
for (i, x) in dst.iter_mut().enumerate() {
if let Some(from) = (start + i).checked_add_signed(-off)
&& let Some(v) = src.get(from)
{
*x = *v;
}
}
}
let first = links.partition_point(|l| (l.cell as usize) < start);
for link in links.get(first..).unwrap_or_default().iter().take_while(|l| (l.cell as usize) < start + CELLS) {
let (cell, q) = (link.cell as usize, link.q as usize);
if q >= QT {
continue;
}
let v = thermal_rule(g, s, groups, cell, q, link.rule);
if let (Some(v), Some(x)) = (v, out.get_mut(q).and_then(|d| d.get_mut(cell - start))) {
*x = v;
}
}
});
t.heat.fill(0.0);
for link in links {
let (cell, q) = (link.cell as usize, link.q as usize);
if q >= QT {
continue;
}
if let (Rule::Bounce { group, .. } | Rule::Reflect { group, .. }, Some(back)) =
(link.rule, thermal_rule(g, s, groups, cell, q, link.rule))
{
let out = g.get(opposite(q) * s + cell).copied().unwrap_or(0.0);
if let Some(h) = t.heat.get_mut(group as usize) {
// what left the fluid toward the wall minus what came back
*h += out - back;
}
}
}
}
/// Inlets hold their temperature; outlets take their neighbour's populations.
fn open_boundaries_thermal(&mut self) {
let s = self.stride;
let (open, offset, cell_u) = (&self.open, &self.offset, &self.cell_u);
let Some(t) = &mut self.thermal else { return };
for (oc, held) in open.iter().zip(&t.open) {
let cell = oc.cell as usize;
match held {
Some(value) => {
let u = cell_u.get(cell).copied().unwrap_or([0.0; 3]);
for q in 0..QT {
if let Some(x) = t.g2.get_mut(q * s + cell) {
*x = equilibrium_t(q, *value, u);
}
}
}
None => {
// the neighbour one cell inward along the face's axis
let axis_dir = 1 + 2 * oc.axis + usize::from(oc.inward < 0.0);
let off = offset.get(axis_dir).copied().unwrap_or(0);
if let Some(from) = cell.checked_add_signed(off) {
for q in 0..QT {
let v = t.g2.get(q * s + from).copied();
if let (Some(v), Some(x)) = (v, t.g2.get_mut(q * s + cell)) {
*x = v;
}
}
}
}
}
}
}
/// Every fluid cell's temperature from its populations.
/// The temperature of every fluid cell from its populations; false when one is not finite.
fn update_temperature(&mut self) -> bool {
let n = self.n;
let s = self.stride;
let fluid = &self.fluid;
let Some(t) = &mut self.thermal else { return true };
let mut finite = true;
for cell in 0..n {
if !fluid.get(cell).copied().unwrap_or(false) {
continue;
}
let mut temp = 0.0;
for q in 0..QT {
temp += t.g.get(q * s + cell).copied().unwrap_or(0.0);
}
finite &= temp.is_finite();
if let Some(slot) = t.temperature.get_mut(cell) {
*slot = temp;
}
}
finite
}
/// Two-relaxation-time collision with Guo forcing, in place, by blocks of [`CELLS`] cells
/// in parallel. Returns the largest squared lattice speed, or NaN when a density is not
/// positive.
@@ -451,19 +845,27 @@ impl Flow {
let n = self.n;
let (wp, wm) = (self.omega_plus, self.omega_minus);
let (sp, sm) = (1.0 - 0.5 * wp, 1.0 - 0.5 * wm);
let a = self.acceleration;
let forced = a != [0.0; 3];
let constant = self.acceleration;
let cell_force = &self.cell_force;
let forced = constant != [0.0; 3] || !cell_force.is_empty();
let fluid = &self.fluid;
let mut blocks = cell_blocks(&mut self.f, self.stride, n);
let mut maxima = vec![0.0f64; blocks.len()];
let mut work: Vec<(&mut Vec<&mut [f64]>, &mut f64)> = blocks.iter_mut().zip(maxima.iter_mut()).collect();
parallel::for_each_mut(&mut work, |b, (f, max_u2)| {
// with heat, each block's velocities go to its slice of cell_u (empty otherwise)
let mut u_blocks: Vec<&mut [[f64; 3]]> = self.cell_u.chunks_mut(CELLS).collect();
let mut work: Vec<(&mut Vec<&mut [f64]>, &mut f64, Option<&mut [[f64; 3]]>)> = Vec::with_capacity(blocks.len());
let mut u_iter = u_blocks.drain(..);
for (f, m) in blocks.iter_mut().zip(maxima.iter_mut()) {
work.push((f, m, u_iter.next()));
}
parallel::for_each_mut(&mut work, |b, (f, max_u2, u_out)| {
let mut fq = [0.0; Q];
let len = f.first().map_or(0, |d| d.len());
for i in 0..len {
if !fluid.get(b * CELLS + i).copied().unwrap_or(false) {
continue;
}
let a = cell_force.get(b * CELLS + i).copied().unwrap_or(constant);
let (mut rho, mut j) = (0.0, [0.0; 3]);
for q in 0..Q {
let v = f.get(q).and_then(|d| d.get(i)).copied().unwrap_or(0.0);
@@ -482,6 +884,9 @@ impl Flow {
let u = [(j[0] + 0.5 * force[0]) / rho, (j[1] + 0.5 * force[1]) / rho, (j[2] + 0.5 * force[2]) / rho];
let uu = u[0] * u[0] + u[1] * u[1] + u[2] * u[2];
**max_u2 = max_u2.max(uu);
if let Some(slot) = u_out.as_mut().and_then(|u| u.get_mut(i)) {
*slot = u;
}
let uf = u[0] * force[0] + u[1] * force[1] + u[2] * force[2];
let mut put = |q: usize, v: f64| {
if let Some(x) = f.get_mut(q).and_then(|d| d.get_mut(i)) {
@@ -592,9 +997,9 @@ impl Flow {
/// Zou–He velocity and pressure boundaries on the streamed populations.
fn open_boundaries(&mut self) {
let s = self.stride;
let a_half = self.acceleration.map(|v| 0.5 * v);
for oc in &self.open {
let cell = oc.cell as usize;
let a_half = self.force_at(cell).map(|v| 0.5 * v);
let axis = oc.axis;
let sign = oc.inward;
let ci = |q: usize| f64::from(C[q][axis]);
@@ -642,3 +1047,78 @@ impl Flow {
}
}
}
/// What a scalar population of a cell next to a wall or open face becomes (`None`: streamed as
/// usual): a periodic image pulls, an adiabatic wall (or a slip face) bounces back, a held
/// temperature anti-bounces back, a flux adds its heat, an open face is set afterwards.
fn thermal_rule(g: &[f64], s: usize, groups: &[ThermalCondition], cell: usize, q: usize, rule: Rule) -> Option<f64> {
let back = g.get(opposite(q) * s + cell).copied()?;
match rule {
Rule::Pull { from } => g.get(q * s + from as usize).copied(),
Rule::Bounce { group, .. } | Rule::Reflect { group, .. } => Some(match groups.get(group as usize).copied().unwrap_or_default() {
ThermalCondition::Adiabatic {} => back,
ThermalCondition::Temperature { value } => -back + 2.0 * WT.get(q).copied().unwrap_or(0.0) * value,
ThermalCondition::Flux { value } => back + value,
}),
Rule::Open => Some(back),
}
}
impl crate::backend::Backend for Flow {
fn name(&self) -> String {
"cpu".into()
}
fn advance(&mut self, steps: u64) -> Result<()> {
for _ in 0..steps {
self.step()?;
}
Ok(())
}
fn steps_done(&self) -> u64 {
self.steps_done
}
fn fields(&mut self) -> Result<crate::backend::Fields> {
let n = self.n;
let mut density = filled(n, 0.0, "density field")?;
let mut velocity = filled(n, [0.0; 3], "velocity field")?;
for cell in 0..n {
if let (Some((rho, u)), Some(d), Some(v)) = (self.moments(cell), density.get_mut(cell), velocity.get_mut(cell)) {
*d = rho;
*v = u;
}
}
let temperature = self.thermal.as_ref().map(|t| t.temperature.clone()).unwrap_or_default();
Ok(crate::backend::Fields { density, velocity, temperature })
}
fn heat(&mut self) -> Result<Vec<f64>> {
Ok(Flow::heat(self).to_vec())
}
fn mass(&mut self) -> Result<f64> {
Ok(Flow::mass(self))
}
fn forces(&mut self) -> Result<Vec<[f64; 3]>> {
Ok(self.forces.clone())
}
fn groups(&self) -> &[Group] {
&self.groups
}
fn fluid_cells(&self) -> usize {
self.fluid_cells
}
fn is_fluid(&self, cell: usize) -> bool {
Flow::is_fluid(self, cell)
}
fn empty_obstacles(&self) -> &[String] {
&self.empty_obstacles
}
}
+5 -2
View File
@@ -48,15 +48,18 @@
// Population arrays are indexed by cell and direction counts fixed when they are allocated.
#![allow(clippy::needless_range_loop)]
pub mod backend;
pub mod flow;
pub mod lattice;
pub mod setup;
pub mod solve;
pub mod voxel;
pub use flow::{Flow, Group};
pub use backend::{Backend, Choice, Fields, register_backend};
pub use flow::{Flow, Group, Link, OpenCell, Rule, Target, ThermalState};
pub use setup::ThermalSetup;
pub use setup::{Obstacle, Setup, fluid_step};
pub use solve::{FluidResult, FluidStats, ForceHistory, Frame, run, solve_fluid};
pub use solve::{FluidResult, FluidStats, ForceHistory, Frame, HeatHistory, run, solve_fluid, solve_fluid_on};
pub use voxel::Grid;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
+118 -3
View File
@@ -26,7 +26,7 @@
//! density stops the run with [`crate::Error::Unstable`].
use fieldcraft_mesh::surface::BoundarySurface;
use fieldcraft_model::{BoxFace, FaceCondition, FluidDomain, Model, Procedure, SurfaceCondition};
use fieldcraft_model::{BoxFace, Directional, FaceCondition, FluidDomain, Model, Procedure, SurfaceCondition, ThermalCondition};
use crate::voxel::Grid;
use crate::{Error, Limits, Result};
@@ -66,6 +66,25 @@ pub struct ObstacleFaces {
pub names: Vec<String>,
/// Every face tag's wall velocity in lattice units (zero for walls at rest).
pub velocities: Vec<[f64; 3]>,
/// Every face tag's thermal condition (fluxes in lattice units); adiabatic without heat.
pub thermal: Vec<ThermalCondition>,
}
/// Heat in the fluid, in lattice units: a D3Q7 scalar lattice for the temperature
/// (`flow::ThermalState`).
#[derive(Debug, Clone, PartialEq)]
pub struct ThermalSetup {
/// The scalar lattice's relaxation time: its diffusivity is `(τ_T − ½) / 4`.
pub tau: f64,
/// The fluid's thermal diffusivity k / (ρ c), model units.
pub diffusivity: f64,
pub initial_temperature: f64,
/// `−β g` in lattice units: the buoyant acceleration per temperature above the initial one.
pub buoyancy: [f64; 3],
/// The box faces' conditions (fluxes in lattice units: temperature per step through a face).
pub faces: [ThermalCondition; 6],
/// Power per lattice heat flow (a temperature per step through one cell face): ρ c Δx³ / Δt.
pub heat_scale: f64,
}
/// A fluid step in lattice units.
@@ -93,6 +112,8 @@ pub struct Setup {
pub initial_velocity: [f64; 3],
/// The domain's obstacles, in its order.
pub obstacles: Vec<ObstacleFaces>,
/// Heat in the fluid, when the domain has thermal settings.
pub thermal: Option<ThermalSetup>,
pub warnings: Vec<String>,
}
@@ -108,6 +129,21 @@ pub fn fluid_step<'m>(model: &'m Model, step: Option<&str>) -> Result<(&'m field
Ok((s, domain))
}
/// A thermal condition in lattice units: a flux (power per area) becomes the temperature it adds
/// to the cell behind the face per step.
fn lattice_thermal(c: &ThermalCondition, flux_scale: f64) -> Result<ThermalCondition> {
Ok(match *c {
ThermalCondition::Flux { value } => {
let lattice = value * flux_scale;
if !lattice.is_finite() {
return Err(Error::Invalid(format!("a heat flux of {value} is too large for this lattice")));
}
ThermalCondition::Flux { value: lattice }
}
other => other,
})
}
impl Setup {
/// Convert a fluid step, checking it against the stability limits and `limits`.
pub fn new(model: &Model, step: Option<&str>, obstacles: &[Obstacle], limits: &Limits) -> Result<Setup> {
@@ -243,9 +279,68 @@ impl Setup {
)));
}
// Heat: the scalar lattice's relaxation time from the diffusivity, the conditions in
// lattice units (a flux is a temperature per step through a cell face).
let mut warnings = Vec::new();
let thermal = match &domain.thermal {
None => None,
Some(t) => {
let props = material.and_then(|m| m.thermal.as_ref());
let (Some(Directional::Isotropic(k)), Some(c)) = (props.and_then(|p| p.conductivity), props.and_then(|p| p.specific_heat)) else {
return Err(Error::Invalid(format!(
"material `{}` needs an isotropic conductivity and a specific heat for heat in the fluid",
domain.material
)));
};
let rho_c = density * c;
let diffusivity = k / rho_c;
if !(diffusivity > 0.0 && diffusivity.is_finite()) {
return Err(Error::Invalid(format!("the thermal diffusivity k / (ρ c) of material `{}` is {diffusivity}", domain.material)));
}
let alpha = diffusivity * dt / (h * h);
let tau = 4.0 * alpha + 0.5;
if !(tau >= MIN_TAU) {
return Err(Error::Invalid(format!(
"the thermal relaxation time τ_T = {tau:.5} is below {MIN_TAU}: the grid cannot resolve diffusivity {diffusivity} at this time step (cell Péclet number {:.1}); refine the spacing or raise the time step",
if alpha > 0.0 { lattice_velocity / alpha } else { f64::INFINITY }
)));
}
if tau > 2.0 {
warnings.push(format!("the thermal relaxation time τ_T = {tau:.3} is large: heat diffuses fast on this grid and time step; a shorter time step is more accurate"));
}
let flux_scale = dt / (rho_c * h);
let mut faces = [ThermalCondition::Adiabatic {}; 6];
for face in BoxFace::ALL {
let c = t.faces.get(face);
if matches!(c, ThermalCondition::Flux { .. })
&& matches!(
domain.faces.get(face),
FaceCondition::Velocity { .. } | FaceCondition::Pressure { .. } | FaceCondition::Periodic {}
)
{
return Err(Error::Invalid(format!(
"box face {}: a heat flux goes on a wall, not on an inlet, outlet or periodic face",
face.name()
)));
}
if let Some(slot) = faces.get_mut(face.index()) {
*slot = lattice_thermal(c, flux_scale)?;
}
}
let beta = t.expansion.unwrap_or(0.0);
Some(ThermalSetup {
tau,
diffusivity,
initial_temperature: t.initial_temperature,
buoyancy: t.gravity.map(|g| -beta * g * dt * dt / h),
faces,
heat_scale: rho_c * h * h * h / dt,
})
}
};
// Obstacles, their face names and their faces' wall velocities.
let mut domain_obstacles = Vec::with_capacity(domain.obstacles.len());
let mut warnings = Vec::new();
for body in &domain.obstacles {
let (index, o) = obstacles
.iter()
@@ -255,6 +350,25 @@ impl Setup {
let tags = o.surface.tri_face().iter().copied().max().map_or(1, |m| m as usize + 1).max(o.faces.len());
let names: Vec<String> = (0..tags).map(|t| o.faces.get(t).cloned().unwrap_or_else(|| format!("face{t}"))).collect();
let mut velocities = vec![[0.0; 3]; tags];
let mut thermal_faces = vec![ThermalCondition::Adiabatic {}; tags];
if let (Some(t), Some(ts)) = (&domain.thermal, &thermal) {
// power per area → temperature per step through a cell face, as for the box faces
let flux_scale = h * h / ts.heat_scale;
for sb in t.surfaces.iter().filter(|sb| &sb.face.body == body) {
let mut hit = false;
for (tag, name) in names.iter().enumerate() {
if sb.face.selects(&fieldcraft_model::GeometryRef::face(body.as_str(), name.as_str())?) {
hit = true;
if let Some(slot) = thermal_faces.get_mut(tag) {
*slot = lattice_thermal(&sb.condition, flux_scale)?;
}
}
}
if !hit {
return Err(Error::NotFound(format!("the {} is not a face of the obstacle's surface", sb.face)));
}
}
}
for sb in domain.surfaces.iter().filter(|sb| &sb.face.body == body) {
let mut hit = false;
for (tag, name) in names.iter().enumerate() {
@@ -269,7 +383,7 @@ impl Setup {
return Err(Error::NotFound(format!("the {} is not a face of the obstacle's surface", sb.face)));
}
}
domain_obstacles.push(ObstacleFaces { index, names, velocities });
domain_obstacles.push(ObstacleFaces { index, names, velocities, thermal: thermal_faces });
}
if mach > 0.1 + 1e-12 {
warnings.push(format!(
@@ -298,6 +412,7 @@ impl Setup {
acceleration,
initial_velocity: domain.initial_velocity.map(|c| c * dt / h),
obstacles: domain_obstacles,
thermal,
warnings,
domain,
})
+86 -32
View File
@@ -2,6 +2,7 @@
use fieldcraft_model::Model;
use crate::backend::{Backend, Choice, Fields};
use crate::flow::Flow;
use crate::setup::{Obstacle, Setup};
use crate::voxel::Grid;
@@ -22,10 +23,21 @@ pub struct Frame {
pub velocity: Vec<[f32; 3]>,
/// Gauge pressure.
pub pressure: Vec<f32>,
/// Temperature, when the flow carries heat (empty otherwise).
pub temperature: Vec<f32>,
/// The total fluid mass (density times volume).
pub mass: f64,
}
/// The heat the fluid gives one wall over time (power; positive into the wall).
#[derive(Debug, Clone, PartialEq)]
pub struct HeatHistory {
pub name: String,
pub body: Option<String>,
/// One value per [`FluidResult::history_times`].
pub power: Vec<f64>,
}
/// The force the fluid exerts on one wall over time.
#[derive(Debug, Clone, PartialEq)]
pub struct ForceHistory {
@@ -54,6 +66,10 @@ pub struct FluidStats {
pub mach: f64,
/// Fluid cells times steps.
pub cell_updates: u64,
/// What stepped the flow: `cpu`, or `gpu: <adapter>`.
pub backend: String,
/// The scalar lattice's relaxation time, with heat.
pub tau_thermal: Option<f64>,
}
/// The result of a fluid step.
@@ -68,58 +84,72 @@ pub struct FluidResult {
pub history_times: Vec<f64>,
/// Every box wall and every obstacle face.
pub forces: Vec<ForceHistory>,
/// The heat every wall receives, with heat (empty otherwise), the same walls and times.
pub heat: Vec<HeatHistory>,
pub stats: FluidStats,
pub warnings: Vec<String>,
}
impl FluidResult {
/// The cell containing a point and the frame's velocity and pressure there (`None` outside
/// the grid; zeros in a solid cell).
pub fn probe(&self, frame: usize, p: [f64; 3]) -> Option<(usize, [f32; 3], f32)> {
/// The cell containing a point and the frame's velocity, pressure and temperature (`None`
/// without heat) there (`None` outside the grid; zeros in a solid cell).
pub fn probe(&self, frame: usize, p: [f64; 3]) -> Option<(usize, [f32; 3], f32, Option<f32>)> {
let cell = self.grid.cell_at(p)?;
let f = self.frames.get(frame)?;
Some((cell, *f.velocity.get(cell)?, *f.pressure.get(cell)?))
Some((cell, *f.velocity.get(cell)?, *f.pressure.get(cell)?, f.temperature.get(cell).copied()))
}
}
/// Solve the fluid step named `step` (or the model's only fluid step) with the obstacles' surfaces
/// prepared by the caller.
/// prepared by the caller, on the CPU.
pub fn solve_fluid(model: &Model, step: Option<&str>, obstacles: &[Obstacle], limits: &Limits) -> Result<FluidResult> {
let setup = Setup::new(model, step, obstacles, limits)?;
let mut flow = Flow::new(&setup, obstacles)?;
run(&setup, &mut flow, limits)
solve_fluid_on(model, step, obstacles, limits, Choice::Cpu)
}
fn frame(setup: &Setup, flow: &Flow, step: u64) -> Result<Frame> {
/// [`solve_fluid`] on the backend `choice` names (the registered device backend, or the CPU).
pub fn solve_fluid_on(model: &Model, step: Option<&str>, obstacles: &[Obstacle], limits: &Limits, choice: Choice) -> Result<FluidResult> {
let setup = Setup::new(model, step, obstacles, limits)?;
let flow = Flow::new(&setup, obstacles)?;
let mut backend = crate::backend::backend(choice, flow, &setup)?;
run(&setup, backend.as_mut(), limits)
}
fn frame(setup: &Setup, flow: &mut dyn Backend, step: u64) -> Result<Frame> {
let n = setup.grid.len();
let mut velocity = Vec::new();
let mut pressure = Vec::new();
velocity.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for an output frame".into()))?;
pressure.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for an output frame".into()))?;
let vs = setup.velocity_scale();
let Fields { density, velocity: u, temperature: t } = flow.fields()?;
let mut temperature = Vec::new();
if !t.is_empty() {
temperature.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for an output frame".into()))?;
}
for cell in 0..n {
match flow.moments(cell) {
Some((rho, u)) => {
velocity.push(u.map(|c| (c * vs) as f32));
pressure.push(setup.gauge_pressure(rho) as f32);
}
None => {
velocity.push([0.0; 3]);
pressure.push(0.0);
}
if flow.is_fluid(cell) {
velocity.push(u.get(cell).copied().unwrap_or([0.0; 3]).map(|c| (c * vs) as f32));
pressure.push(setup.gauge_pressure(density.get(cell).copied().unwrap_or(1.0)) as f32);
} else {
velocity.push([0.0; 3]);
pressure.push(0.0);
}
if !t.is_empty() {
temperature.push(t.get(cell).copied().unwrap_or(0.0) as f32);
}
}
let mass = flow.mass() * setup.density * setup.grid.spacing.powi(3);
Ok(Frame { time: step as f64 * setup.time_step, step, velocity, pressure, mass })
let mass = flow.mass()? * setup.density * setup.grid.spacing.powi(3);
Ok(Frame { time: step as f64 * setup.time_step, step, velocity, pressure, temperature, mass })
}
/// Run a prepared flow (fresh from [`Flow::new`], possibly with its own initial state) through
/// the setup's steps, recording frames and force histories.
pub fn run(setup: &Setup, flow: &mut Flow, limits: &Limits) -> Result<FluidResult> {
/// Run a prepared flow (fresh from [`Flow::new`], possibly with its own initial state, on any
/// backend) through the setup's steps, recording frames and force histories. The steps go to
/// the backend in chunks, up to the next frame, history sample or progress check.
pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<FluidResult> {
let n = setup.grid.len();
let total = setup.steps;
let frames = u64::from(setup.frames).min(total).max(1);
let frame_bytes = (frames + 1) as f64 * n as f64 * 16.0;
let frame_bytes = (frames + 1) as f64 * n as f64 * if setup.thermal.is_some() { 20.0 } else { 16.0 };
if !(frame_bytes <= limits.max_frame_bytes as f64) {
return Err(Error::Limit(format!(
"{} frames of {n} cells need {:.0} MB, above the cap of {:.0} MB; write fewer frames or raise `max_frame_bytes`",
@@ -130,26 +160,42 @@ pub fn run(setup: &Setup, flow: &mut Flow, limits: &Limits) -> Result<FluidResul
}
let every = total.div_ceil(MAX_HISTORY).max(1);
let fs = setup.force_scale();
let hs = setup.thermal.as_ref().map_or(0.0, |t| t.heat_scale);
let mut out_frames = vec![frame(setup, flow, 0)?];
let mut history_times = Vec::new();
let mut forces: Vec<Vec<[f64; 3]>> = vec![Vec::new(); flow.groups().len()];
let mut heats: Vec<Vec<f64>> = vec![Vec::new(); if setup.thermal.is_some() { flow.groups().len() } else { 0 }];
let mut next_frame = 1u64;
// Frame j (1..=frames) at step round(j · total / frames).
let frame_step = |j: u64| (j * total + frames / 2) / frames;
fieldcraft_model::progress::stage("Fluid steps", Some(total as f64), "");
for k in 1..=total {
let mut k = 0u64;
while k < total {
fieldcraft_model::progress::check()?;
flow.step()?;
if k % 64 == 0 {
// Up to the next event: a history sample, a frame, a progress count.
let mut next = total.min(k + 64 - k % 64);
next = next.min((k / every + 1) * every);
if next_frame <= frames {
next = next.min(frame_step(next_frame).max(k + 1));
}
flow.advance(next - k)?;
k = next;
if k.is_multiple_of(64) || k == total {
fieldcraft_model::progress::count(k as f64);
}
if k % every == 0 || k == total {
if k.is_multiple_of(every) || k == total {
history_times.push(k as f64 * setup.time_step);
for (h, f) in forces.iter_mut().zip(flow.forces()) {
for (h, f) in forces.iter_mut().zip(flow.forces()?) {
h.push(f.map(|c| c * fs));
}
if !heats.is_empty() {
for (h, q) in heats.iter_mut().zip(flow.heat()?) {
h.push(q * hs);
}
}
}
// Frame j (1..=frames) at step round(j · total / frames).
while next_frame <= frames && k == (next_frame * total + frames / 2) / frames {
while next_frame <= frames && k == frame_step(next_frame) {
out_frames.push(frame(setup, flow, k)?);
next_frame += 1;
}
@@ -158,7 +204,8 @@ pub fn run(setup: &Setup, flow: &mut Flow, limits: &Limits) -> Result<FluidResul
let mut warnings = setup.warnings.clone();
// The weakly compressible scheme turns pressure differences into density differences of
// Δp / (ρ c_s²) in lattice units; large ones show the time step is too long for the flow.
let spread = (0..n).filter_map(|c| flow.moments(c)).map(|(rho, _)| (rho - 1.0).abs()).fold(0.0, f64::max);
let end = flow.fields()?;
let spread = (0..n).filter(|c| flow.is_fluid(*c)).filter_map(|c| end.density.get(c)).map(|rho| (rho - 1.0).abs()).fold(0.0, f64::max);
if spread > COMPRESSIBILITY_WARNING {
warnings.push(format!(
"the density varies by {:.1}% at the end: the weakly compressible error is visible; a shorter time step reduces it (quadratically)",
@@ -168,6 +215,10 @@ pub fn run(setup: &Setup, flow: &mut Flow, limits: &Limits) -> Result<FluidResul
for body in flow.empty_obstacles() {
warnings.push(format!("obstacle `{body}` covers no cell centre: it is smaller than the spacing or outside the box"));
}
if flow.name() != "cpu" {
warnings
.push(format!("solved on {}: single precision, deterministic on this device, within the backend's tolerance of the CPU", flow.name()));
}
let solid: Vec<bool> = (0..n).map(|c| !flow.is_fluid(c)).collect();
Ok(FluidResult {
step: setup.step.clone(),
@@ -176,6 +227,7 @@ pub fn run(setup: &Setup, flow: &mut Flow, limits: &Limits) -> Result<FluidResul
frames: out_frames,
history_times,
forces: flow.groups().iter().zip(forces).map(|(g, force)| ForceHistory { name: g.name.clone(), body: g.body.clone(), force }).collect(),
heat: flow.groups().iter().zip(heats).map(|(g, power)| HeatHistory { name: g.name.clone(), body: g.body.clone(), power }).collect(),
stats: FluidStats {
cells: n,
fluid_cells: flow.fluid_cells(),
@@ -189,6 +241,8 @@ pub fn run(setup: &Setup, flow: &mut Flow, limits: &Limits) -> Result<FluidResul
lattice_velocity: setup.lattice_velocity,
mach: setup.mach,
cell_updates: (flow.fluid_cells() as u64).saturating_mul(total),
backend: flow.name(),
tau_thermal: setup.thermal.as_ref().map(|t| t.tau),
},
warnings,
})
+1
View File
@@ -21,6 +21,7 @@ pub fn domain(min: [f64; 3], max: [f64; 3], spacing: f64) -> FluidDomain {
initial_velocity: [0.0; 3],
acceleration: [0.0; 3],
tolerance: None,
thermal: None,
}
}
+297
View File
@@ -0,0 +1,297 @@
//! Heat in the fluid: the temperature lattice against closed-form solutions and a published
//! benchmark (de Vahl Davis, heavy).
mod common;
use common::*;
use fieldcraft_lbm::backend::Backend;
use fieldcraft_lbm::{Limits, run};
use fieldcraft_model::{BoxFace, Directional, FaceCondition, Fluid, FluidThermal, Material, Model, Procedure, Step, Thermal, ThermalCondition};
const PERIODIC: FaceCondition = FaceCondition::Periodic {};
/// A fluid with heat: density ρ, kinematic viscosity ν, thermal diffusivity α (conductivity
/// ρ α, unit specific heat), so k / (ρ c) = α.
fn hot_model(density: f64, nu: f64, alpha: f64, domain: fieldcraft_model::FluidDomain, procedure: Procedure) -> Model {
let mut m = Model::default();
m.add_material(
"Fluid",
Material {
density: Some(density),
fluid: Some(Fluid::Newtonian { viscosity: nu * density }),
thermal: Some(Thermal { conductivity: Some(Directional::Isotropic(density * alpha)), specific_heat: Some(1.0), ..Default::default() }),
..Material::default()
},
)
.unwrap();
m.add_fluid_domain("Flow", domain).unwrap();
m.add_step(Step::new("Run", procedure), None).unwrap();
m
}
/// Plates at y = 0 and y = 1, periodic in x and z, no flow: held at 0 and 1, the steady
/// temperature is linear, and anti-bounce-back puts the walls exactly halfway between cells.
#[test]
fn conduction_between_plates_is_linear() {
for n in [8usize, 16] {
let (h, alpha) = (1.0, 0.05);
let dx = h / n as f64;
let mut d = domain([0.0; 3], [dx, h, dx], dx);
d.faces.x_min = PERIODIC;
d.faces.x_max = PERIODIC;
d.faces.z_min = PERIODIC;
d.faces.z_max = PERIODIC;
let mut t = FluidThermal::default();
t.faces.y_min = ThermalCondition::Temperature { value: 0.0 };
t.faces.y_max = ThermalCondition::Temperature { value: 1.0 };
d.thermal = Some(t);
// τ_T = 1: Δt = Δx² / (8 α)
let dt = dx * dx / (8.0 * alpha);
let m = hot_model(1.0, 0.1, alpha, d, step(5.0 * h * h / alpha, dt, None));
let (setup, mut flow) = prepare(&m, &[]);
assert!((setup.thermal.as_ref().unwrap().tau - 1.0).abs() < 1e-12);
run_all(&setup, &mut flow);
let mut err: f64 = 0.0;
for j in 0..n {
let y = (j as f64 + 0.5) * dx;
let t = flow.temperature(j).unwrap();
err = err.max((t - y / h).abs());
}
println!("conduction between plates, {n} cells: max error {err:.2e}");
assert!(err < 1e-9, "{n}: {err}");
}
}
/// A flux q through the cold plate and a held temperature on the other: the steady drop over
/// the gap is q H / k, and the heat the hot plate receives is the flux times its area.
#[test]
fn a_heat_flux_sets_the_gradient_and_the_wall_heat_balances() {
let (n, h, alpha, rho) = (16usize, 1.0, 0.05, 2.0);
let dx = h / n as f64;
let mut d = domain([0.0; 3], [dx, h, dx], dx);
d.faces.x_min = PERIODIC;
d.faces.x_max = PERIODIC;
d.faces.z_min = PERIODIC;
d.faces.z_max = PERIODIC;
let q = 0.3;
let mut t = FluidThermal::default();
t.faces.y_min = ThermalCondition::Flux { value: q };
t.faces.y_max = ThermalCondition::Temperature { value: 1.0 };
d.thermal = Some(t);
let dt = dx * dx / (8.0 * alpha);
let m = hot_model(rho, 0.1, alpha, d, step(8.0 * h * h / alpha, dt, None));
let setup = fieldcraft_lbm::Setup::new(&m, None, &[], &Limits::default()).unwrap();
let mut flow = fieldcraft_lbm::Flow::new(&setup, &[]).unwrap();
let r = run(&setup, &mut flow, &Limits::default()).unwrap();
let k = rho * alpha;
// the temperature halfway below the first cell (the wall) against the hot wall at 1
let t0 = f64::from(r.frames.last().unwrap().temperature[0]) + 0.5 * dx * q / k;
let drop = t0 - 1.0;
let expected = q * h / k;
println!("flux plate: drop {drop:.6} vs q H / k = {expected:.6}");
assert!((drop - expected).abs() < 1e-6 * expected, "{drop} vs {expected}");
let hot = r.heat.iter().find(|w| w.name == "y_max").unwrap();
let last = *hot.power.last().unwrap();
let area = dx * dx;
println!("heat into the hot wall: {last:.6e} vs q A = {:.6e}", q * area);
assert!((last - q * area).abs() < 1e-6 * q * area, "{last} vs {}", q * area);
let cold = r.heat.iter().find(|w| w.name == "y_min").unwrap();
assert!((cold.power.last().unwrap() + q * area).abs() < 1e-6 * q * area, "the flux face gives q A");
}
/// A channel with a velocity inlet at 1, slip (adiabatic) walls and a pressure outlet: the
/// plug flow carries the inlet temperature through the whole channel. The flow starts at
/// its steady state (an impulsive start in a frictionless channel rings for a long time).
#[test]
fn the_flow_carries_the_inlet_temperature() {
let (h, l, umax, nu, alpha) = (1.0, 4.0, 0.1, 0.05, 0.01);
let dx = h / 8.0;
let mut d = domain([0.0; 3], [l, h, dx], dx);
d.faces.x_min = FaceCondition::Velocity { velocity: [umax, 0.0, 0.0], profile: fieldcraft_model::Profile::Uniform };
d.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
d.faces.y_min = FaceCondition::Slip {};
d.faces.y_max = FaceCondition::Slip {};
d.faces.z_min = PERIODIC;
d.faces.z_max = PERIODIC;
let mut t = FluidThermal::default();
t.faces.x_min = ThermalCondition::Temperature { value: 1.0 };
d.thermal = Some(t);
let lattice_u = 0.05;
let m = hot_model(1.0, nu, alpha, d, step(4.0 * l / umax, lattice_u * dx / umax, None));
let (setup, mut flow) = prepare(&m, &[]);
flow.set_equilibrium(|_| (1.0, [lattice_u, 0.0, 0.0]));
run_all(&setup, &mut flow);
let grid = *flow.grid();
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for c in 0..grid.len() {
let t = flow.temperature(c).unwrap();
lo = lo.min(t);
hi = hi.max(t);
}
println!("inlet temperature carried through: {lo:.6}..{hi:.6}");
assert!(lo > 1.0 - 1e-3 && hi < 1.0 + 1e-3, "{lo}..{hi}");
let f = flow.fields().unwrap();
assert_eq!(f.temperature.len(), grid.len());
}
/// Natural convection in a square cavity (de Vahl Davis, Int. J. Numer. Meth. Fluids 3 (1983)
/// 249–264): hot left wall at 1, cold right wall at 0, adiabatic top and bottom, Pr = 0.71.
/// The mean Nusselt number on the hot wall at Ra = 10³ is 1.118 (10⁴: 2.243); `u_max` is the
/// fastest flow in the cavity in units of α / H (3.65 and 19.6). Returns the Nusselt number
/// and the steady power into the hot wall and into the cold wall (the mean over the last
/// tenth of the run).
fn cavity(ra: f64, n: usize, u_max: f64) -> (f64, f64, f64) {
let (h, pr) = (1.0, 0.71);
let dx = h / n as f64;
let alpha = 1.0;
let nu = pr * alpha;
// g β ΔT H³ / (ν α) = Ra, with ΔT = 1, H = 1
let g_beta = ra * nu * alpha / (h * h * h);
let mut d = domain([0.0; 3], [h, h, dx], dx);
d.faces.z_min = PERIODIC;
d.faces.z_max = PERIODIC;
let mut t = FluidThermal { initial_temperature: 0.5, gravity: [0.0, -g_beta, 0.0], expansion: Some(1.0), ..FluidThermal::default() };
t.faces.x_min = ThermalCondition::Temperature { value: 1.0 };
t.faces.x_max = ThermalCondition::Temperature { value: 0.0 };
d.thermal = Some(t);
// the fastest flow at lattice speed 0.05, and a lattice diffusivity of at most 1/4 (τ_T ≤ 1.5)
let dt = (0.05 * dx / (u_max * alpha / h)).min(dx * dx / (4.0 * alpha));
let m = hot_model(1.0, nu, alpha, d, step(4.0 * h * h / alpha, dt, None));
let setup = fieldcraft_lbm::Setup::new(&m, None, &[], &Limits::default()).unwrap();
let mut flow = fieldcraft_lbm::Flow::new(&setup, &[]).unwrap();
let r = run(&setup, &mut flow, &Limits::default()).unwrap();
// Nu = (heat into the fluid through the hot wall) H / (k ΔT A): the hot wall's history
// holds the power the wall receives (negative when it heats the fluid).
let steady = |name: &str| {
let w = r.heat.iter().find(|w| w.name == name).unwrap();
let m = w.power.len();
let tail = &w.power[m - m / 10..];
tail.iter().sum::<f64>() / tail.len() as f64
};
let (hot, cold) = (steady("x_min"), steady("x_max"));
let k = alpha;
let area = h * dx;
let nu_mean = -hot * h / (k * 1.0 * area);
println!("cavity Ra = {ra:.0e}, {n} cells: Nu = {nu_mean:.4}, hot wall {hot:.6e}, cold wall {cold:.6e}; {:?}", r.warnings);
(nu_mean, hot, cold)
}
/// At steady state the heat the hot wall gives the fluid is the heat the cold wall takes.
fn heat_balances(hot: f64, cold: f64, tolerance: f64) {
assert!(hot < 0.0 && cold > 0.0, "the hot wall heats the fluid ({hot}) and the cold wall cools it ({cold})");
assert!((hot + cold).abs() < tolerance * cold, "heat in {hot:e} vs heat out {cold:e}");
}
/// The published cavity on a coarse grid, every run: Ra = 10³ on 16 cells is within 2 % of
/// de Vahl Davis (the heavy test refines it), and the heat balances between the walls.
#[test]
fn natural_convection_cavity_is_close_on_a_coarse_grid() {
let (nu3, hot, cold) = cavity(1e3, 16, 3.65);
assert!((nu3 / 1.118 - 1.0).abs() < 0.02, "Ra 1e3: {nu3}");
heat_balances(hot, cold, 1e-3);
}
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn natural_convection_cavity_matches_de_vahl_davis() {
let (nu3, hot, cold) = cavity(1e3, 32, 3.65);
assert!((nu3 / 1.118 - 1.0).abs() < 0.01, "Ra 1e3: {nu3}");
heat_balances(hot, cold, 1e-3);
let (nu4, hot, cold) = cavity(1e4, 48, 19.6);
assert!((nu4 / 2.243 - 1.0).abs() < 0.01, "Ra 1e4: {nu4}");
heat_balances(hot, cold, 1e-3);
}
/// A periodic box with a uniform flow and a blob of heat: the total temperature is conserved
/// to round-off and the blob moves with the flow.
#[test]
fn a_periodic_flow_conserves_and_carries_heat() {
let (l, u0, nu, alpha) = (4.0, 0.1, 0.05, 0.01);
let dx = l / 32.0;
let mut d = domain([0.0; 3], [l, dx, dx], dx);
for f in BoxFace::ALL {
*d.faces.get_mut(f) = PERIODIC;
}
d.initial_velocity = [u0, 0.0, 0.0];
d.thermal = Some(FluidThermal::default());
let m = hot_model(1.0, nu, alpha, d, step(l / u0, 0.05 * dx / u0, None));
let (setup, mut flow) = prepare(&m, &[]);
let grid = *flow.grid();
flow.set_temperature(|c| if grid.coords(c)[0] < 8 { 1.0 } else { 0.0 });
let total = |f: &fieldcraft_lbm::Flow| (0..grid.len()).map(|c| f.temperature(c).unwrap()).sum::<f64>();
let t0 = total(&flow);
run_all(&setup, &mut flow);
let t1 = total(&flow);
let row: Vec<String> = (0..32).map(|i| format!("{:.3}", flow.temperature(grid.index([i, 0, 0])).unwrap())).collect();
println!("periodic blob after one lap: total {t0:.6} -> {t1:.6}; {}", row.join(" "));
assert!((t1 - t0).abs() < 1e-10 * t0, "{t0} vs {t1}");
}
/// A sine of temperature in a periodic box drifting with a uniform flow: it keeps its shape,
/// moves with the flow and decays as e^(−α k² t). At rest the decay converges to the
/// diffusivity at second order. With the flow the D3Q7 lattice's linear equilibrium diffuses
/// less along the flow by (τ_T − ½) u² in lattice units, 4 u² of the diffusivity (Chopard,
/// Falcone and Latt, Eur. Phys. J. Special Topics 171 (2009) 245–249): the measured decay
/// shows that deficit, which bounds the lattice speed a heat problem should run at.
#[test]
fn a_drifting_sine_decays_at_the_diffusivity() {
let (l, nu, alpha, amplitude) = (4.0, 0.05, 0.01, 0.25);
let k = 2.0 * std::f64::consts::PI / l;
let decay_time = 1.0 / (alpha * k * k);
let mut at_rest = Vec::new();
for (n, lattice_u) in [(32usize, 0.0), (64, 0.0), (64, 0.1), (64, 0.15)] {
let dx = l / n as f64;
// At rest τ_T = 1; with the flow at `lattice_u` the time step follows the speed.
let u0 = if lattice_u > 0.0 { 0.1 } else { 0.0 };
let dt = if lattice_u > 0.0 { lattice_u * dx / u0 } else { dx * dx / (8.0 * alpha) };
let mut d = domain([0.0; 3], [l, dx, dx], dx);
for f in BoxFace::ALL {
*d.faces.get_mut(f) = PERIODIC;
}
d.initial_velocity = [u0, 0.0, 0.0];
d.thermal = Some(FluidThermal { initial_temperature: 0.5, ..FluidThermal::default() });
let m = hot_model(1.0, nu, alpha, d, step(decay_time, dt, None));
let (setup, mut flow) = prepare(&m, &[]);
let tau = setup.thermal.as_ref().unwrap().tau;
let grid = *flow.grid();
let x = |c: usize| (grid.coords(c)[0] as f64 + 0.5) * dx;
flow.set_temperature(|c| 0.5 + amplitude * (k * x(c)).sin());
run_all(&setup, &mut flow);
let t = setup.steps as f64 * setup.time_step;
// The sine's amplitude and phase from the Fourier coefficient at k, then the shape.
let (mut a, mut b) = (0.0, 0.0);
for i in 0..n {
let c = grid.index([i, 0, 0]);
let temp = flow.temperature(c).unwrap();
a += temp * (k * x(c)).sin();
b += temp * (k * x(c)).cos();
}
let measured = (a * a + b * b).sqrt() * 2.0 / n as f64;
let drift = -b.atan2(a) / k;
let mut shape: f64 = 0.0;
for i in 0..n {
let c = grid.index([i, 0, 0]);
shape = shape.max((flow.temperature(c).unwrap() - 0.5 - measured * (k * (x(c) - drift)).sin()).abs());
}
let alpha_measured = -(measured / amplitude).ln() / (k * k * t);
let error = alpha_measured / alpha - 1.0;
let lag = (drift - u0 * t).rem_euclid(l);
let lag = lag.min(l - lag);
println!(
"drifting sine, {n} cells, lattice speed {lattice_u}: τ_T = {tau:.4}, α measured {alpha_measured:.5} vs {alpha} ({error:+.2e}), lag {lag:.2e}, shape error {shape:.2e}"
);
// A Fourier mode of a linear scheme on a periodic grid stays one, to round-off.
assert!(shape < 1e-9 * amplitude, "{n} cells at {lattice_u}: the sine lost its shape by {shape}");
assert!(lag < dx / 5.0, "{n} cells at {lattice_u}: drift {drift} vs {}", u0 * t);
if lattice_u > 0.0 {
let deficit = -4.0 * lattice_u * lattice_u;
assert!((error - deficit).abs() < 0.01, "{n} cells at {lattice_u}: α error {error} vs the deficit {deficit}");
} else {
assert!(error.abs() < 1e-3, "{n} cells at rest: α error {error}");
at_rest.push(error.abs());
}
}
let order = orders(&at_rest, 2.0);
println!("decay at rest converges at order {order:?}");
assert!(order.iter().all(|o| *o > 1.8), "{order:?}");
}
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
fieldcraft-model = { workspace = true }
fieldcraft-parallel = { workspace = true }
# Pure-Rust maths (a port of musl's libm) for sin, cos, log, exp, pow, hypot, acos: the same
# bits on every platform, where the system library's are not correctly rounded and differ (the
# plate's hex mesh differed between macOS and Linux, #438). atan2 and cbrt are crate::vec3's.
+13 -37
View File
@@ -11,8 +11,7 @@
//! 2. **Grid** ([`grid`]). Root cubes over the part's box, refined as a 27-tree where a B-rep
//! face curves (its normals turn by more than [`OctreeOptions::curvature_angle`] inside a
//! cell), where the part is thin (fewer than [`OctreeOptions::thickness_cells`] cells across a
//! wall, measured along the inward normal of a boundary triangle to a face that faces it, at
//! the triangle's centre and, on a triangle larger than the cell, where it is nearest the cell)
//! wall, measured along the inward normal of each boundary triangle to a face that faces it)
//! and where a patch is narrow, then strongly balanced and closed under the transition
//! templates. The levels the thinnest wall and narrowest patch need are found before the grid
//! is built, and the root size is chosen with them (see [`octree_hex_mesh`]'s plan): a part
@@ -501,29 +500,17 @@ fn choose_frame(g: &Geometry) -> [[f64; 3]; 3] {
/// ray that starts near a convex edge leaves through the face beyond the edge after a distance
/// that only measures how near the edge it started; the faces of a wall face each other.
fn triangle_thickness(g: &Geometry) -> Vec<f64> {
(0..g.surface.triangles().len() as u32).map(|t| thickness_at(g, t, g.triangle_centre(t))).collect()
}
/// The distance through the part from point `p` of boundary triangle `t` (see
/// [`triangle_thickness`]).
fn thickness_at(g: &Geometry, t: u32, p: P) -> f64 {
let far = 2.0 * norm(sub(g.bounds.hi, g.bounds.lo)) + 1.0;
let opposed = -libm::cos(WALL_OPENING.to_radians());
let n = g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
match g.ray(p, n.map(|x| -x), far, t) {
Some((hit, s)) if g.normals.get(hit as usize).is_some_and(|&m| dot(m, n) <= opposed) => s,
_ => f64::INFINITY,
}
}
fn longest_edge(a: P, b: P, c: P) -> f64 {
norm(sub(b, a)).max(norm(sub(c, b))).max(norm(sub(a, c)))
}
/// `p` on a triangle moved a millionth of the way to its centroid, so a ray from it does not
/// start on an edge it shares with a neighbour.
fn toward_centre(p: P, [a, b, c]: [P; 3]) -> P {
[0, 1, 2].map(|k| p[k] + 1e-6 * ((a[k] + b[k] + c[k]) / 3.0 - p[k]))
(0..g.surface.triangles().len() as u32)
.map(|t| {
let n = g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
match g.ray(g.triangle_centre(t), n.map(|x| -x), far, t) {
Some((hit, s)) if g.normals.get(hit as usize).is_some_and(|&m| dot(m, n) <= opposed) => s,
_ => f64::INFINITY,
}
})
.collect()
}
fn centroid(points: &[P], hx: &[u32; 8]) -> P {
@@ -642,8 +629,7 @@ impl Context<'_> {
let mut first: BTreeMap<u32, P> = BTreeMap::new();
for t in self.g.overlapping(&bx) {
let [a, b, c] = self.g.triangle(t);
let near = geom::closest_on_triangle(centre, a, b, c);
if geom::dist2(centre, near) > reach * reach {
if geom::dist2(centre, geom::closest_on_triangle(centre, a, b, c)) > reach * reach {
continue;
}
let n = self.g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
@@ -652,18 +638,8 @@ impl Context<'_> {
if key.0 < self.options.curvature_levels && dot(f, n) < cos {
return Ok(true);
}
// The wall at the triangle's centre, and, on a triangle larger than the cell, where it
// is nearest the cell: a planar face beside a pocket is a few triangles whose centres
// may face a far wall while the wall here is thin. That reading is held to the
// thinnest wall the plan chose the levels for (towards a wedge's blade the wall
// thins to nothing). (With a relative margin, so cells planned at exactly the size a
// wall needs stay.)
let need = self.options.thickness_cells * e;
let thin = |th: f64| th * (1.0 + 1e-9) < need;
if self.thickness.get(t as usize).is_some_and(|&th| thin(th)) {
return Ok(true);
}
if longest_edge(a, b, c) > e && thin(thickness_at(self.g, t, toward_centre(near, [a, b, c])).max(self.needs.wall)) {
// (With a relative margin, so cells planned at exactly the size a wall needs stay.)
if self.thickness.get(t as usize).is_some_and(|&th| th * (1.0 + 1e-9) < self.options.thickness_cells * e) {
return Ok(true);
}
// Narrow patches (a hole's wall, a thin rim) need cells across them.
-41
View File
@@ -182,44 +182,3 @@ fn a_size_box_refines_the_grid_inside_it() {
let e = octree_hex_mesh(&s, &deep, &shallow).unwrap_err().to_string();
assert!(e.contains("max_depth"), "{e}");
}
/// A prism: the polygon `profile` in the x–z plane (counter-clockwise, star-shaped from its
/// first point) extruded from y = 0 to `depth`. Faces 0 and 1 are the caps, 2 + i the side on
/// edge i.
fn prism_surface(profile: &[[f64; 2]], depth: f64) -> BoundarySurface {
let n = profile.len();
let mut p: Vec<P> = profile.iter().map(|q| [q[0], 0.0, q[1]]).collect();
p.extend(profile.iter().map(|q| [q[0], depth, q[1]]));
let (f, b) = (|i: usize| (i % n) as u32, |i: usize| (n + i % n) as u32);
let (mut tris, mut face) = (Vec::new(), Vec::new());
for i in 1..n - 1 {
tris.extend([[f(0), f(i), f(i + 1)], [b(0), b(i + 1), b(i)]]);
face.extend([0, 1]);
}
for i in 0..n {
tris.extend([[f(i), b(i), b(i + 1)], [f(i), b(i + 1), f(i + 1)]]);
face.extend([2 + i as u32; 2]);
}
BoundarySurface::new(&p, &tris, &face, &crate::surface::SurfaceOptions::default()).unwrap()
}
#[test]
fn a_wall_is_measured_where_a_large_triangle_meets_the_cell() {
// A 30 mm block with a 3 mm flange: the bottom face is two triangles whose centres lie under
// the block, so their centre rays measure 30, but under the flange the wall is 3 thick.
let s = prism_surface(&[[0.0, 0.0], [60.0, 0.0], [60.0, 3.0], [50.0, 3.0], [50.0, 30.0], [0.0, 30.0]], 40.0);
let g = Geometry::new(&s, 30.0, 160.0).unwrap();
let thickness = triangle_thickness(&g);
let bottom: Vec<u32> = (0..s.triangles().len() as u32).filter(|&t| s.tri_face()[t as usize] == 2).collect();
assert!(bottom.iter().all(|&t| thickness[t as usize] == 30.0), "{thickness:?}");
let o = OctreeOptions::default();
let needs = Needs::of(&g, &thickness, &o);
let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
let field = UniformSize(100.0);
let ctx = Context::new(&g, &field, &o, &thickness, identity, 3.0, ([0.0; 3], [60.0, 40.0, 30.0]), needs).unwrap();
assert_eq!(ctx.origin, [-1.5, -2.5, -1.5]);
// Root cells of 3 on the bottom face, clear of every other face: under the flange
// (x 52.5..55.5, 3 cells across need cells of 1) and under the block (x 28.5..31.5).
assert!(ctx.needs_split((0, [18, 7, 0])).unwrap(), "under the flange");
assert!(!ctx.needs_split((0, [10, 7, 0])).unwrap(), "under the block");
}
+26 -3
View File
@@ -454,6 +454,8 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
let edge_c = |e: [u32; 2]| Constraint::of(&as_vec(edge_on.get(&e)));
// 1. Vertices.
fieldcraft_model::progress::stage("Projection: block vertices", None, "");
fieldcraft_model::progress::check()?;
let mut vpos: Vec<P> = t.vertices.clone();
for (v, p) in vpos.iter_mut().enumerate() {
*p = place(g, &targets, vertex_c(v as u32), *p, tol);
@@ -514,11 +516,17 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
// lengths). A class runs through every block its edges are parallel in, so its edges can
// differ a lot in length (a cup's wall, 4 mm thick, and its floor's radial edges, twice
// that); the longest edge alone would cut the thin blocks into flat slivers. No element is
// longer than [`MAX_STRETCH`] sizes, so a coarse size still follows the geometry.
// longer than [`MAX_STRETCH`] sizes, so a coarse size still follows the geometry. An edge
// that bends with a curved target is the exception: its elements' sides are chords of the
// surface, and one longer than the size cuts off more of the part than the size allows (a
// butterfly's rim shares its class with the shorter sides of its middle square), so a class
// takes at least the geometric mean of its bent edges' lengths in sizes (rounded,
// as the mean; an annulus's rim and bore balance each other as its straight edges do).
let (classes, nclass) = edge_classes(t);
let mut class_log = vec![(0.0f64, 0usize); nclass];
// Per class, its longest edge in element sizes.
let mut class_max = vec![0.0f64; nclass];
let mut class_bent = vec![(0.0f64, 0usize); nclass];
let mut seen: BTreeSet<[u32; 2]> = BTreeSet::new();
for (block, cls) in t.blocks.iter().zip(&classes) {
for d in 0..3 {
@@ -550,6 +558,12 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
if let Some(m) = class_max.get_mut(cls[d]) {
*m = m.max(measured);
}
if len - dist(pa, pb) > tol
&& let Some(l) = class_bent.get_mut(cls[d])
{
l.0 += libm::log(measured);
l.1 += 1;
}
if let Some(l) = class_log.get_mut(cls[d]).filter(|_| len > tol) {
l.0 += libm::log(measured);
l.1 += 1;
@@ -615,9 +629,11 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
.iter()
.zip(&class_max)
.zip(&class_arc)
.map(|((&(sum, k), &longest), &arc)| {
.zip(&class_bent)
.map(|(((&(sum, k), &longest), &arc), &(bent_sum, bent_k))| {
let mean = if k == 0 { 1.0 } else { libm::exp(sum / k as f64).round() };
(mean.max((longest / MAX_STRETCH).ceil()) as usize).max(arc).clamp(1, crate::block::MAX_DIVISIONS)
let bent = if bent_k == 0 { 0 } else { libm::exp(bent_sum / bent_k as f64).round().min(crate::block::MAX_DIVISIONS as f64) as usize };
(mean.max((longest / MAX_STRETCH).ceil()) as usize).max(arc).max(bent).clamp(1, crate::block::MAX_DIVISIONS)
})
.collect();
for &(b, d, n) in &opts.divisions {
@@ -683,6 +699,8 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
}
}
// 3. Edge nodes: even by arc length along their curve or surface.
fieldcraft_model::progress::stage("Projection: edge nodes", None, "");
fieldcraft_model::progress::check()?;
for (b, block) in t.blocks.iter().enumerate() {
let nd = lat.divisions[b];
let bn = &lat.block_nodes[b];
@@ -726,6 +744,8 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
}
}
// 4. Faces (projected when outside), then 5. the interior.
fieldcraft_model::progress::stage("Projection: faces and interior", None, "");
fieldcraft_model::progress::check()?;
for (b, block) in t.blocks.iter().enumerate() {
let nd = lat.divisions[b];
let bn = &lat.block_nodes[b];
@@ -784,6 +804,8 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
}
// Nodes on a sheet slide along it among their neighbours on it, as boundary nodes do.
let boundary_quads: Vec<[u32; 4]> = quads.iter().map(|q| q.0).chain(sheet_quads).collect();
fieldcraft_model::progress::stage("Projection: smoothing", None, "");
fieldcraft_model::progress::check()?;
let smoothing = smooth::smooth(
smooth::Mesh { nodes: &mut nodes, hexes: &lat.hexes, constraint: &constraint, held: &held, boundary: &boundary_quads },
g,
@@ -793,6 +815,7 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
1e-4 * opts.size,
);
fieldcraft_model::progress::check()?;
// Certified quality, the gap to the geometry and the tags.
let (mut min_sj, mut sum_sj, mut volume) = (f64::INFINITY, 0.0, 0.0);
let mut invalid: Vec<usize> = Vec::new();
+37
View File
@@ -44,6 +44,43 @@ fn regular_tetrahedron_scores_one() {
}
}
/// The dihedral angles and the scaled Jacobian measure different things on the same corners
/// (#547): a flat tetrahedron is low on both; a needle has every dihedral angle between 60° and
/// 90° but a nearly flat apex corner, so only the scaled Jacobian sees it. A tet10 with straight
/// edges scores what its corners score, and the tet mesher's report reads the same numbers.
#[test]
fn dihedral_angles_and_scaled_jacobian_on_the_same_tetrahedra() {
let both = |p: &[[f64; 3]]| {
let q = element_quality(ElementKind::Tet4, p).unwrap();
(metric(&q, Metric::MinDihedral), metric(&q, Metric::MaxDihedral), metric(&q, Metric::ScaledJacobian))
};
let (lo, hi, sj) = both(&regular_tet());
assert!((lo - 70.528_779).abs() < 1e-5 && (hi - lo).abs() < 1e-9 && close(sj, 1.0), "{lo} {hi} {sj}");
let flat = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [1., 1., 0.01]];
let (lo, hi, sj) = both(&flat);
assert!(lo < 2.0 && hi > 178.0 && sj > 0.0 && sj < 0.02, "{lo} {hi} {sj}");
// Equilateral base of side 1, apex 12 above its centre.
let h = 3.0_f64.sqrt() / 2.0;
let needle = [[0., 0., 0.], [1., 0., 0.], [0.5, h, 0.], [0.5, h / 3.0, 12.0]];
let (lo, hi, sj) = both(&needle);
assert!(lo > 59.0 && hi < 91.0, "{lo} {hi}");
// Apex corner: 6V = (√3/2)·12, edges √(1/3 + 144) each.
let edge = (1.0 / 3.0 + 144.0_f64).sqrt();
assert!(close(sj, 2.0_f64.sqrt() * h * 12.0 / edge.powi(3)), "{sj}");
assert!(sj < 0.01);
for p in [regular_tet(), flat, needle] {
let tet10 = element_quality(ElementKind::Tet10, &midpoints(&p, &jacobian::TET_EDGES)).unwrap();
let (lo, hi, sj) = both(&p);
assert!(close(metric(&tet10, Metric::MinDihedral), lo) && close(metric(&tet10, Metric::MaxDihedral), hi));
assert!(close(metric(&tet10, Metric::ScaledJacobian), sj));
let r = crate::tet::tet_quality_stats(&p, &[[0, 1, 2, 3]]).unwrap();
assert!(close(r.min_dihedral, lo) && close(r.max_dihedral, hi) && close(r.min_scaled_jacobian, sj), "{r:?}");
}
}
#[test]
fn right_corner_tetrahedron() {
let q = element_quality(ElementKind::Tet4, &RIGHT_TET).unwrap();
+15 -3
View File
@@ -275,7 +275,8 @@ struct Elem {
nodes: Vec<u32>,
/// A shell's normal as it was: its side, which a move must keep.
normal: Option<P>,
/// Its size as it was ([`strong::size`]): the strong mode keeps sizes near it.
/// Its size as it was ([`strong::size`]): the strong mode keeps sizes near it (only the strong
/// mode measures it, a certified check per element; 0 otherwise).
size0: f64,
}
@@ -570,7 +571,13 @@ impl<'a> State<'a> {
}
}
}
state.elems.push(Elem { label, kind: e.kind, normal: shell_normal(e.kind, &x), size0: strong::size(e.kind, &x), nodes: e.nodes.clone() });
state.elems.push(Elem {
label,
kind: e.kind,
normal: shell_normal(e.kind, &x),
size0: if pin_curves { strong::size(e.kind, &x) } else { 0.0 },
nodes: e.nodes.clone(),
});
}
Ok(state)
}
@@ -938,8 +945,13 @@ pub fn apply_field(
Ok(out)
}
mod moving;
mod strong;
pub use strong::{MAX_ROUNDS, MorphOptions, Morphed, Round, morph};
pub use moving::{
DEFAULT_MIN_QUALITY, ElementChange, GUARD_STEPS, Guard, Influence, MIN_RELATIVE_RADIUS, MoveRun, Moved, Propagate, Unconstrained, centroid,
move_nodes, move_nodes_with, move_weights, nearest_corner,
};
pub use strong::{DEFAULT_REACH_LENGTHS, Guards, MAX_ROUNDS, MorphOptions, Morphed, Round, morph};
#[cfg(test)]
mod tests;
+431
View File
@@ -0,0 +1,431 @@
//! Moving nodes by hand (`mesh.node.move`): grabbed nodes are displaced by a vector, and the
//! nodes round them follow, so an analyst can drag a node of a finished mesh and see what the
//! move does to the elements around it.
//!
//! **Who follows.** Every node gets a share `w` in `[0, 1]` of the displacement ([`move_weights`]):
//!
//! - *Falloff*: the grabbed nodes move by all of it, a node within the radius of them by
//! `strength × falloff(t)` (`t` its distance to the nearest grabbed node over the radius,
//! [`Falloff`]); with a sphere and no grabbed nodes, every node inside it by
//! `strength × falloff(t)` of its distance to the centre (a soft selection).
//! - *Harmonic*: the share is the harmonic function on the graph of element edges that is 1 at
//! the grabbed nodes and 0 beyond the radius and at the nodes that may not move (Gauss–Seidel
//! on the graph Laplacian, as the strong relaxation's morph: [`super::strong`]). The
//! displacement then spreads through the mesh as a membrane would carry it, following the
//! mesh's connectivity rather than straight-line distance (M. Botsch, O. Sorkine, *On linear
//! variational surface deformation methods*, IEEE TVCG 14, 2008, §3: harmonic deformation
//! fields with fixed and handle regions).
//!
//! **Where a node may go** is the relaxation's [`Constraints`]: a node on a geometry face slides
//! in the face's parameters, a node on a geometry edge along it, a node on a vertex stays; with
//! the geometry off ([`Unconstrained`]) every node moves in space. Mid-side nodes follow their
//! edge's corners, as in a relaxation.
//!
//! **Guard.** With [`Guard::NoInversion`] the displacement is scaled back (by bisection, at
//! most [`GUARD_STEPS`] halvings) to the largest share of it with which no valid element becomes
//! invalid or drops below the guard's smallest scaled Jacobian (default
//! [`DEFAULT_MIN_QUALITY`]; or its own, when that was lower); the scale is reported.
use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_model::Model;
use super::{Constraints, Falloff, Freedom, P, RegionQuality, State, add, corner_nodes, dist, dot, measure, scale, sub};
use crate::{Error, Result};
/// How the displacement reaches the nodes round the grabbed ones.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Propagate {
/// `strength × falloff` of the distance.
#[default]
Falloff,
/// The harmonic function of the element-edge graph.
Harmonic,
}
impl Propagate {
pub fn from_name(name: &str) -> Option<Propagate> {
match name {
"falloff" => Some(Propagate::Falloff),
"harmonic" => Some(Propagate::Harmonic),
_ => None,
}
}
}
/// What keeps a move from breaking elements.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Guard {
/// The move as asked, whatever it does to the elements.
None,
/// Scaled back until no valid element becomes invalid or falls below `min_quality` (scaled
/// Jacobian; an element already below keeps at least its own).
NoInversion { min_quality: f64 },
}
impl Default for Guard {
fn default() -> Self {
Guard::NoInversion { min_quality: DEFAULT_MIN_QUALITY }
}
}
/// Halvings of the guard's bisection: the scale is found to within 2⁻²⁰.
pub const GUARD_STEPS: usize = 20;
/// The default smallest scaled Jacobian the guard lets a move make: with 0 the bisection stops at
/// the very edge of inversion, with an element of quality 0.
pub const DEFAULT_MIN_QUALITY: f64 = 0.05;
/// The smallest radius of influence, relative to the mesh's size (its bounding box diagonal):
/// below it no node but the grabbed ones is within reach, and the search grid would overflow.
pub const MIN_RELATIVE_RADIUS: f64 = 1e-6;
/// Every node moves in space, on the skin too: a move with the geometry off.
#[derive(Debug, Clone, Copy, Default)]
pub struct Unconstrained;
impl Constraints for Unconstrained {
fn freedom(&self, _: u32) -> Option<Freedom> {
Some(Freedom::Free)
}
fn slide_face(&self, _: u32, _: P, _: P) -> Option<P> {
None
}
fn slide_curve(&self, _: u32, _: P, _: P) -> Option<P> {
None
}
}
/// Which nodes follow the grabbed ones, and how.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Influence {
/// The sphere's centre: shares by the distance to it rather than to the grabbed nodes.
pub centre: Option<P>,
/// Nodes this close to the grabbed nodes (or the centre) follow; `None`: only the grabbed
/// nodes move.
pub radius: Option<f64>,
pub propagate: Propagate,
/// Falloff only: the largest share of a node that is not grabbed, in `(0, 1]`.
pub strength: f64,
pub falloff: Falloff,
}
impl Default for Influence {
fn default() -> Self {
Influence { centre: None, radius: None, propagate: Propagate::Falloff, strength: 1.0, falloff: Falloff::Smooth }
}
}
/// For each candidate, its distance to the nearest of `from` when that is at most `r` (a uniform
/// grid of cell `r` over `from`; deterministic).
pub(super) fn nearest_within(from: &[P], candidates: &BTreeMap<u32, P>, r: f64) -> BTreeMap<u32, f64> {
// Saturating casts and additions: no coordinate over radius overflows the index.
let cell = |p: P| p.map(|x| (x / r).floor() as i64);
let mut grid: BTreeMap<[i64; 3], Vec<P>> = BTreeMap::new();
for &p in from {
grid.entry(cell(p)).or_default().push(p);
}
let mut out = BTreeMap::new();
for (&n, &p) in candidates {
let [i, j, k] = cell(p);
let mut best = f64::INFINITY;
for di in -1..=1 {
for dj in -1..=1 {
for dk in -1..=1 {
for &q in grid.get(&[i.saturating_add(di), j.saturating_add(dj), k.saturating_add(dk)]).map(Vec::as_slice).unwrap_or(&[]) {
best = best.min(dist(p, q));
}
}
}
}
if best <= r {
out.insert(n, best);
}
}
out
}
/// The share of the displacement each node takes (node → share in `(0, 1]`): the grabbed
/// corner nodes `handles` all of it, the others as `influence` says. Nodes that may not move
/// under `geometry` (a vertex, a skin node of unknown geometry) are anchors of the harmonic
/// function and get no share. Only corner nodes of measured elements count (mid-side nodes
/// follow them).
pub fn move_weights(model: &Model, handles: &BTreeSet<u32>, influence: &Influence, geometry: &dyn Constraints) -> Result<BTreeMap<u32, f64>> {
let Influence { centre, radius, propagate, strength, falloff } = *influence;
if !(strength > 0.0 && strength <= 1.0) {
return Err(Error::Invalid(format!("strength must be in (0, 1], got {strength}")));
}
if radius.is_some_and(|r| !(r > 0.0 && r.is_finite())) {
return Err(Error::Invalid("the radius must be positive".into()));
}
if centre.is_some_and(|c| !c.iter().all(|x| x.is_finite())) {
return Err(Error::Invalid("the centre must be finite".into()));
}
let corners = corner_nodes(model);
let handles: BTreeSet<u32> = handles.iter().copied().filter(|n| corners.contains(n)).collect();
if handles.len() > super::MAX_NODES {
return Err(Error::Limit(format!("more than {} nodes to move", super::MAX_NODES)));
}
let positions: BTreeMap<u32, P> = corners.iter().filter_map(|&n| model.node(n).map(|p| (n, p.xyz))).collect();
if let Some(r) = radius {
let (lo, hi) = positions.values().fold(([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]), |(lo, hi), p| {
([lo[0].min(p[0]), lo[1].min(p[1]), lo[2].min(p[2])], [hi[0].max(p[0]), hi[1].max(p[1]), hi[2].max(p[2])])
});
let size = if positions.is_empty() { 0.0 } else { dist(lo, hi) };
let min = MIN_RELATIVE_RADIUS * size;
if r < min {
return Err(Error::Invalid(format!("the radius must be at least {min:.3e} (a millionth of the mesh's size), got {r:.3e}")));
}
}
// The nodes within the radius, with their distance to the nearest handle (or the centre).
let near = match (radius, centre) {
(None, _) => BTreeMap::new(),
(Some(r), Some(c)) => nearest_within(&[c], &positions, r),
(Some(r), None) => {
let from: Vec<P> = handles.iter().filter_map(|n| positions.get(n).copied()).collect();
nearest_within(&from, &positions, r)
}
};
if near.len() > super::MAX_NODES {
return Err(Error::Limit(format!("more than {} nodes within the radius; give a smaller one", super::MAX_NODES)));
}
let mut out: BTreeMap<u32, f64> = handles.iter().map(|&n| (n, 1.0)).collect();
match propagate {
Propagate::Falloff => {
let r = radius.unwrap_or(1.0);
for (&n, &d) in &near {
let w = strength * falloff.weight(d / r);
if w > 0.0 && !handles.contains(&n) {
out.insert(n, w);
}
}
}
Propagate::Harmonic => {
if radius.is_none() {
return Err(Error::Invalid("a harmonic move needs a radius".into()));
}
if handles.is_empty() {
return Err(Error::Invalid("a harmonic move needs nodes to grab".into()));
}
let movers: BTreeSet<u32> = near.keys().chain(&handles).copied().collect();
let state = State::new(model, &movers, geometry, false)?;
let free: Vec<u32> = near.keys().copied().filter(|n| !handles.contains(n) && state.freedom(*n) != Freedom::Fixed).collect();
let fixed: BTreeMap<u32, P> = handles.iter().map(|&n| (n, [1.0, 0.0, 0.0])).collect();
let u = state.harmonic(&free, &fixed)?;
for n in free {
let w = u.get(&n).map_or(0.0, |v| v[0]).clamp(0.0, 1.0);
if w > 1e-12 {
out.insert(n, w);
}
}
}
}
Ok(out)
}
/// One element's quality before and after a move.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ElementChange {
pub label: u32,
/// Scaled Jacobian (a triangle's smallest angle over 60°).
pub before: f64,
pub after: f64,
pub valid_before: bool,
pub valid_after: bool,
}
impl ElementChange {
/// Its quality dropped, or it became invalid.
pub fn worse(&self) -> bool {
self.after < self.before - SAME || (self.valid_before && !self.valid_after)
}
/// Its quality rose.
pub fn better(&self) -> bool {
self.after > self.before + SAME && !self.worse()
}
}
/// What [`move_nodes`] did.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Moved {
/// The new position of every node that moved (corners and the mid-side nodes following them).
pub positions: BTreeMap<u32, P>,
/// Corner nodes that moved.
pub moved: usize,
pub max_displacement: f64,
/// The quality of the elements with a corner that may move, before and after.
pub before: RegionQuality,
pub after: RegionQuality,
pub mean_before: f64,
pub mean_after: f64,
/// Elements whose quality dropped, and rose.
pub worse: usize,
pub better: usize,
/// The share of the asked displacement applied (below 1 when the guard scaled it back).
pub scale: f64,
/// Every element of the region, by label.
pub elements: Vec<ElementChange>,
}
/// A change in quality smaller than this is no change.
const SAME: f64 = 1e-9;
/// Move each node of `weights` by its share of `d`, as `geometry` lets it, the displacement
/// scaled back under `guard`. The model is not changed: the result has the new positions.
pub fn move_nodes(model: &Model, weights: &BTreeMap<u32, f64>, d: P, guard: Guard, geometry: &dyn Constraints) -> Result<Moved> {
move_nodes_with(model, weights, d, guard, geometry, &MoveRun::default())
}
/// How [`move_nodes_with`] runs: what a live preview reuses and how finely it guards.
#[derive(Debug, Clone, Default)]
pub struct MoveRun {
/// The region's elements' quality and validity before any move, in label order (from an
/// earlier run's [`Moved::elements`] with the same weights); measured again when absent or
/// of another length.
pub baseline: Option<Vec<(f64, bool)>>,
/// Halvings of the guard's bisection; 0 is [`GUARD_STEPS`].
pub guard_steps: usize,
}
/// Each element's quality and validity at the state's positions, measured in parallel (fixed
/// blocks: the same result on any thread count).
fn measure_all(elems: &[super::Elem], pos: &BTreeMap<u32, P>) -> Vec<(f64, bool)> {
fieldcraft_parallel::map(elems.len(), |i| {
let Some(e) = elems.get(i) else { return (-1.0, false) };
let x: Vec<P> = e.nodes.iter().map(|n| pos.get(n).copied().unwrap_or([0.0; 3])).collect();
measure(e, &x)
})
}
/// The region's quality from its elements' measures.
fn summary(state: &State<'_>, values: &[(f64, bool)]) -> RegionQuality {
let mut r = RegionQuality { elements: state.elems.len(), min_quality: f64::INFINITY, max_edge_ratio: 0.0, invalid: 0, worst: Vec::new() };
let mut all = Vec::with_capacity(values.len());
for (e, &(q, valid)) in state.elems.iter().zip(values) {
r.min_quality = r.min_quality.min(q);
r.max_edge_ratio = r.max_edge_ratio.max(super::edge_ratio(e.kind, &state.coords(e)));
r.invalid += usize::from(!valid);
all.push((e.label, q));
}
all.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
all.truncate(super::WORST_LISTED);
r.worst = all;
if r.elements == 0 {
r.min_quality = 0.0;
}
r
}
/// [`move_nodes`], reusing what `run` carries.
pub fn move_nodes_with(model: &Model, weights: &BTreeMap<u32, f64>, d: P, guard: Guard, geometry: &dyn Constraints, run: &MoveRun) -> Result<Moved> {
if !d.iter().all(|x| x.is_finite()) || dot(d, d).sqrt() > fieldcraft_model::MAX_COORD {
return Err(Error::Invalid("the displacement must be finite".into()));
}
if weights.len() > super::MAX_NODES {
return Err(Error::Limit(format!("more than {} nodes to move", super::MAX_NODES)));
}
let floor = match guard {
Guard::None => 0.0,
Guard::NoInversion { min_quality } if (0.0..=1.0).contains(&min_quality) => min_quality,
Guard::NoInversion { min_quality } => return Err(Error::Invalid(format!("the smallest quality must be in [0, 1], got {min_quality}"))),
};
if let Some((n, w)) = weights.iter().find(|(_, w)| !(**w > 0.0 && **w <= 1.0)) {
return Err(Error::Invalid(format!("node {n} has share {w}; shares are in (0, 1]")));
}
let steps = if run.guard_steps == 0 { GUARD_STEPS } else { run.guard_steps.min(GUARD_STEPS) };
let movers: BTreeSet<u32> = weights.keys().copied().collect();
let mut state = State::new(model, &movers, geometry, false)?;
let original = state.pos.clone();
let start = match &run.baseline {
Some(b) if b.len() == state.elems.len() => b.clone(),
_ => measure_all(&state.elems, &state.pos),
};
let before = summary(&state, &start);
let freedom: BTreeMap<u32, Freedom> = movers.iter().map(|&n| (n, state.freedom(n))).collect();
// The state with the displacement applied at scale `t`.
let apply = |state: &mut State<'_>, t: f64| {
state.pos.clone_from(&original);
for (&n, &w) in weights {
let (Some(&f), Some(&p0)) = (freedom.get(&n), original.get(&n)) else { continue };
if !state.around.contains_key(&n) {
continue;
}
if let Some(q) = state.moved(f, p0, scale(d, w * t)) {
state.place(n, q);
}
}
};
// Whether every element valid before is still valid, and no worse than the guard's floor
// (or its own quality, when that was lower).
let sound = |now: &[(f64, bool)]| now.iter().zip(&start).all(|(&(q, valid), &(q0, was))| !was || (valid && q >= floor.min(q0)));
apply(&mut state, 1.0);
let mut now = measure_all(&state.elems, &state.pos);
let mut t = 1.0;
if guard != Guard::None && !sound(&now) {
let (mut lo, mut hi) = (0.0, 1.0);
let mut at_lo = None;
for _ in 0..steps {
fieldcraft_model::progress::check()?;
let mid = 0.5 * (lo + hi);
apply(&mut state, mid);
let m = measure_all(&state.elems, &state.pos);
if sound(&m) {
lo = mid;
at_lo = Some(m);
} else {
hi = mid;
}
}
t = lo;
apply(&mut state, t);
now = at_lo.unwrap_or_else(|| start.clone());
}
let after = summary(&state, &now);
let mut out = Moved { before, after, scale: t, ..Moved::default() };
let (mut sum_before, mut sum_after) = (0.0, 0.0);
for (e, (&(q0, v0), &(q1, v1))) in state.elems.iter().zip(start.iter().zip(&now)) {
sum_before += q0;
sum_after += q1;
let change = ElementChange { label: e.label, before: q0, after: q1, valid_before: v0, valid_after: v1 };
out.worse += usize::from(change.worse());
out.better += usize::from(change.better());
out.elements.push(change);
}
if !state.elems.is_empty() {
out.mean_before = sum_before / state.elems.len() as f64;
out.mean_after = sum_after / state.elems.len() as f64;
}
for (n, p) in &state.pos {
let Some(o) = original.get(n) else { continue };
let moved = dist(*o, *p);
if moved > 0.0 {
out.max_displacement = out.max_displacement.max(moved);
out.moved += usize::from(movers.contains(n));
out.positions.insert(*n, *p);
}
}
Ok(out)
}
/// The centroid of `nodes`' positions (`None` when none has one): where a move `to` a point
/// starts from.
pub fn centroid(model: &Model, nodes: &BTreeSet<u32>) -> Option<P> {
let pts: Vec<P> = nodes.iter().filter_map(|n| model.node(*n).map(|p| p.xyz)).collect();
if pts.is_empty() {
return None;
}
Some(scale(pts.iter().fold([0.0; 3], |s, p| add(s, *p)), 1.0 / pts.len() as f64))
}
/// The corner node of a measured element nearest `p` (the node a sphere's harmonic move grabs).
pub fn nearest_corner(model: &Model, p: P) -> Option<u32> {
let mut best: Option<(f64, u32)> = None;
for n in corner_nodes(model) {
let Some(x) = model.node(n) else { continue };
let d = dot(sub(x.xyz, p), sub(x.xyz, p));
if best.is_none_or(|(b, _)| d < b) {
best = Some((d, n));
}
}
best.map(|(_, n)| n)
}
+49 -15
View File
@@ -254,21 +254,37 @@ impl State<'_> {
}
}
/// When [`morph`] keeps a cluster's morph.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Guards {
/// Kept whenever no element it touches inverts (more invalid elements than before).
#[default]
Valid,
/// Kept (at full or a halved scale) only when, besides, the worst element it touches is no
/// worse, the smooth objective there drops and no element gets longer than the longest was.
Strict,
}
/// Default reach of [`morph`], in mean edge lengths of the bad elements.
pub const DEFAULT_REACH_LENGTHS: f64 = 5.0;
/// What [`morph`] does.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MorphOptions {
/// Elements below this quality are the bad ones.
pub below: f64,
/// The radius round the bad nodes whose nodes follow them; `None`: three times the bad
/// elements' mean edge length.
/// The radius round the bad nodes whose nodes follow them; `None`:
/// [`DEFAULT_REACH_LENGTHS`] times the bad elements' mean edge length.
pub reach: Option<f64>,
/// Most rounds (bad nodes moved, the region morphed after them).
pub rounds: usize,
/// When a cluster's morph is kept.
pub guards: Guards,
}
impl Default for MorphOptions {
fn default() -> Self {
MorphOptions { below: 0.7, reach: None, rounds: 20 }
MorphOptions { below: 0.7, reach: None, rounds: 20, guards: Guards::Valid }
}
}
@@ -380,8 +396,9 @@ impl State<'_> {
/// Morph round one cluster of bad nodes: their own optimisation (the rest held) gives their
/// displacements, the harmonic extension carries them to the nodes within `reach`, applied at
/// full or a halved scale when that improves the elements it touches. The region's size and
/// whether it was kept.
fn morph_cluster(&mut self, bad: &BTreeSet<u32>, reach: f64, movable: &dyn Fn(&State<'_>, u32) -> bool) -> Result<(usize, bool)> {
/// whether it was kept. With [`Guards::Valid`] the full morph is kept unless an element
/// it touches inverts; with [`Guards::Strict`] only a scale that improves the elements is.
fn morph_cluster(&mut self, bad: &BTreeSet<u32>, reach: f64, guards: Guards, movable: &dyn Fn(&State<'_>, u32) -> bool) -> Result<(usize, bool)> {
let start: BTreeMap<u32, P> = bad.iter().map(|&n| (n, self.at(n))).collect();
let mut undo = Vec::new();
let mut steps: BTreeMap<u32, f64> = bad.iter().map(|&n| (n, 0.1)).collect();
@@ -418,7 +435,11 @@ impl State<'_> {
}
}
let now = self.certified(&touched);
if now.improves(&was) && now.ratio <= self.ratio_cap.max(was.ratio) {
let keep = match guards {
Guards::Valid => now.invalid <= was.invalid,
Guards::Strict => now.improves(&was) && now.ratio <= self.ratio_cap.max(was.ratio),
};
if keep {
return Ok((region.len(), true));
}
self.restore(undo);
@@ -458,7 +479,7 @@ impl State<'_> {
/// The harmonic extension over `free` of the displacements `fixed` (zero at every other
/// node): Gauss–Seidel on the graph Laplacian of the element edges.
fn harmonic(&self, free: &[u32], fixed: &BTreeMap<u32, P>) -> Result<BTreeMap<u32, P>> {
pub(super) fn harmonic(&self, free: &[u32], fixed: &BTreeMap<u32, P>) -> Result<BTreeMap<u32, P>> {
let mut u: BTreeMap<u32, P> = fixed.clone();
for &n in free {
u.insert(n, [0.0; 3]);
@@ -494,10 +515,11 @@ impl State<'_> {
/// the reach by harmonic interpolation (the graph Laplacian solved with the bad nodes'
/// displacements and zero beyond the reach and at held nodes). Face nodes are put back on their
/// face, edge and vertex nodes do not move, nodes outside the reach stay exactly as they are. A
/// cluster's morph is kept, at full or a halved scale, only if no more elements are invalid, no element
/// gets longer than the longest one was, the worst element it touches is no worse and the smooth
/// objective there drops. Only nodes of `nodes`
/// move. The model is not changed: the result has the new positions.
/// cluster's morph is kept as long as no element it touches inverts ([`Guards::Valid`]), or, with
/// [`Guards::Strict`], at full or a halved scale only if besides no element gets longer than the
/// longest one was, the worst element it touches is no worse and the smooth objective there drops.
/// The result is the round with the highest smallest quality (never below the start). Only nodes
/// of `nodes` move. The model is not changed: the result has the new positions.
pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geometry: &dyn Constraints) -> Result<Morphed> {
if !(options.below > 0.0 && options.below <= 1.0) {
return Err(Error::Invalid(format!("below must be in (0, 1], got {}", options.below)));
@@ -518,6 +540,14 @@ pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geomet
let (_, _, mean_before) = state.qualities();
let movable = |s: &State<'_>, n: u32| nodes.contains(&n) && s.around.contains_key(&n) && s.freedom(n) != Freedom::Fixed;
let mut rounds = Vec::new();
// The best state of the run: the highest smallest quality, then the fewest elements below
// `below`, then the highest mean; the start until a round beats it.
let score = |s: &State<'_>| {
let (q, min, mean) = s.qualities();
(min, q.iter().filter(|v| **v < options.below).count(), mean)
};
let better = |a: (f64, usize, f64), b: (f64, usize, f64)| a.0 > b.0 || (a.0 == b.0 && (a.1 < b.1 || (a.1 == b.1 && a.2 > b.2)));
let mut best = (score(&state), original.clone());
for round in 0..options.rounds {
fieldcraft_model::progress::stage(&format!("morph round {}", round + 1), Some(options.rounds as f64), "rounds");
fieldcraft_model::progress::count(round as f64);
@@ -539,7 +569,7 @@ pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geomet
if bad.is_empty() {
break;
}
let reach = options.reach.unwrap_or(if edge_sum.1 > 0 { 3.0 * edge_sum.0 / edge_sum.1 as f64 } else { 0.0 });
let reach = options.reach.unwrap_or(if edge_sum.1 > 0 { DEFAULT_REACH_LENGTHS * edge_sum.0 / edge_sum.1 as f64 } else { 0.0 });
if !(reach > 0.0) {
break;
}
@@ -548,18 +578,22 @@ pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geomet
let mut round_out = Round { bad_elements: bad_elems.len(), bad_nodes: bad.len(), reach, clusters: clusters.len(), ..Round::default() };
for cluster in &clusters {
fieldcraft_model::progress::check()?;
let (region, kept) = state.morph_cluster(cluster, reach, &|s: &State<'_>, n| movable(s, n))?;
let (region, kept) = state.morph_cluster(cluster, reach, options.guards, &|s: &State<'_>, n| movable(s, n))?;
round_out.region_nodes += region;
round_out.kept += usize::from(kept);
}
let (_, min, mean) = state.qualities();
(round_out.min_quality, round_out.mean_quality) = (min, mean);
let now = score(&state);
(round_out.min_quality, round_out.mean_quality) = (now.0, now.2);
if better(now, best.0) {
best = (now, state.pos.clone());
}
let any = round_out.kept > 0;
rounds.push(round_out);
if !any {
break;
}
}
state.pos = best.1;
let mut after = region_quality(&state);
let (_, _, mut mean_after) = state.qualities();
// Never worse than the start.
+154 -2
View File
@@ -360,7 +360,7 @@ fn strong_relaxation_of_a_distorted_block_keeps_faces_and_edges() {
let (original, m) = distorted_annulus(6, 0.06);
let geo = Geo { model: original.clone() };
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
let options = MorphOptions { below: 0.9, reach: None, rounds: 40 };
let options = MorphOptions { below: 0.9, reach: None, rounds: 40, ..MorphOptions::default() };
let r = morph(&m, &nodes, options, &geo).unwrap();
assert!(r.after.min_quality > r.before.min_quality + 0.2, "{:?} → {:?}: {:?}", r.before.min_quality, r.after.min_quality, r.rounds);
assert!(r.mean_after > r.mean_before, "mean {} → {}", r.mean_before, r.mean_after);
@@ -389,7 +389,7 @@ fn strong_relaxation_moves_only_nodes_within_the_reach() {
let geo = Geo { model: original };
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
let reach = 0.4;
let r = morph(&m, &nodes, MorphOptions { below: 0.8, reach: Some(reach), rounds: 20 }, &geo).unwrap();
let r = morph(&m, &nodes, MorphOptions { below: 0.8, reach: Some(reach), rounds: 20, ..MorphOptions::default() }, &geo).unwrap();
assert!(r.after.min_quality > r.before.min_quality, "{:?}", r.rounds);
for n in r.positions.keys() {
assert!(dist(m.node(*n).unwrap().xyz, CENTRE) < 0.2 + 2.0 * reach, "node {n} far from the bad spot moved");
@@ -413,6 +413,22 @@ fn strong_relaxation_untangles_inverted_elements() {
assert!(r.after.min_quality > 0.5, "{:?}", r.after);
}
/// Both guards keep the result valid and never below the start; the valid guards (keep a morph
/// unless it inverts an element) lift the worst element at least as far as the strict ones.
#[test]
fn strong_relaxation_guards_valid_and_strict() {
let (original, m) = distorted_annulus(6, 0.06);
let geo = Geo { model: original };
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
let run = |guards| morph(&m, &nodes, MorphOptions { below: 0.9, rounds: 40, guards, ..MorphOptions::default() }, &geo).unwrap();
let (valid, strict) = (run(Guards::Valid), run(Guards::Strict));
for r in [&valid, &strict] {
assert_eq!(r.after.invalid, 0, "{:?}", r.after);
assert!(r.after.min_quality >= r.before.min_quality, "{:?} → {:?}", r.before.min_quality, r.after.min_quality);
}
assert!(valid.after.min_quality >= strict.after.min_quality - 1e-9, "valid {:?}, strict {:?}", valid.after, strict.after);
}
/// A good mesh is left as it is; bad options are errors.
#[test]
fn strong_relaxation_keeps_a_good_mesh_and_refuses_bad_options() {
@@ -449,3 +465,139 @@ fn guarded_relaxation_keeps_the_largest_edge_ratio() {
assert!(r.after.invalid <= r.before.invalid);
}
}
// ---- Moving nodes by hand (`mesh.node.move`) ----
/// The annulus node `(i, j, k)` of [`hex_annulus`] with `n` divisions.
fn node_of(n: usize, i: usize, j: usize, k: usize) -> u32 {
(1 + i + (n + 1) * (j + (n + 1) * k)) as u32
}
fn grab(n: u32) -> BTreeSet<u32> {
BTreeSet::from([n])
}
/// Falloff: the grabbed node moves by all of the displacement, the nodes round it by less the
/// farther they are, none beyond the radius; the three falloffs differ.
#[test]
fn a_falloff_move_carries_the_grabbed_node_and_fades_to_the_radius() {
let m = hex_annulus(6);
let h = node_of(6, 3, 3, 3);
let d = [0.02, -0.01, 0.015];
let r = 0.6;
let mut shares = Vec::new();
for falloff in [Falloff::Smooth, Falloff::Linear, Falloff::None] {
let inf = Influence { radius: Some(r), falloff, ..Influence::default() };
let w = move_weights(&m, &grab(h), &inf, &Unconstrained).unwrap();
assert_eq!(w.get(&h), Some(&1.0));
let moved = move_nodes(&m, &w, d, Guard::None, &Unconstrained).unwrap();
assert!(dist(moved.positions[&h], add(m.node(h).unwrap().xyz, d)) < 1e-12);
let p0 = m.node(h).unwrap().xyz;
for (n, p) in &moved.positions {
let old = m.node(*n).unwrap().xyz;
assert!(dist(old, p0) <= r + 1e-12, "node {n} beyond the radius moved");
assert!(dist(old, *p) <= dist([0.0; 3], d) + 1e-12);
}
assert_eq!(moved.scale, 1.0);
assert!(moved.elements.len() >= 8 && moved.worse + moved.better > 0);
shares.push(w.get(&node_of(6, 4, 3, 3)).copied().unwrap_or(0.0));
}
// Smooth (1 − t²)², linear 1 − t, none 1, at t = one element over the radius.
assert!(shares[0] > 0.0 && shares[0] < 1.0 && shares[1] > 0.0 && shares[1] < 1.0 && shares[2] == 1.0, "{shares:?}");
assert_ne!(shares[0], shares[1]);
// Strength scales the followers, never the grabbed node.
let half = Influence { radius: Some(r), strength: 0.5, falloff: Falloff::None, ..Influence::default() };
let w = move_weights(&m, &grab(h), &half, &Unconstrained).unwrap();
assert_eq!((w.get(&h), w.get(&node_of(6, 4, 3, 3))), (Some(&1.0), Some(&0.5)));
// A sphere without grabbed nodes is a soft selection round its centre.
let c = m.node(h).unwrap().xyz;
let w = move_weights(&m, &BTreeSet::new(), &Influence { centre: Some(c), radius: Some(r), ..Influence::default() }, &Unconstrained).unwrap();
assert!(w.get(&h).is_some_and(|v| (*v - 1.0).abs() < 1e-12) && w.len() > 1);
}
/// Harmonic: shares between 0 and 1, 1 at the grabbed node, falling with the graph distance,
/// and zero beyond the radius (no node there moves).
#[test]
fn a_harmonic_move_is_zero_beyond_the_radius() {
let m = hex_annulus(8);
let h = node_of(8, 4, 4, 4);
let r = 0.45;
let inf = Influence { radius: Some(r), propagate: Propagate::Harmonic, ..Influence::default() };
let w = move_weights(&m, &grab(h), &inf, &Unconstrained).unwrap();
let p0 = m.node(h).unwrap().xyz;
assert_eq!(w.get(&h), Some(&1.0));
assert!(w.len() > 10);
for (n, s) in &w {
assert!(*s > 0.0 && *s <= 1.0);
assert!(dist(m.node(*n).unwrap().xyz, p0) <= r + 1e-12, "node {n} beyond the radius has a share");
}
let (near, far) = (w[&node_of(8, 5, 4, 4)], w.get(&node_of(8, 6, 4, 4)).copied().unwrap_or(0.0));
assert!(near < 1.0 && far < near, "{near} {far}");
let moved = move_nodes(&m, &w, [0.0, 0.0, 0.03], Guard::default(), &Unconstrained).unwrap();
assert!(moved.positions.keys().all(|n| w.contains_key(n)));
assert_eq!(moved.after.invalid, 0);
}
/// On the geometry: a node on the outer cylinder stays on it, a node on an edge of two faces
/// stays on both, a vertex is held; followers on the faces stay on theirs.
#[test]
fn a_move_on_the_geometry_slides_on_faces_and_edges_and_holds_vertices() {
let n = 6;
let m = hex_annulus(n);
let geo = Geo { model: m.clone() };
let d = [0.05, 0.05, 0.05];
let inf = Influence { radius: Some(0.5), ..Influence::default() };
for h in [node_of(n, n, 3, 3), node_of(n, n, 3, 0), node_of(n, n, 0, 0)] {
let w = move_weights(&m, &grab(h), &inf, &geo).unwrap();
let moved = move_nodes(&m, &w, d, Guard::default(), &geo).unwrap();
for (k, p) in &moved.positions {
let faces = Annulus::faces(m.node(*k).unwrap().xyz);
assert!(faces.len() < 3, "vertex {k} moved");
assert!(Annulus::on(*p, &faces), "node {k} left its faces {faces:?}");
}
let moved_handle = moved.positions.contains_key(&h);
assert_eq!(moved_handle, Annulus::faces(m.node(h).unwrap().xyz).len() < 3, "node {h}");
}
}
/// The guard scales a move that would invert elements back to the largest that inverts none.
#[test]
fn the_guard_scales_a_move_back_until_nothing_inverts() {
let m = hex_annulus(6);
let h = node_of(6, 3, 3, 3);
let w = move_weights(&m, &grab(h), &Influence::default(), &Unconstrained).unwrap();
assert_eq!(w.len(), 1);
let d = [0.6, 0.0, 0.0];
let wild = move_nodes(&m, &w, d, Guard::None, &Unconstrained).unwrap();
assert!(wild.after.invalid > 0 && wild.scale == 1.0);
let guarded = move_nodes(&m, &w, d, Guard::default(), &Unconstrained).unwrap();
assert_eq!(guarded.after.invalid, 0);
assert!(guarded.scale > 0.05 && guarded.scale < 1.0, "{}", guarded.scale);
assert!(guarded.worse > 0 && guarded.after.min_quality < guarded.before.min_quality);
assert!(guarded.after.min_quality >= DEFAULT_MIN_QUALITY, "the guard stops short of a degenerate element");
let p = guarded.positions[&h];
assert!((p[0] - m.node(h).unwrap().xyz[0] - 0.6 * guarded.scale).abs() < 1e-12);
}
#[test]
fn hostile_move_parameters_are_refused() {
let m = hex_annulus(3);
let h = grab(node_of(3, 1, 1, 1));
let bad = |inf: Influence| move_weights(&m, &h, &inf, &Unconstrained).is_err();
assert!(bad(Influence { strength: 0.0, ..Influence::default() }));
assert!(bad(Influence { strength: f64::NAN, ..Influence::default() }));
assert!(bad(Influence { radius: Some(-1.0), ..Influence::default() }));
assert!(bad(Influence { radius: Some(f64::INFINITY), ..Influence::default() }));
assert!(bad(Influence { centre: Some([f64::NAN, 0.0, 0.0]), radius: Some(1.0), ..Influence::default() }));
assert!(bad(Influence { propagate: Propagate::Harmonic, ..Influence::default() }));
let w = move_weights(&m, &h, &Influence::default(), &Unconstrained).unwrap();
assert!(move_nodes(&m, &w, [f64::NAN, 0.0, 0.0], Guard::None, &Unconstrained).is_err());
assert!(move_nodes(&m, &w, [1e300, 0.0, 0.0], Guard::None, &Unconstrained).is_err());
assert!(move_nodes(&m, &BTreeMap::from([(1, 2.0)]), [0.0; 3], Guard::None, &Unconstrained).is_err());
assert!(move_nodes(&m, &w, [0.1; 3], Guard::NoInversion { min_quality: 1.5 }, &Unconstrained).is_err());
// A radius too small for the mesh is refused, never an overflow of the search grid.
assert!(bad(Influence { radius: Some(1e-300), ..Influence::default() }));
assert!(bad(Influence { centre: Some([-1e300, -1e300, 0.0]), radius: Some(1e-300), ..Influence::default() }));
let near = |r: f64| moving::nearest_within(&[[-1e300, 0.0, 0.0]], &BTreeMap::from([(1, [1e300, 0.0, 0.0])]), r);
assert!(near(1e-300).is_empty() && near(1.0).is_empty());
}
+11
View File
@@ -1033,6 +1033,17 @@ impl TetComplex {
if self.curve(vs[k], vs[l]).is_some() {
return Ok(false);
}
// Each neighbour gains a boundary face (both, when they are one tetrahedron); one left
// with every face on the boundary would float free of the mesh, joined to it by its
// vertices alone (a singular stiffness).
for m in [k, l] {
let n = ns[m];
let gained = if ns[k] == ns[l] { 2 } else { 1 };
let open = self.adj(n)?.iter().filter(|&&x| x == NONE).count();
if open + gained >= 4 {
return Ok(false);
}
}
let raw = self.journal.recording.then(|| Box::new(self.snapshot(&[t, ns[k], ns[l]], &vs)));
for m in [k, l] {
let n = ns[m];
+45 -5
View File
@@ -74,11 +74,14 @@ pub fn dihedral_cosines(x: &[P; 4]) -> [f64; 6] {
})
}
/// The quality objective: the smallest signed dihedral sine (1 is impossible; the regular
/// tetrahedron scores √(8/9) ≈ 0.943). Positive for a valid tetrahedron, 0 for a flat one,
/// negative for an inverted one (then the sine of largest magnitude, so worse inversions score
/// lower). With `sin θ_ij = 6V ℓ_ij / (2A_k · 2A_l)`, the extreme sine is found on squared
/// lengths and areas, with one square root.
/// The quality objective of a valid tetrahedron: the smaller of its smallest dihedral sine and
/// its weighted scaled Jacobian ([`JACOBIAN_WEIGHT`]; the regular tetrahedron scores
/// √(8/9) ≈ 0.943). The dihedral sine catches slivers and caps; it does not see needles, whose
/// dihedral angles can all be fine while a corner is nearly flat (Shewchuk 2002), and the scaled
/// Jacobian `√2 · 6V / (ℓ₁ℓ₂ℓ₃)` at the worst corner, the measure analysts check (the skill's
/// limit, [`crate::quality`]), does. 0 for a flat tetrahedron; for an inverted one the negative
/// sine of largest magnitude, so worse inversions score lower. Extremes are found on squared
/// lengths and areas, with one square root each.
pub fn quality(x: &[P; 4]) -> f64 {
let v6 = volume6(x);
if v6 == 0.0 || !v6.is_finite() {
@@ -95,9 +98,36 @@ pub fn quality(x: &[P; 4]) -> f64 {
pick = if v6 > 0.0 { pick.min(r) } else { pick.max(r) };
}
let q = v6 * pick.sqrt();
let q = if v6 > 0.0 { q.min(JACOBIAN_WEIGHT * scaled_jacobian(x, v6)) } else { q };
if q.is_finite() { q } else { 0.0 }
}
/// The weight of the scaled Jacobian in [`quality`]: the meshing skill's limit, a scaled
/// Jacobian of 0.3, counts as much as a 30° dihedral angle (sin 30°, the improvement's target,
/// [`super::improve::ImproveOptions::target`]), so the Jacobian decides only for tetrahedra near
/// that limit (needles) and the dihedral sine everywhere else.
pub const JACOBIAN_WEIGHT: f64 = 0.5 / 0.3;
/// The corners of a tetrahedron and the other three corners of each.
const CORNERS: [(usize, [usize; 3]); 4] = [(0, [1, 2, 3]), (1, [0, 2, 3]), (2, [0, 1, 3]), (3, [0, 1, 2])];
/// The squared product of the three edge lengths at each corner.
fn corner_products(x: &[P; 4]) -> [f64; 4] {
CORNERS.map(|(c, o)| {
o.iter().fold(1.0, |m, &j| {
let e = sub(x[j], x[c]);
m * dot(e, e)
})
})
}
/// The scaled Jacobian at the worst corner of a tetrahedron with six times the volume `v6`
/// (as [`crate::quality`] reports it).
fn scaled_jacobian(x: &[P; 4], v6: f64) -> f64 {
let worst = corner_products(x).iter().fold(0.0_f64, |m, &p| m.max(p));
std::f64::consts::SQRT_2 * v6 / worst.sqrt()
}
/// The smallest and largest dihedral angle in degrees (reports only).
pub fn dihedral_range(x: &[P; 4]) -> (f64, f64) {
let (s, c) = (dihedral_sines(x), dihedral_cosines(x));
@@ -221,6 +251,16 @@ mod tests {
assert!(circumcenter(&flat).is_none());
}
/// A needle: every dihedral angle above 20°, yet one corner nearly flat; the objective sees
/// it through the scaled Jacobian.
#[test]
fn a_needle_scores_low() {
let x = positive([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 0.866, 0.0], [0.5, 0.29, 12.0]]);
let (lo, hi) = dihedral_range(&x);
assert!(lo > 20.0 && hi < 120.0, "{lo} {hi}");
assert!(quality(&x) < 0.15 * JACOBIAN_WEIGHT, "{}", quality(&x));
}
#[test]
fn sliver_scores_low() {
let x = positive([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.01]]);
+38 -3
View File
@@ -140,6 +140,10 @@ pub struct TetQuality {
pub max_dihedral: f64,
pub min_radius_ratio: f64,
pub mean_radius_ratio: f64,
/// The smallest scaled Jacobian at the worst corner (as `mesh.quality` reports it). The
/// dihedral range does not bound it: a needle has every dihedral angle near 60° or 90° and a
/// nearly flat corner (#547).
pub min_scaled_jacobian: f64,
/// Tetrahedra with a dihedral angle below 10° or above 170°.
pub slivers: usize,
/// Tetrahedra not admissible by FieldCraft's validity criterion.
@@ -149,7 +153,7 @@ pub struct TetQuality {
/// The quality of tetrahedra given by corner coordinates, with the metrics `mesh.quality`
/// reports ([`crate::quality::element_quality`]).
pub fn tet_quality_stats(points: &[[f64; 3]], tets: &[[u32; 4]]) -> Result<TetQuality> {
let mut s = TetQuality { min_dihedral: 180.0, min_radius_ratio: 1.0, ..TetQuality::default() };
let mut s = TetQuality { min_dihedral: 180.0, min_radius_ratio: 1.0, min_scaled_jacobian: 1.0, ..TetQuality::default() };
let mut sum = 0.0;
for t in tets {
let p = |v: u32| points.get(v as usize).copied().ok_or_else(|| Error::Failed("quality: vertex id".into()));
@@ -159,6 +163,7 @@ pub fn tet_quality_stats(points: &[[f64; 3]], tets: &[[u32; 4]]) -> Result<TetQu
s.min_dihedral = s.min_dihedral.min(lo);
s.max_dihedral = s.max_dihedral.max(hi);
s.min_radius_ratio = s.min_radius_ratio.min(rr);
s.min_scaled_jacobian = s.min_scaled_jacobian.min(m(Metric::ScaledJacobian));
sum += rr;
s.slivers += usize::from(!(lo >= 10.0 && hi <= 170.0));
s.inadmissible += usize::from(q.validity() != Some(Validity::Valid));
@@ -167,6 +172,7 @@ pub fn tet_quality_stats(points: &[[f64; 3]], tets: &[[u32; 4]]) -> Result<TetQu
if tets.is_empty() {
s.min_dihedral = 0.0;
s.min_radius_ratio = 0.0;
s.min_scaled_jacobian = 0.0;
} else {
s.mean_radius_ratio = sum / tets.len() as f64;
}
@@ -559,6 +565,26 @@ pub fn quality_mesh(surface: &BoundarySurface, cdt: &TetMesh, analytic: Vec<Opti
}
}
/// The tetrahedra (1-based) that share none of their faces with another one, in a mesh of more
/// than one: each is a separate piece of boundary, joined to the mesh by vertices or an edge.
fn detached_tets(tets: &[[u32; 4]]) -> Vec<u32> {
if tets.len() < 2 {
return Vec::new();
}
let face = |t: &[u32; 4], k: usize| {
let mut f = [t[(k + 1) % 4], t[(k + 2) % 4], t[(k + 3) % 4]];
f.sort_unstable();
f
};
let mut count: std::collections::BTreeMap<[u32; 3], u32> = std::collections::BTreeMap::new();
for t in tets {
for k in 0..4 {
*count.entry(face(t, k)).or_default() += 1;
}
}
tets.iter().enumerate().filter(|(_, t)| (0..4).all(|k| count.get(&face(t, k)).copied().unwrap_or(0) < 2)).map(|(i, _)| i as u32 + 1).collect()
}
/// The constrained Delaunay tetrahedralization itself as a [`QualityMesh`] (no quality stage),
/// through the same validity gate: every tetrahedron admissible, the volume equal to what its
/// boundary encloses.
@@ -576,9 +602,18 @@ pub fn raw_mesh(surface: &BoundarySurface, cdt: &TetMesh) -> Result<QualityMesh>
Ok(QualityMesh { points: cdt.points.clone(), elements: cdt.tets.iter().map(|t| t.to_vec()).collect(), kind: ElementKind::Tet4, boundary, report })
}
/// The validity gate of a linear mesh: every tetrahedron admissible, and the volume equal to
/// what its boundary encloses within 1e-9 (no overlap, no gap).
/// The validity gate of a linear mesh: every tetrahedron admissible, joined to the others by a
/// face (when there are several), and the volume equal to what its boundary encloses within
/// 1e-9 (no overlap, no gap).
pub fn linear_gate(points: &[[f64; 3]], tets: &[[u32; 4]], boundary_volume: f64) -> Result<GateReport> {
let detached = detached_tets(tets);
if !detached.is_empty() {
let list = detached.iter().take(10).map(u32::to_string).collect::<Vec<_>>().join(", ");
return Err(Error::Failed(format!(
"mesh validity gate: {} tetrahedra share no face with the rest of the mesh ({list}): held by vertices alone, they would make the stiffness singular",
detached.len()
)));
}
let coords = |t: &[u32; 4]| -> Result<[[f64; 3]; 4]> {
let p = |v: u32| points.get(v as usize).copied().ok_or_else(|| Error::Failed("gate: vertex id".into()));
Ok([p(t[0])?, p(t[1])?, p(t[2])?, p(t[3])?])
+54 -1
View File
@@ -433,7 +433,17 @@ mod determinism {
let (p, t, f) = washer();
let m = mesh(&p, &t, &f);
assert!(m.elements.len() > 100, "{:?}", m.report);
assert_eq!(fingerprint(&m), 6_285_915_972_108_509_003, "{} tets, {} nodes", m.elements.len(), m.points.len());
// No needles either: the scaled Jacobian analysts check is above the skill's limit.
let sj = m
.elements
.iter()
.map(|e| {
let xyz: Vec<[f64; 3]> = e.iter().map(|&v| m.points[v as usize]).collect();
crate::quality::element_quality(m.kind, &xyz).unwrap().get(crate::quality::Metric::ScaledJacobian).unwrap()
})
.fold(f64::INFINITY, f64::min);
assert!(sj >= 0.3, "min scaled Jacobian {sj}");
assert_eq!(fingerprint(&m), 11_252_377_805_886_396_373, "{} tets, {} nodes", m.elements.len(), m.points.len());
}
/// Listing the triangles, their corners and the vertices in another order, and numbering
@@ -454,3 +464,46 @@ mod determinism {
}
}
}
/// A cap tetrahedron (two boundary faces of one B-rep face) whose two other neighbours each have
/// three boundary faces: deleting it would leave them joined to nothing by a face, floating free
/// (a singular stiffness: the dome-on-a-plate mesh on Linux, #438), so it stays.
#[test]
fn deleting_a_cap_never_leaves_a_neighbour_floating() {
let p: Vec<[f64; 3]> = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.3, 0.3, -1.0], [1.0, 1.0, 1.0]];
let pts: Vec<Point3> = p.iter().map(|c| Point3::try_from(*c).unwrap()).collect();
let positive = |t: [u32; 4]| if measure::volume6(&t.map(|v| pts[v as usize].coords())) > 0.0 { t } else { [t[1], t[0], t[2], t[3]] };
// The cap, and a tetrahedron on each of its faces 012 and 123.
let tets = [positive([0, 1, 2, 3]), positive([0, 1, 2, 4]), positive([1, 2, 3, 5])];
let key = |f: [u32; 3]| {
let mut f = f;
f.sort_unstable();
f
};
let mut boundary = Vec::new();
for (t, vs) in tets.iter().enumerate() {
for k in 0..4 {
let f = key(face_toward(*vs, k));
let shared = tets.iter().enumerate().any(|(u, ws)| u != t && (0..4).any(|j| key(face_toward(*ws, j)) == f));
if !shared {
boundary.push((t as u32, k, 0));
}
}
}
let mut cx = TetComplex::new(&p, vec![VertexKind::Corner; 6], &tets, &boundary, &[], 100, 100).unwrap();
assert!(!cx.delete_boundary_tet(0).unwrap(), "the cap was deleted, leaving its neighbours floating");
assert_eq!(cx.live_tets().count(), 3);
cx.validate().unwrap();
}
/// The validity gate refuses a tetrahedron that shares no face with the rest of the mesh, even
/// when the volume adds up: it says why instead of leaving the solver to find it singular.
#[test]
fn the_gate_refuses_a_detached_tetrahedron() {
let p = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 1.0, 1.0], [2.0, 1.0, 1.0], [1.0, 2.0, 1.0], [1.0, 1.0, 2.0]];
let tets = [[0, 1, 2, 3], [4, 5, 6, 7]];
let v = 2.0 / 6.0;
let e = super::linear_gate(&p, &tets, v).unwrap_err().to_string();
assert!(e.contains("share no face") && e.contains("(1, 2)"), "{e}");
assert!(super::linear_gate(&p, &tets[..1], v / 2.0).is_ok());
}
+278 -5
View File
@@ -16,7 +16,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::geometry::{Entity, GeometryRef};
use crate::{Error, Model, Result, context, finite, insert_new, not_in_use, positive, remove_existing, replace_existing, require};
use crate::{Error, Material, Model, Result, context, finite, insert_new, not_in_use, positive, remove_existing, replace_existing, require};
/// Most obstacle bodies in one domain.
pub const MAX_OBSTACLES: usize = 1000;
@@ -243,6 +243,177 @@ pub struct SurfaceBoundary {
pub condition: SurfaceCondition,
}
/// What a boundary does to the fluid's temperature.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum ThermalCondition {
/// No heat crosses it (every boundary, unless told otherwise); at an inlet the fluid enters
/// at the initial temperature, at an outlet it leaves at the temperature it has.
Adiabatic {},
/// Held at this temperature (at an inlet, the fluid enters at it).
Temperature { value: f64 },
/// A heat flux into the fluid (power per area; negative draws heat out).
Flux { value: f64 },
}
impl ThermalCondition {
pub fn name(&self) -> &'static str {
match self {
ThermalCondition::Adiabatic {} => "adiabatic",
ThermalCondition::Temperature { .. } => "temperature",
ThermalCondition::Flux { .. } => "flux",
}
}
fn validate(&self) -> Result<()> {
match self {
ThermalCondition::Adiabatic {} => Ok(()),
ThermalCondition::Temperature { value } | ThermalCondition::Flux { value } => finite(self.name(), *value).map(|_| ()),
}
}
}
impl Default for ThermalCondition {
fn default() -> Self {
ThermalCondition::Adiabatic {}
}
}
/// The thermal condition of each box face.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BoxThermalFaces {
pub x_min: ThermalCondition,
pub x_max: ThermalCondition,
pub y_min: ThermalCondition,
pub y_max: ThermalCondition,
pub z_min: ThermalCondition,
pub z_max: ThermalCondition,
}
impl BoxThermalFaces {
pub fn get(&self, face: BoxFace) -> &ThermalCondition {
match face {
BoxFace::XMin => &self.x_min,
BoxFace::XMax => &self.x_max,
BoxFace::YMin => &self.y_min,
BoxFace::YMax => &self.y_max,
BoxFace::ZMin => &self.z_min,
BoxFace::ZMax => &self.z_max,
}
}
pub fn get_mut(&mut self, face: BoxFace) -> &mut ThermalCondition {
match face {
BoxFace::XMin => &mut self.x_min,
BoxFace::XMax => &mut self.x_max,
BoxFace::YMin => &mut self.y_min,
BoxFace::YMax => &mut self.y_max,
BoxFace::ZMin => &mut self.z_min,
BoxFace::ZMax => &mut self.z_max,
}
}
}
/// A thermal condition on one named face of an obstacle body.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SurfaceThermalBoundary {
pub face: GeometryRef,
pub condition: ThermalCondition,
}
/// Heat in the fluid: a temperature field advected with the flow and diffused with the
/// material's diffusivity k / (ρ c), its conditions on the boundaries, and buoyancy.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FluidThermal {
/// The temperature everywhere at the start, and the reference of the buoyancy.
pub initial_temperature: f64,
/// Gravity for the buoyancy (Boussinesq: a force per mass of −β (T − T₀) g on the fluid);
/// zero for none. Separate from the domain's `acceleration`, which is a plain body force.
pub gravity: [f64; 3],
/// The fluid's volumetric thermal expansion coefficient β (per temperature); needed with
/// a gravity.
#[serde(skip_serializing_if = "Option::is_none")]
pub expansion: Option<f64>,
pub faces: BoxThermalFaces,
/// Conditions on named faces of the obstacles (adiabatic unless listed).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub surfaces: Vec<SurfaceThermalBoundary>,
}
impl Default for FluidThermal {
fn default() -> Self {
FluidThermal { initial_temperature: 0.0, gravity: [0.0; 3], expansion: None, faces: BoxThermalFaces::default(), surfaces: Vec::new() }
}
}
impl FluidThermal {
fn validate(&self, obstacles: &[String]) -> Result<()> {
finite("initial temperature", self.initial_temperature)?;
vector("gravity", self.gravity)?;
if let Some(b) = self.expansion {
finite("expansion coefficient", b)?;
if b < 0.0 {
return Err(Error::Invalid(format!("the expansion coefficient {b} is negative")));
}
}
if self.gravity != [0.0; 3] && self.expansion.is_none() {
return Err(Error::Invalid("buoyancy needs the fluid's expansion coefficient (`expansion`) with its gravity".into()));
}
for face in BoxFace::ALL {
self.faces.get(face).validate().map_err(|e| context("box face", face.name(), e))?;
}
if self.surfaces.len() > MAX_SURFACE_CONDITIONS {
return Err(Error::Limit(format!("a fluid domain holds at most {MAX_SURFACE_CONDITIONS} thermal surface conditions")));
}
for (i, s) in self.surfaces.iter().enumerate() {
s.condition.validate()?;
if !matches!(s.face.entity, Entity::Face(_)) {
return Err(Error::Invalid(format!("thermal conditions go on faces, not on the {}", s.face)));
}
if !obstacles.contains(&s.face.body) {
return Err(Error::Invalid(format!(
"the {} is not on an obstacle of the domain (list body `{}` in `obstacles`)",
s.face, s.face.body
)));
}
if self.surfaces.get(..i).is_some_and(|before| before.iter().any(|b| b.face == s.face)) {
return Err(Error::Invalid(format!("the {} has two thermal conditions", s.face)));
}
}
Ok(())
}
}
/// What a fluid domain needs of its material: a density and fluid behaviour, and with heat a
/// positive isotropic conductivity and specific heat (the diffusivity k / (ρ c)).
fn material_fits_fluid_domain(m: &Material, name: &str, d: &FluidDomain) -> Result<()> {
if m.fluid.is_none() {
return Err(Error::Invalid(format!("material `{name}` has no fluid behaviour (a viscosity)")));
}
if m.density.is_none() {
return Err(Error::Invalid(format!("material `{name}` has no density")));
}
if d.thermal.is_some() {
let thermal = m.thermal.as_ref();
match thermal.and_then(|t| t.conductivity) {
Some(crate::Directional::Isotropic(k)) if k > 0.0 => {}
Some(crate::Directional::Isotropic(_)) | None => {
return Err(Error::Invalid(format!("material `{name}` needs a positive isotropic conductivity for heat in the fluid")));
}
Some(crate::Directional::Orthotropic(_)) => {
return Err(Error::Invalid(format!("material `{name}`: a fluid's conductivity is isotropic")));
}
}
if !thermal.and_then(|t| t.specific_heat).is_some_and(|c| c > 0.0) {
return Err(Error::Invalid(format!("material `{name}` needs a positive specific heat for heat in the fluid")));
}
}
Ok(())
}
/// Where a boundary condition goes: a face of the box, or a named face of an obstacle.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
@@ -279,6 +450,9 @@ pub struct FluidDomain {
/// The chordal tolerance obstacles are triangulated with (default: a tenth of the spacing).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tolerance: Option<f64>,
/// Heat in the fluid (a temperature field, buoyancy); `None` for an isothermal flow.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thermal: Option<FluidThermal>,
}
fn is_zero(v: &[f64; 3]) -> bool {
@@ -346,6 +520,9 @@ impl FluidDomain {
return Err(Error::Invalid(format!("obstacle `{b}` is listed twice")));
}
}
if let Some(t) = &self.thermal {
t.validate(&self.obstacles).map_err(|e| context("thermal", "settings", e))?;
}
if self.surfaces.len() > MAX_SURFACE_CONDITIONS {
return Err(Error::Limit(format!("a fluid domain holds at most {MAX_SURFACE_CONDITIONS} surface conditions")));
}
@@ -373,6 +550,25 @@ impl FluidDomain {
}
/// Set the condition on one boundary: a box face, or a named face of an obstacle, which
/// Set one boundary's thermal condition (an adiabatic obstacle face removes its entry,
/// adiabatic being the default). Needs the domain's thermal settings; the caller validates
/// the result ([`Model::set_fluid_thermal_boundary`] does).
pub fn set_thermal_boundary(&mut self, at: FluidBoundary, condition: ThermalCondition) -> Result<()> {
let Some(t) = &mut self.thermal else {
return Err(Error::Invalid("the domain has no thermal settings (`thermal`), so no boundary has a thermal condition".into()));
};
match at {
FluidBoundary::Box(face) => *t.faces.get_mut(face) = condition,
FluidBoundary::Surface(face) => {
t.surfaces.retain(|s| s.face != face);
if condition != (ThermalCondition::Adiabatic {}) {
t.surfaces.push(SurfaceThermalBoundary { face, condition });
}
}
}
Ok(())
}
/// takes a wall or a moving wall (a wall there removes the entry, walls being the default).
/// The caller validates the result ([`Model::set_fluid_boundary`] does).
pub fn set_boundary(&mut self, at: FluidBoundary, condition: FaceCondition) -> Result<()> {
@@ -407,12 +603,26 @@ impl Model {
d.validate()?;
require(&self.materials, "material", &d.material)?;
match self.materials.get(&d.material) {
Some(m) if m.fluid.is_none() => Err(Error::Invalid(format!("material `{}` has no fluid behaviour (a viscosity)", d.material))),
Some(m) if m.density.is_none() => Err(Error::Invalid(format!("material `{}` has no density", d.material))),
_ => Ok(()),
Some(m) => material_fits_fluid_domain(m, &d.material, d),
None => Ok(()),
}
}
/// The fluid domains `material` must keep fitting when it is replaced.
pub(crate) fn check_material_fits_fluid_domains(&self, name: &str, material: &Material) -> Result<()> {
for (domain, d) in self.fluid_domains.iter().filter(|(_, d)| d.material == name) {
material_fits_fluid_domain(material, name, d).map_err(|e| context("fluid domain", domain, e))?;
}
Ok(())
}
/// Set the thermal condition on one boundary of a domain.
pub fn set_fluid_thermal_boundary(&mut self, name: &str, at: FluidBoundary, condition: ThermalCondition) -> Result<()> {
let mut d = self.fluid_domains.get(name).cloned().ok_or_else(|| Error::NotFound(format!("fluid domain `{name}`")))?;
d.set_thermal_boundary(at, condition).map_err(|e| context("fluid domain", name, e))?;
self.replace_fluid_domain(name, d)
}
pub fn add_fluid_domain(&mut self, name: &str, domain: FluidDomain) -> Result<()> {
self.check_fluid_domain(&domain).map_err(|e| context("fluid domain", name, e))?;
insert_new(&mut self.fluid_domains, "fluid domain", name, domain)
@@ -449,7 +659,7 @@ impl Model {
#[cfg(test)]
mod tests {
use super::*;
use crate::{Fluid, Material, Procedure, Step};
use crate::{Directional, Fluid, Material, Procedure, Step};
fn water() -> Material {
Material { density: Some(1.0e-9), fluid: Some(Fluid::Newtonian { viscosity: 1.0e-9 }), ..Material::default() }
@@ -473,9 +683,72 @@ mod tests {
initial_velocity: [0.0; 3],
acceleration: [0.0; 3],
tolerance: None,
thermal: None,
}
}
#[test]
fn thermal_settings_validate_and_need_the_materials_heat_properties() {
let mut m = Model::default();
m.add_material("Water", water()).unwrap();
let thermal = |f: &dyn Fn(&mut FluidThermal)| {
let mut d = channel();
let mut t = FluidThermal { initial_temperature: 20.0, ..FluidThermal::default() };
f(&mut t);
d.thermal = Some(t);
d
};
// heat in the fluid needs a conductivity and a specific heat on the material
assert!(m.add_fluid_domain("Flow", thermal(&|_| {})).is_err());
let mut hot = water();
hot.thermal = Some(crate::Thermal { conductivity: Some(Directional::Isotropic(0.6)), specific_heat: Some(4180.0), ..Default::default() });
m.replace_material("Water", hot.clone()).unwrap();
m.add_fluid_domain("Flow", thermal(&|_| {})).unwrap();
m.add_fluid_domain(
"Hot",
thermal(&|t| {
t.faces.y_min = ThermalCondition::Temperature { value: 50.0 };
t.gravity = [0.0, -9810.0, 0.0];
t.expansion = Some(2e-4);
}),
)
.unwrap();
for d in [
thermal(&|t| t.initial_temperature = f64::NAN),
thermal(&|t| t.gravity = [0.0, -1.0, 0.0]),
thermal(&|t| t.expansion = Some(-1.0)),
thermal(&|t| t.faces.x_min = ThermalCondition::Flux { value: f64::INFINITY }),
thermal(&|t| {
t.surfaces = vec![SurfaceThermalBoundary {
face: GeometryRef::face("Nope", "F1").unwrap(),
condition: ThermalCondition::Temperature { value: 1.0 },
}]
}),
] {
assert!(m.add_fluid_domain("Bad", d).is_err());
}
let mut aniso = hot;
aniso.thermal =
Some(crate::Thermal { conductivity: Some(Directional::Orthotropic([1.0; 3])), specific_heat: Some(1.0), ..Default::default() });
m.add_material("Oil", aniso.clone()).unwrap();
let mut d = thermal(&|_| {});
d.material = "Oil".into();
assert!(m.add_fluid_domain("Aniso", d).is_err(), "a fluid's conductivity is isotropic");
assert!(m.replace_material("Water", water()).is_err(), "the domains with heat keep needing the heat properties");
assert!(m.replace_material("Water", aniso).is_err());
// setting conditions
m.set_fluid_thermal_boundary("Flow", FluidBoundary::Box(BoxFace::XMin), ThermalCondition::Temperature { value: 80.0 }).unwrap();
assert_eq!(m.fluid_domain("Flow").unwrap().thermal.as_ref().unwrap().faces.x_min, ThermalCondition::Temperature { value: 80.0 });
assert!(m.set_fluid_thermal_boundary("Flow", FluidBoundary::Box(BoxFace::XMin), ThermalCondition::Flux { value: f64::NAN }).is_err());
let mut plain = channel();
plain.thermal = None;
m.add_fluid_domain("Plain", plain).unwrap();
assert!(
m.set_fluid_thermal_boundary("Plain", FluidBoundary::Box(BoxFace::XMin), ThermalCondition::Adiabatic {}).is_err(),
"no thermal settings"
);
}
#[test]
fn domains_validate_and_refer_to_a_fluid_material() {
let mut m = Model::default();
+4 -1
View File
@@ -66,7 +66,10 @@ pub use constraints::{Coupling, CouplingKind, ReferencePoint};
pub use contact::{Contact, ContactSurfaces, Friction, InitialPenetration};
pub use datums::{Datum, Partition};
pub use discrete::{Direction, Mass, PointMass, ResolvedDirection, ResolvedMass, ResolvedSpring, Spring};
pub use fluids::{BoxFace, BoxFaces, FaceCondition, FluidBoundary, FluidDomain, Profile, SurfaceBoundary, SurfaceCondition};
pub use fluids::{
BoxFace, BoxFaces, BoxThermalFaces, FaceCondition, FluidBoundary, FluidDomain, FluidThermal, Profile, SurfaceBoundary, SurfaceCondition,
SurfaceThermalBoundary, ThermalCondition,
};
pub use geometry::{BodyMesh, Classification, Entity, GeometryRef};
pub use loads::{Distribution, Load, centrifugal_acceleration};
pub use materials::{Damage, Directional, Elastic, Erosion, Fluid, Hardening, HardeningPoint, Hyperelastic, Material, Plastic, Thermal};
+1 -5
View File
@@ -510,11 +510,7 @@ impl Model {
pub fn replace_material(&mut self, name: &str, material: Material) -> Result<()> {
material.validate().map_err(|e| context("material", name, e))?;
if material.fluid.is_none()
&& let Some(d) = self.fluid_domains_of_material(name).next()
{
return Err(Error::Invalid(format!("material `{name}`: fluid domain `{d}` needs its fluid behaviour")));
}
self.check_material_fits_fluid_domains(name, &material)?;
replace_existing(&mut self.materials, "material", name, material)
}
+20 -1
View File
@@ -5,7 +5,7 @@
//! sharp iso-lines on any renderer, with no shader support: the GPU viewport and the CPU
//! rasterizer draw the same flat-coloured pieces.
use crate::{Rgb, Vec3};
use crate::{Rgb, SceneTri, Vec3};
/// Equal bands from `min` to `max`.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -94,6 +94,19 @@ pub fn split_triangle(p: [Vec3; 3], v: [f64; 3], bands: &Bands, out: &mut impl F
}
}
/// [`split_triangle`] with each piece fanned into scene triangles of `normal`, coloured by `color`
/// of its band (`None`: a value that is not a number).
pub fn banded(p: [Vec3; 3], v: [f64; 3], normal: Vec3, bands: &Bands, color: &impl Fn(Option<u32>) -> Rgb, out: &mut Vec<SceneTri>) {
split_triangle(p, v, bands, &mut |piece, band| {
let color = color(band);
for k in 1..piece.len().saturating_sub(1) {
if let (Some(&a), Some(&b), Some(&c)) = (piece.first(), piece.get(k), piece.get(k + 1)) {
out.push(SceneTri { p: [a, b, c], normal, color });
}
}
});
}
/// The part of a convex polygon where the value is at least (`keep_above`) or at most `level`
/// (Sutherland–Hodgman against one level).
fn clip(poly: &[(Vec3, f64)], level: f64, keep_above: bool) -> Vec<(Vec3, f64)> {
@@ -189,6 +202,12 @@ mod tests {
assert_eq!(k, None);
});
assert_eq!(n, 2);
// Fanned into scene triangles, keeping the area and the bands.
let mut tris = Vec::new();
banded(p, [0.0, 10.0, 0.0], Vec3::Z, &b, &|k| Rgb(k.map_or(0, |k| k as u8), 0, 0), &mut tris);
assert!((tris.iter().map(|t| area(&t.p)).sum::<f64>() - 50.0).abs() < 1e-9);
assert_eq!(tris.iter().map(|t| t.color.0).max(), Some(4));
assert!(tris.iter().all(|t| t.normal == Vec3::Z));
}
/// A clamped range puts the values above it in the "over" band, split exactly at the
+615
View File
@@ -0,0 +1,615 @@
//! Display data of a flow field on a regular grid of cells (a lattice Boltzmann result): a slice
//! plane through the cells carrying their values, a field sampled at a surface beside the cells,
//! and streamlines traced through the velocity field.
//!
//! Values are cell-centred; a cell with no value (inside an obstacle) holds NaN. A slice joins the
//! cell centres of the layers either side of its plane (interpolated linearly between them) into
//! triangles carrying their corners' values, so [`crate::contour::split_triangle`] bands them as it
//! bands a nodal field; the outermost centres are moved out to the domain's faces, so the slice
//! covers the whole domain. A triangle touching cells with no value takes the mean of its other
//! corners there, and one with none is left out (the obstacle is drawn there).
//!
//! Streamlines follow the trilinear interpolation of the cell velocities between the cell centres
//! (solid cells at rest: the no-slip value), integrated along their arc length by the midpoint
//! (second-order Runge–Kutta) method with a step of half a cell. Each stops where it leaves the
//! domain, enters a solid cell or reaches still fluid, or at its step cap; all of them together
//! stop at a cap on points, so hostile counts cost a bounded time.
use crate::{Aabb3, Vec3};
/// Most quadrilaterals a slice is made of: a larger layer is sampled every few cells.
pub const MAX_SLICE_QUADS: usize = 1 << 20;
/// Most streamlines traced at once.
pub const MAX_STREAMLINES: usize = 2_000;
/// Most steps of one streamline.
pub const MAX_STREAMLINE_STEPS: usize = 100_000;
/// Most points of all the streamlines traced at once.
pub const MAX_STREAMLINE_POINTS: usize = 2_000_000;
/// A streamline's step, in cells.
const STEP: f64 = 0.5;
/// A speed below this fraction of the field's largest is still fluid, where a streamline ends.
const STILL: f64 = 1e-6;
/// A regular grid of cubic cells: cell `(i, j, k)` spans `origin + spacing · (i, j, k)` to one
/// spacing further, and its values sit at index `i + nx (j + ny k)`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CellGrid {
pub origin: [f64; 3],
pub spacing: f64,
pub cells: [usize; 3],
}
impl CellGrid {
/// The number of cells, or `None` for a grid that cannot hold values: no cells, a spacing
/// that is not a positive number, an origin that is not finite, a count that overflows.
pub fn cell_count(&self) -> Option<usize> {
let ok = self.spacing.is_finite() && self.spacing > 0.0 && self.origin.iter().all(|c| c.is_finite()) && self.cells.iter().all(|&n| n > 0);
if !ok {
return None;
}
self.cells[0].checked_mul(self.cells[1])?.checked_mul(self.cells[2])
}
fn index(&self, [i, j, k]: [usize; 3]) -> usize {
i + self.cells[0] * (j + self.cells[1] * k)
}
/// The `(i, j, k)` of cell index `c`.
pub fn coords(&self, c: usize) -> [usize; 3] {
let [nx, ny, _] = self.cells.map(|n| n.max(1));
[c % nx, (c / nx) % ny, c / (nx * ny)]
}
/// The centre of cell `(i, j, k)`.
pub fn centre(&self, ijk: [usize; 3]) -> Vec3 {
Vec3::new(self.centre_along(0, ijk[0]), self.centre_along(1, ijk[1]), self.centre_along(2, ijk[2]))
}
fn centre_along(&self, axis: usize, i: usize) -> f64 {
self.start(axis) + (i as f64 + 0.5) * self.spacing
}
/// The near end of the grid along `axis` (its origin there).
pub fn start(&self, axis: usize) -> f64 {
self.origin.get(axis).copied().unwrap_or(0.0)
}
/// The far end of the grid along `axis`.
pub fn end(&self, axis: usize) -> f64 {
self.start(axis) + self.cells.get(axis).copied().unwrap_or(0) as f64 * self.spacing
}
/// The box the cells fill.
pub fn bounds(&self) -> Aabb3 {
let mut b = Aabb3::EMPTY;
b.add(Vec3::from(self.origin));
b.add(Vec3::new(self.end(0), self.end(1), self.end(2)));
b
}
/// The cell holding `p`, if it is in the grid.
pub fn cell_at(&self, p: Vec3) -> Option<usize> {
let mut ijk = [0usize; 3];
for (axis, slot) in ijk.iter_mut().enumerate() {
let n = self.cells.get(axis).copied().unwrap_or(0);
let t = (coord(p, axis) - self.start(axis)) / self.spacing;
if !(t >= 0.0 && t < n as f64) {
return None;
}
*slot = (t.floor() as usize).min(n.saturating_sub(1));
}
Some(self.index(ijk))
}
/// The cells around `p` for trilinear interpolation between their centres: per axis the lower
/// index, the upper one and the upper one's weight. `None` when `p` lies more than `margin`
/// cells outside the grid.
fn stencil(&self, p: Vec3, margin: f64) -> Option<[(usize, usize, f64); 3]> {
let mut out = [(0, 0, 0.0); 3];
for (axis, slot) in out.iter_mut().enumerate() {
let n = self.cells.get(axis).copied().unwrap_or(0);
let t = (coord(p, axis) - self.start(axis)) / self.spacing - 0.5;
if !(t >= -0.5 - margin && t <= n as f64 - 0.5 + margin) {
return None;
}
let t = t.clamp(0.0, n.saturating_sub(1) as f64);
let lo = t.floor() as usize;
let hi = (lo + 1).min(n.saturating_sub(1));
*slot = (lo, hi, t - lo as f64);
}
Some(out)
}
}
/// The eight cells of a trilinear stencil ([`CellGrid::stencil`]) with their weights.
fn corners(st: &[(usize, usize, f64); 3]) -> impl Iterator<Item = ([usize; 3], f64)> + '_ {
(0..8).map(move |corner| {
let mut ijk = [0; 3];
let mut w = 1.0;
for (axis, (lo, hi, f)) in st.iter().enumerate() {
let up = (corner >> axis) & 1 == 1;
ijk[axis] = if up { *hi } else { *lo };
w *= if up { *f } else { 1.0 - f };
}
(ijk, w)
})
}
/// The coordinate of `p` along `axis` (0, 1, 2: x, y, z).
fn coord(p: Vec3, axis: usize) -> f64 {
match axis {
0 => p.x,
1 => p.y,
_ => p.z,
}
}
/// A triangle carrying a value at each corner.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ValueTri {
pub p: [Vec3; 3],
pub v: [f64; 3],
}
/// A slice plane through a grid: its triangles with their corners' values, and its normal.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Slice {
pub normal: Vec3,
pub tris: Vec<ValueTri>,
}
/// The cell indices a slice samples along an axis of `n` cells, every `stride`-th and the last,
/// each with the coordinate its vertex is drawn at: the cell centre, the first and last moved out
/// to the grid's faces (`lo`, `hi`). One cell gives two vertices, one at each face.
fn samples(n: usize, stride: usize, lo: f64, hi: f64, centre: impl Fn(usize) -> f64) -> Vec<(usize, f64)> {
let mut out: Vec<(usize, f64)> = (0..n).step_by(stride.max(1)).map(|i| (i, centre(i))).collect();
if let Some(last) = n.checked_sub(1)
&& out.last().is_some_and(|(i, _)| *i != last)
{
out.push((last, centre(last)));
}
if out.len() == 1
&& let Some(&(i, _)) = out.first()
{
out.push((i, hi));
}
if let Some(first) = out.first_mut() {
first.1 = lo;
}
if let Some(last) = out.last_mut() {
last.1 = hi;
}
out
}
/// The slice of `values` (one per cell, NaN where there is none) on the plane normal to `axis`
/// (0, 1, 2: x, y, z) at coordinate `at`. `None` when the plane misses the grid, the axis is not
/// one, or the values do not match the grid.
pub fn slice(grid: &CellGrid, values: &[f64], axis: usize, at: f64) -> Option<Slice> {
let n = grid.cell_count()?;
if values.len() != n || axis > 2 || !at.is_finite() {
return None;
}
let lo = grid.start(axis);
if !(at >= lo && at <= grid.end(axis)) {
return None;
}
let (u, v) = ((axis + 1) % 3, (axis + 2) % 3);
let (nw, nu, nv) = (grid.cells.get(axis).copied()?, grid.cells.get(u).copied()?, grid.cells.get(v).copied()?);
// The layers either side of the plane, and the upper one's weight.
let t = ((at - lo) / grid.spacing - 0.5).clamp(0.0, nw.saturating_sub(1) as f64);
let k0 = t.floor() as usize;
let k1 = (k0 + 1).min(nw.saturating_sub(1));
let w = t - k0 as f64;
let value = |a: usize, b: usize| -> f64 {
let layer = |k: usize| {
let mut ijk = [0; 3];
ijk[axis] = k;
ijk[u] = a;
ijk[v] = b;
values.get(grid.index(ijk)).copied().filter(|x| x.is_finite()).unwrap_or(f64::NAN)
};
let (x0, x1) = (layer(k0), layer(k1));
match (x0.is_nan(), x1.is_nan()) {
(false, false) => x0 + (x1 - x0) * w,
// Beside an obstacle: the nearer layer's value, if it has one.
(false, true) if w < 0.5 => x0,
(true, false) if w >= 0.5 => x1,
_ => f64::NAN,
}
};
let stride = (nu.saturating_mul(nv) as f64 / MAX_SLICE_QUADS as f64).sqrt().ceil().max(1.0) as usize;
let su = samples(nu, stride, grid.start(u), grid.end(u), |i| grid.centre_along(u, i));
let sv = samples(nv, stride, grid.start(v), grid.end(v), |i| grid.centre_along(v, i));
let grid_values: Vec<Vec<f64>> = su.iter().map(|&(a, _)| sv.iter().map(|&(b, _)| value(a, b)).collect()).collect();
let at_vertex = |a: usize, b: usize| -> Option<(Vec3, f64)> {
let (cu, cv) = (su.get(a)?.1, sv.get(b)?.1);
let mut c = [0.0; 3];
c[axis] = at;
c[u] = cu;
c[v] = cv;
Some((Vec3::from(c), *grid_values.get(a)?.get(b)?))
};
let mut tris = Vec::new();
for a in 0..su.len().saturating_sub(1) {
for b in 0..sv.len().saturating_sub(1) {
let (Some(c00), Some(c10), Some(c11), Some(c01)) = (at_vertex(a, b), at_vertex(a + 1, b), at_vertex(a + 1, b + 1), at_vertex(a, b + 1))
else {
continue;
};
let corners = [c00, c10, c11, c01];
let (sum, count) = corners.iter().filter(|c| !c.1.is_nan()).fold((0.0, 0usize), |(s, n), c| (s + c.1, n + 1));
if count == 0 {
continue;
}
let mean = sum / count as f64;
let [p0, p1, p2, p3] = corners.map(|c| c.0);
let [v0, v1, v2, v3] = corners.map(|c| if c.1.is_nan() { mean } else { c.1 });
tris.push(ValueTri { p: [p0, p1, p2], v: [v0, v1, v2] });
tris.push(ValueTri { p: [p0, p2, p3], v: [v0, v2, v3] });
}
}
let mut normal = [0.0; 3];
normal[axis] = 1.0;
Some(Slice { normal: Vec3::from(normal), tris })
}
/// The value of `values` (one per cell, NaN where there is none) at `p`, interpolated
/// trilinearly between the cell centres around it from those with a value; with none of them, the
/// nearest cell with a value within two cells. NaN outside the grid (more than a cell beyond it),
/// with nothing near, or when the values do not match the grid. For colouring an obstacle's
/// surface by the fluid beside it.
pub fn sample(grid: &CellGrid, values: &[f64], p: Vec3) -> f64 {
if grid.cell_count() != Some(values.len()) {
return f64::NAN;
}
let Some(st) = grid.stencil(p, 1.0) else { return f64::NAN };
let at = |ijk: [usize; 3]| values.get(grid.index(ijk)).copied().filter(|x| x.is_finite());
let (mut sum, mut weight) = (0.0, 0.0);
for (ijk, w) in corners(&st) {
if let Some(x) = at(ijk).filter(|_| w > 0.0) {
sum += w * x;
weight += w;
}
}
if weight > 1e-9 {
return sum / weight;
}
// The nearest cell with a value, two cells around the nearest centre at most.
let near = st.map(|(lo, hi, f)| if f < 0.5 { lo } else { hi });
let span = |axis: usize| {
let n = near.get(axis).copied().unwrap_or(0);
n.saturating_sub(2)..=(n + 2).min(grid.cells.get(axis).copied().unwrap_or(1).saturating_sub(1))
};
let mut best: Option<(f64, f64)> = None;
for k in span(2) {
for j in span(1) {
for i in span(0) {
let Some(x) = at([i, j, k]) else { continue };
let d = grid.centre([i, j, k]).dist(p);
if best.is_none_or(|(bd, _)| d < bd) {
best = Some((d, x));
}
}
}
}
best.map_or(f64::NAN, |(_, x)| x)
}
/// A traced streamline: its points from the seed downstream and the speed at each.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Streamline {
pub points: Vec<Vec3>,
pub speed: Vec<f64>,
}
/// The velocity at `p` interpolated trilinearly between the cell centres (solid cells at rest);
/// `None` outside the grid.
fn velocity_at(grid: &CellGrid, velocity: &[[f32; 3]], solid: &[bool], p: Vec3) -> Option<Vec3> {
let st = grid.stencil(p, 0.0)?;
let mut u = Vec3::ZERO;
for (ijk, w) in corners(&st) {
let c = grid.index(ijk);
if w == 0.0 || solid.get(c).copied().unwrap_or(false) {
continue;
}
let Some(v) = velocity.get(c) else { continue };
let v = Vec3::new(f64::from(v[0]), f64::from(v[1]), f64::from(v[2]));
if v.is_finite() {
u = u + v * w;
}
}
Some(u)
}
/// Streamlines of `velocity` (one per cell; `solid` flags the cells inside obstacles, or is empty)
/// from each seed (at most [`MAX_STREAMLINES`]) downstream, each at most `max_steps` steps of half
/// a cell (capped at [`MAX_STREAMLINE_STEPS`]), all together at most [`MAX_STREAMLINE_POINTS`]
/// points. A seed outside the fluid gives no streamline; nothing is traced when the fields do not
/// match the grid.
pub fn streamlines(grid: &CellGrid, velocity: &[[f32; 3]], solid: &[bool], seeds: &[Vec3], max_steps: usize) -> Vec<Streamline> {
let Some(n) = grid.cell_count() else { return Vec::new() };
if velocity.len() != n || !(solid.is_empty() || solid.len() == n) {
return Vec::new();
}
let fastest =
velocity.iter().map(|v| Vec3::new(f64::from(v[0]), f64::from(v[1]), f64::from(v[2])).len()).filter(|s| s.is_finite()).fold(0.0, f64::max);
if !(fastest > 0.0) {
return Vec::new();
}
let still = STILL * fastest;
let h = STEP * grid.spacing;
// The flow's direction and speed at a point in the fluid.
let dir = |p: Vec3| -> Option<(Vec3, f64)> {
let cell = grid.cell_at(p)?;
if solid.get(cell).copied().unwrap_or(false) {
return None;
}
let u = velocity_at(grid, velocity, solid, p)?;
let s = u.len();
(s > still).then(|| (u * (1.0 / s), s))
};
let max_steps = max_steps.min(MAX_STREAMLINE_STEPS);
let mut budget = MAX_STREAMLINE_POINTS;
let mut out = Vec::new();
for &seed in seeds.iter().take(MAX_STREAMLINES) {
if budget == 0 {
break;
}
let Some((mut d, s)) = dir(seed) else { continue };
let mut line = Streamline { points: vec![seed], speed: vec![s] };
budget -= 1;
let mut p = seed;
for _ in 0..max_steps {
if budget == 0 {
break;
}
let Some((mid, _)) = dir(p + d * (0.5 * h)) else { break };
let q = p + mid * h;
let Some((next, s)) = dir(q) else { break };
line.points.push(q);
line.speed.push(s);
budget -= 1;
(p, d) = (q, next);
}
if line.points.len() >= 2 {
out.push(line);
}
}
out
}
/// `count` seeds (at most [`MAX_STREAMLINES`]) on a lattice across the grid's face at the low
/// end of `axis` (`high`: the high end), half a cell inside it: over the solid cells seen along the
/// axis, the box around them grown by half its size on every side and kept on the face, so the
/// streamlines pass over and around the obstacles; over the whole face without solid cells.
/// Empty for an axis that is not one or a grid that holds no cells.
pub fn face_seeds(grid: &CellGrid, solid: &[bool], axis: usize, high: bool, count: usize) -> Vec<Vec3> {
let Some(n) = grid.cell_count() else { return Vec::new() };
let count = count.min(MAX_STREAMLINES);
if axis > 2 || count == 0 {
return Vec::new();
}
let (u, v) = ((axis + 1) % 3, (axis + 2) % 3);
let face = |a: usize| (grid.start(a), grid.end(a));
let (mut ru, mut rv) = (face(u), face(v));
// The solid cells' extent across the face, by cell index.
let mut box_ = [usize::MAX, 0, usize::MAX, 0];
for (c, _) in solid.iter().enumerate().take(n).filter(|(_, s)| **s) {
let ijk = grid.coords(c);
let (a, b) = (ijk[u], ijk[v]);
box_ = [box_[0].min(a), box_[1].max(a), box_[2].min(b), box_[3].max(b)];
}
if box_[0] <= box_[1] {
let grow = |(lo, hi): (f64, f64), first: usize, last: usize, axis: usize| {
let a = grid.start(axis) + first as f64 * grid.spacing;
let b = grid.start(axis) + (last + 1) as f64 * grid.spacing;
let m = 0.5 * (b - a);
((a - m).max(lo), (b + m).min(hi))
};
ru = grow(ru, box_[0], box_[1], u);
rv = grow(rv, box_[2], box_[3], v);
}
let (wu, wv) = (ru.1 - ru.0, rv.1 - rv.0);
if !(wu > 0.0 && wv > 0.0) {
return Vec::new();
}
let cu = ((count as f64 * wu / wv).sqrt().round() as usize).clamp(1, count);
let cv = (count / cu).max(1);
let at = if high { grid.end(axis) - 0.5 * grid.spacing } else { grid.start(axis) + 0.5 * grid.spacing };
let mut out = Vec::with_capacity(cu * cv);
for a in 0..cu {
for b in 0..cv {
let mut c = [0.0; 3];
c[axis] = at;
c[u] = ru.0 + (a as f64 + 0.5) / cu as f64 * wu;
c[v] = rv.0 + (b as f64 + 0.5) / cv as f64 * wv;
out.push(Vec3::from(c));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn grid(n: [usize; 3]) -> CellGrid {
CellGrid { origin: [0.0; 3], spacing: 1.0, cells: n }
}
/// A field linear in x (the value is the cell centre's x) slices to a plane with exact values
/// at its corners, covering the whole domain.
#[test]
fn a_slice_covers_the_domain_and_interpolates() {
let g = grid([4, 3, 2]);
let values: Vec<f64> = (0..g.cell_count().unwrap()).map(|c| (c % 4) as f64 + 0.5).collect();
let s = slice(&g, &values, 2, 1.0).unwrap();
assert_eq!(s.normal, Vec3::Z);
assert_eq!(s.tris.len(), 2 * 3 * 2, "4 × 3 samples, 3 × 2 quads");
let mut b = Aabb3::EMPTY;
for t in &s.tris {
for (p, v) in t.p.iter().zip(t.v) {
b.add(*p);
assert_eq!(p.z, 1.0);
// Inside, the value is x; at the faces, the outer cells' values.
let expect = p.x.clamp(0.5, 3.5);
assert!((v - expect).abs() < 1e-12, "{p:?} {v}");
}
}
assert_eq!((b.min, b.max), (Vec3::new(0.0, 0.0, 1.0), Vec3::new(4.0, 3.0, 1.0)));
// Between two layers the values are interpolated: z-dependent field, plane at z = 1.
let vz: Vec<f64> = (0..g.cell_count().unwrap()).map(|c| (c / 12) as f64 * 10.0).collect();
let s = slice(&g, &vz, 2, 1.0).unwrap();
assert!(s.tris.iter().all(|t| t.v.iter().all(|v| (v - 5.0).abs() < 1e-12)));
// Along x and y too; one cell along an axis still makes a quad across it.
assert!(slice(&g, &values, 0, 2.0).is_some_and(|s| s.normal == Vec3::X && s.tris.len() == 2 * 2));
assert!(slice(&grid([1, 1, 1]), &[3.0], 1, 0.5).is_some_and(|s| s.tris.len() == 2));
}
/// Cells with no value: a quad touching some takes its other corners' mean, one with none is
/// left out; hostile planes and fields are refused.
#[test]
fn a_slice_skips_solid_cells_and_refuses_bad_input() {
let g = grid([3, 3, 1]);
let mut values = vec![1.0; 9];
values[4] = f64::NAN;
let s = slice(&g, &values, 2, 0.5).unwrap();
assert_eq!(s.tris.len(), 8);
assert!(s.tris.iter().all(|t| t.v == [1.0; 3]));
let all_solid = slice(&g, &[f64::NAN; 9], 2, 0.5).unwrap();
assert!(all_solid.tris.is_empty());
for (axis, at) in [(3, 0.5), (2, -0.1), (2, 1.1), (2, f64::NAN), (2, f64::INFINITY)] {
assert!(slice(&g, &values, axis, at).is_none(), "{axis} {at}");
}
assert!(slice(&g, &values[..8], 2, 0.5).is_none());
let bad = [
CellGrid { spacing: 0.0, ..g },
CellGrid { spacing: f64::NAN, ..g },
CellGrid { origin: [f64::INFINITY, 0.0, 0.0], ..g },
CellGrid { cells: [0, 3, 1], ..g },
CellGrid { cells: [usize::MAX, usize::MAX, 2], ..g },
];
for b in bad {
assert!(slice(&b, &values, 2, 0.5).is_none(), "{b:?}");
assert!(sample(&b, &values, Vec3::ZERO).is_nan());
assert!(streamlines(&b, &[[1.0, 0.0, 0.0]; 9], &[], &[Vec3::new(0.5, 0.5, 0.5)], 10).is_empty());
assert!(face_seeds(&b, &[], 0, false, 10).is_empty());
}
}
/// A huge layer is sampled every few cells, keeping its extent.
#[test]
fn a_large_slice_is_sampled() {
let g = grid([2048, 1024, 1]);
let values = vec![1.0; g.cell_count().unwrap()];
let s = slice(&g, &values, 2, 0.5).unwrap();
assert!(s.tris.len() <= 2 * MAX_SLICE_QUADS, "{}", s.tris.len());
let xs = s.tris.iter().flat_map(|t| t.p).map(|p| p.x).fold(0.0, f64::max);
assert_eq!(xs, 2048.0);
}
/// Sampling beside a solid block takes the fluid cells' values only.
#[test]
fn sampling_beside_solid_cells_reads_the_fluid() {
let g = grid([4, 1, 1]);
let values = [f64::NAN, f64::NAN, 2.0, 4.0];
// On the block's face at x = 2: cells 1 (solid) and 2.
assert_eq!(sample(&g, &values, Vec3::new(2.0, 0.5, 0.5)), 2.0);
assert_eq!(sample(&g, &values, Vec3::new(3.0, 0.5, 0.5)), 3.0);
// Deep inside the block, the nearest fluid cell; beyond the grid, nothing.
assert_eq!(sample(&g, &values, Vec3::new(0.2, 0.5, 0.5)), 2.0);
assert!(sample(&g, &values, Vec3::new(9.0, 0.5, 0.5)).is_nan());
assert!(sample(&g, &[f64::NAN; 4], Vec3::new(1.0, 0.5, 0.5)).is_nan());
assert!(sample(&g, &values, Vec3::new(f64::NAN, 0.5, 0.5)).is_nan());
}
/// A uniform flow along x carries a streamline straight to the outlet in half-cell steps; a
/// solid cell stops it; still fluid and seeds outside give none.
#[test]
fn streamlines_follow_the_flow() {
let g = grid([10, 3, 3]);
let n = g.cell_count().unwrap();
let u = vec![[2.0f32, 0.0, 0.0]; n];
let seed = Vec3::new(0.5, 1.5, 1.5);
let lines = streamlines(&g, &u, &[], &[seed], 1000);
assert_eq!(lines.len(), 1);
let l = &lines[0];
assert!(l.points.iter().all(|p| (p.y - 1.5).abs() < 1e-12 && (p.z - 1.5).abs() < 1e-12));
let last = *l.points.last().unwrap();
assert!(last.x > 9.4 && last.x < 10.0, "{last:?}");
assert_eq!(l.points.len(), 19);
assert!(l.speed.iter().all(|s| (s - 2.0).abs() < 1e-6));
// A step cap.
assert_eq!(streamlines(&g, &u, &[], &[seed], 3)[0].points.len(), 4);
// A solid cell across the way at x = 5.
let mut solid = vec![false; n];
solid[g.index([5, 1, 1])] = true;
let blocked = streamlines(&g, &u, &solid, &[seed], 1000);
assert!(blocked[0].points.iter().all(|p| p.x < 5.0), "{:?}", blocked[0].points.last());
// Outside, in a solid cell, in still fluid: no streamline.
let none = [Vec3::new(-1.0, 1.5, 1.5), Vec3::new(5.5, 1.5, 1.5), Vec3::new(f64::NAN, 0.0, 0.0)];
assert!(streamlines(&g, &u, &solid, &none, 1000).is_empty());
assert!(streamlines(&g, &vec![[0.0; 3]; n], &[], &[seed], 1000).is_empty());
assert!(streamlines(&g, &u[..5], &[], &[seed], 1000).is_empty());
assert!(streamlines(&g, &u, &solid[..5], &[seed], 1000).is_empty());
}
/// A rigid rotation about z: streamlines are circles, the midpoint rule keeps their radius.
#[test]
fn streamlines_of_a_vortex_close() {
let g = CellGrid { origin: [-10.0, -10.0, 0.0], spacing: 0.5, cells: [40, 40, 2] };
let u: Vec<[f32; 3]> = (0..g.cell_count().unwrap())
.map(|c| {
let p = g.centre([c % 40, (c / 40) % 40, c / 1600]);
[-p.y as f32, p.x as f32, 0.0]
})
.collect();
let lines = streamlines(&g, &u, &[], &[Vec3::new(5.0, 0.0, 0.5)], 2000);
let l = &lines[0];
assert_eq!(l.points.len(), 2001, "a closed streamline runs to its step cap");
for p in &l.points {
assert!(((p.x * p.x + p.y * p.y).sqrt() - 5.0).abs() < 0.05, "{p:?}");
}
}
/// Hostile counts are capped: seeds, steps and points.
#[test]
fn streamline_work_is_bounded() {
let g = grid([64, 64, 64]);
let n = g.cell_count().unwrap();
let u = vec![[0.0f32, 0.0, 1.0]; n];
let seeds = face_seeds(&g, &[], 2, false, usize::MAX);
assert_eq!(seeds.len(), 44 * 45, "capped at {MAX_STREAMLINES}");
assert!(seeds.len() <= MAX_STREAMLINES);
let lines = streamlines(&g, &u, &[], &seeds, usize::MAX);
let points: usize = lines.iter().map(|l| l.points.len()).sum();
assert!(points <= MAX_STREAMLINE_POINTS);
assert!(lines.iter().all(|l| l.points.len() == 127), "each runs up the 64 cells in 126 steps");
}
/// Seeds cover the face, or the obstacle's shadow on it grown by half its size each way.
#[test]
fn seeds_lie_upstream_of_the_obstacle() {
let g = grid([10, 10, 10]);
let all = face_seeds(&g, &[], 0, false, 25);
assert_eq!(all.len(), 25);
assert!(all.iter().all(|p| p.x == 0.5 && (0.0..10.0).contains(&p.y) && (0.0..10.0).contains(&p.z)));
assert_eq!(all[0], Vec3::new(0.5, 1.0, 1.0));
// A 2 × 2 block of cells at y, z in [4, 6]: seeds over y, z in [3, 7].
let mut solid = vec![false; 1000];
for (j, k) in [(4, 4), (4, 5), (5, 4), (5, 5)] {
solid[g.index([7, j, k])] = true;
}
let near = face_seeds(&g, &solid, 0, false, 16);
assert_eq!(near.len(), 16);
assert!(near.iter().all(|p| (3.0..=7.0).contains(&p.y) && (3.0..=7.0).contains(&p.z)), "{near:?}");
let high = face_seeds(&g, &solid, 0, true, 4);
assert!(high.iter().all(|p| p.x == 9.5));
assert!(face_seeds(&g, &solid, 3, false, 4).is_empty());
assert!(face_seeds(&g, &solid, 0, false, 0).is_empty());
// A wide face gets more seeds across its width.
let wide = face_seeds(&grid([1, 40, 10]), &[], 0, false, 16);
assert_eq!(wide.len(), 8 * 2);
}
}
+3 -1
View File
@@ -1,7 +1,8 @@
//! Display data for FieldCraft, with no GPU dependency: what a finite element model looks like
//! on screen ([`DisplayMesh`]: the visible faces, element edges, line elements and nodes), an
//! orbit camera with perspective or orthographic projection, picking ([`pick`]: what a ray through
//! a pixel hits), and a CPU rasterizer for headless snapshots. The desktop viewport draws the
//! a pixel hits), flow fields on a grid of cells ([`flow`]: slices, surface values and
//! streamlines), and a CPU rasterizer for headless snapshots. The desktop viewport draws the
//! same [`Scene`] on the GPU.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -9,6 +10,7 @@
mod camera;
pub mod contour;
mod display;
pub mod flow;
mod font;
pub mod glyphs;
pub mod pick;
+70 -5
View File
@@ -534,11 +534,34 @@ impl Reduction {
});
// A tie whose secondary node is held already: what is left of the row must be a
// constraint of its own, not the difference of two near copies of one.
if row.source == Source::Tie
&& intended.is_none()
&& let Some(&(_, c)) = largest
&& c.abs() < NEARLY_REPEATED * gross
{
let nearly_repeated = row.source == Source::Tie && intended.is_none() && largest.is_some_and(|t| t.1.abs() < NEARLY_REPEATED * gross);
// Held by a prescribed value (the secondary surface meeting a boundary condition or an
// axis-aligned symmetry plane: a crosspoint), the row can only constrain main degrees
// of freedom, and nearly repeated, what it adds is the mortar condition of a node
// whose motion is already given: its weights on main nodes off the boundary do not
// vanish (the dual basis spreads over the node's faces), so after the substitutions
// a small but real constraint is left, not round-off (1.3e-6 of the coefficients that
// cancelled to give it where a tet face meets a symmetry plane, #540; round-off would
// be ~1e-14 there). Eliminating it divides by that smallness; dropping it leaves the
// tie off by that fraction at the crosspoint, below the discretization error. So it
// is dropped, with the same threshold that calls it a near repeat. A secondary degree
// of freedom another constraint already eliminated (a node secondary in two ties, a
// tie and a coupling) is a real conflict and still refused (#389).
if nearly_repeated && state.get(row.pivot) == Some(&State::Fixed) {
// Still consistent with the prescribed values, or a contradiction.
let value = |t: &(usize, f64)| t.1 * values.get(t.0).copied().unwrap_or(0.0);
let residual: f64 = terms.iter().filter(|t| state.get(t.0) == Some(&State::Fixed)).map(value).sum();
let size: f64 = terms.iter().map(|t| value(t).abs()).sum();
if residual.abs() > 1e-9 * size.max(f64::MIN_POSITIVE) && residual.abs() > 0.0 {
return Err(Error::Invalid(format!(
"{} `{}` contradicts the prescribed values (boundary conditions or couplings hold its nodes in a way it cannot follow)",
row.source.what(),
row.coupling
)));
}
continue;
}
if nearly_repeated {
let mut others: Vec<String> = row
.terms
.iter()
@@ -858,6 +881,48 @@ impl Reduction {
mod tests {
use super::*;
fn tie(terms: Vec<(usize, f64)>, pivot: usize) -> Row<'static> {
Row { terms, pivot, coupling: "glue", source: Source::Tie }
}
/// Dofs `held` prescribed, dof 1 at `one` and the others at 0.
fn reduce(rows: &[Row], held: &[usize], one: f64) -> Result<Reduction> {
let layout = DofLayout::uniform(0..2, DofSet::TRANSLATIONS);
let budget = Budget { max_bytes: usize::MAX, pattern_entries: 0, matrices: 1 };
Reduction::new(
&layout,
std::iter::empty(),
&[0, 1],
rows,
|active| Ok((0..active.len()).map(|d| held.contains(&d).then_some(if d == 1 { one } else { 0.0 })).collect()),
budget,
)
}
/// A tie row of a secondary degree of freedom held by a boundary condition (dof 1), which
/// after substituting an earlier tie row (dof 0 following dofs 2 and 3) nearly repeats it, is
/// dropped (a crosspoint); dof 3 stays free.
#[test]
fn a_tie_row_on_a_held_degree_of_freedom_is_dropped() {
let rows = [tie(vec![(0, 1.0), (2, -0.5), (3, -0.5)], 0), tie(vec![(1, 1.0), (0, -1.0), (2, 0.5), (3, 0.5 - 1e-7)], 1)];
let r = reduce(&rows, &[1, 2], 0.0).unwrap();
assert_eq!(r.neq(), 3, "dofs 3, 4, 5 free");
}
/// Nearly repeated but inconsistent: on a secondary degree of freedom another tie already
/// eliminated (a node secondary in two ties) it is refused (#389), and on a held one whose
/// prescribed values it contradicts too.
#[test]
fn a_near_repeat_on_an_eliminated_degree_of_freedom_is_refused() {
let rows = [tie(vec![(0, 1.0), (2, -0.5), (3, -0.5)], 0), tie(vec![(0, 1.0), (2, -0.5), (3, -0.5 - 1e-7)], 0)];
let e = reduce(&rows, &[2], 0.0).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("almost alike"), "{e}");
// Dof 1 held at 1 and dof 2 at 0 while the row says dof 1 follows them.
let rows = [tie(vec![(0, 1.0), (2, -0.5), (3, -0.5)], 0), tie(vec![(1, 1.0), (0, -1.0), (2, 0.5), (3, 0.5 - 1e-7)], 1)];
let e = reduce(&rows, &[1, 2], 1.0).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("contradicts"), "{e}");
}
#[test]
fn reduced_matrix_budget_refuses_byte_counts_that_overflow() {
for matrices in [1, 2] {
+4 -25
View File
@@ -28,7 +28,7 @@ const MIN_SCALED_JACOBIAN: f64 = 0.3;
const MAX_EDGE_RATIO: f64 = 10.0;
/// The mesh's volume against the geometry's (§6.1): a larger difference is a missing cell or a
/// mesh too coarse for the curvature.
const MAX_VOLUME_ERROR: f64 = 0.02;
const MAX_VOLUME_ERROR: f64 = 0.01;
/// The most marked elements relaxed one by one; more than this is a clustered failure (the
/// blocks are wrong, §6.3), which relaxing does not fix.
const MAX_RELAXED: usize = 20;
@@ -52,38 +52,17 @@ const PLAIN_CUP: &str = r#"[
/// Why a part fails the skill's limits today, read from its numbers and its section picture.
/// Shown only while the part fails.
const CAUSES: &[(&str, &str)] = &[
(
"bent-tube",
"the plan's default size (1/25 of the diagonal) puts six elements round the Ø10 tube: the swept section is a hexagon and loses 17 % of the volume. Size from the tube's radius (§5), not the part's diagonal.",
),
(
"filleted-bracket",
"no structured method takes the whole body (the plan has no cuts for the fillet beside the holes), and tet10 at the plan's size leaves slivers (min SJ 0.04, 1709 marked). The same part partitioned into mapped cells (`filleted-bracket-blocks`) passes: partition (§3.4).",
),
(
"loft-transition",
"the lofted sides are swept one to one, and the elements where the section changes shape are skewed (min SJ 0.19). Relaxing the four marked elements did not raise it: the blocks are wrong, not the nodes (§6.3). A loft has no template yet (`unmapped`).",
),
(
"pinhole-block",
"the plan's cuts run beside the small holes' frames and leave strips of thin elements along the cut planes (red in the picture, edge ratio 18): 1680 marked, a clustered failure. §4: offset a cut one element out of the feature.",
),
(
"sharp-wedge",
"the wedge's sharp edge is swept as hex, so every element tapers to it (min SJ 0.01, edge ratio 150). §4: a column of wedge6 at the edge; the plan has no wedge template.",
"the wedge's sharp edge is swept as hex, so every element tapers to it (min SJ 0.01, edge ratio 150). A column of wedge6 at the edge (§4) raises the SJ but not the ratio: any element with a corner on a 2.9° edge has sides at least 1/tan 2.9° ≈ 20 times its width there, twice the limit. The edge has to be blunted (defeatured) or the limit waived along it.",
),
("sphere-on-plate", "no structured method takes the plate with the sunk sphere, and tet10 leaves slivers (min SJ 0.12, 662 marked)."),
(
"analytic-sphere",
"the sphere template meshes it cleanly, but hex8 faces chord the sphere at the plan's size and the mesh is 3 % short of its volume: refine, or hex20 (`order: 2`).",
),
(
"analytic-torus",
"the revolve is clean, but hex8 faces chord the torus at the plan's size and the mesh is 4.7 % short of its volume: refine, or hex20.",
),
(
"crank-arm",
"the edge ratio is 10.5 in the nine elements at the square bore's R1.5 corners, just over the limit. Relaxing each raised its region's edge ratio, so each was undone (§6.3). `mesh.plan.apply` did not find the y = 0 symmetry plane; the sample meshes the partitioned half and mirrors it.",
"no structured method takes a sphere (it has no face to sweep from or axis to revolve about), so the plan does not apply and the fallback's sphere template meshes it at the plan's size (2.45 mm, which keeps a sphere's chords to 0.75 % on paper), 1.1 % short of the volume: the template's elements at the cube's eight corners span more of the sphere than the size. A sphere template in the plan (§3) would size its caps; hex20 (`order: 2`) closes it too.",
),
(
"cross-cylinders",
@@ -518,7 +497,7 @@ with (a set on them finds nothing); `marked` the elements failing the limits bef
relaxing, and what relaxing changed; ¹ a tet mesh has no edge ratio: its largest aspect ratio\n\
stands in; `duplicates` coincident nodes and cracks; `geometry.check` its findings at the size;\n\
`verdict` pass when the minimum scaled Jacobian and the largest edge ratio meet the limits and\n\
the volume is within 2 % (`pass (fallback)` when a fallback meshed it).\n\
the volume is within 1 % (`pass (fallback)` when a fallback meshed it).\n\
The section pictures cut each part through the middle of its longest side, marked elements red.\n\
Times are wall clock on the machine that ran it.\n\n## Results\n\n",
);
+33
View File
@@ -0,0 +1,33 @@
//! Sphere-on-plate at size 4 (#547): the tet mesher's report and `mesh.quality` measure the same
//! final elements, tet4 and tet10 alike, and no needle is left below the skill's scaled Jacobian
//! limit although every dihedral angle is fine.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use fieldcraft_engine::Session;
use fieldcraft_testkit::corpus;
use serde_json::json;
#[test]
fn sphere_on_plate_report_and_quality_agree() {
let parts = corpus::parts().unwrap();
let p = parts.iter().find(|p| p.name == "sphere-on-plate").unwrap();
let dir = std::env::temp_dir().join(format!("fieldcraft-tet-metric-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("part.solidcraft");
std::fs::write(&path, corpus::design_bytes(p).unwrap()).unwrap();
for order in ["linear", "quadratic"] {
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path})).unwrap();
let r = s.execute("mesh.tet.generate", &json!({"body": "Plate", "size": 4.0, "order": order})).unwrap();
let mesh = &r["bodies"][0]["quality"]["mesh"];
let q = s.execute("mesh.quality", &json!({})).unwrap();
let m = &q["metrics"];
let f = |v: &serde_json::Value| v.as_f64().unwrap();
assert_eq!(f(&mesh["min_dihedral"]), f(&m["min_dihedral"]["min"]), "{order}");
assert_eq!(f(&mesh["max_dihedral"]), f(&m["max_dihedral"]["max"]), "{order}");
assert_eq!(f(&mesh["min_scaled_jacobian"]), f(&m["scaled_jacobian"]["min"]), "{order}");
assert!(f(&mesh["min_dihedral"]) > 30.0 && f(&mesh["max_dihedral"]) < 145.0, "{order}: {mesh}");
assert!(f(&mesh["min_scaled_jacobian"]) >= 0.3, "{order}: {mesh}");
}
let _ = std::fs::remove_dir_all(&dir);
}
+5 -1
View File
@@ -32,6 +32,10 @@ pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) {
app.pick.form_hover = false;
app.set_hover(None);
}
// A Feature Graph row hovered last frame no longer is once the graph is not shown.
if app.reply.as_ref().is_none_or(|r| r.command != "geometry.features") {
app.set_feature_hover(Vec::new());
}
if !has_context(app) {
return;
}
@@ -126,7 +130,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
let selected = (!app.pick.selection.is_empty()).then(|| app.pick.selection.summary());
// While a job runs (this form's mesher, a solve) OK waits for it.
let running = app.busy().map(str::to_string);
let relax_note = app.relax_note();
let relax_note = app.relax_note().or_else(|| app.node_move_note());
let units = app.session.model.units();
let session = &app.session;
// The set Use selection will make on OK is offered with the others.
+460
View File
@@ -0,0 +1,460 @@
//! The `geometry.features` reply in words: each feature of a body's SolidCraft timeline as one
//! line a person reads ("Extrude 16 mm along +Z", "Hole Ø 14 through", "Fillet R 1.5 on 4
//! edges") and its remaining inputs as labelled details. Pure functions of the reply's JSON; the
//! context panel ([`crate::reply`]) draws them.
use serde_json::Value;
use crate::describe::quantity;
/// What a feature does, for its glyph.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Extrude,
Cut,
Revolve,
Primitive,
Hole,
Fillet,
Chamfer,
Shell,
Pattern,
Mirror,
Boolean,
Other,
}
/// One feature made readable.
#[derive(Clone, Debug, PartialEq)]
pub struct Feature {
pub id: u64,
pub kind: Kind,
pub name: String,
/// What it does with its key inputs, in plain words.
pub summary: String,
/// Every other input and fact the reply has: (label, value).
pub details: Vec<(String, String)>,
/// The persistent names of the body's faces it made or shaped.
pub faces: Vec<String>,
pub error: Option<String>,
}
/// `v` to 3 significant figures, as the panel writes numbers.
fn num(v: f64) -> String {
if v == 0.0 || !v.is_finite() {
return quantity(v);
}
let scale = 10f64.powi(2 - v.abs().log10().floor() as i32);
quantity((v * scale).round() / scale)
}
fn mm(v: f64) -> String {
format!("{} mm", num(v))
}
fn deg(radians: f64) -> String {
format!("{}°", num(radians.to_degrees()))
}
/// A direction as its axis (`+Z`) when it is one, else its components.
pub fn axis(d: [f64; 3]) -> String {
const TOL: f64 = 1e-6;
for (i, name) in ["X", "Y", "Z"].iter().enumerate() {
let c = d.get(i).copied().unwrap_or(0.0);
let rest: f64 = d.iter().enumerate().filter(|(j, _)| *j != i).map(|(_, v)| v.abs()).sum();
if (c.abs() - 1.0).abs() < TOL && rest < TOL {
return format!("{}{name}", if c > 0.0 { "+" } else { "−" });
}
}
format!("({}, {}, {})", num(d[0]), num(d[1]), num(d[2]))
}
/// A snake-case input key as a label: `taper_angle` → "Taper angle".
fn label(key: &str) -> String {
let words = key.replace('_', " ");
let mut c = words.chars();
c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
}
/// An input's value with its unit, from its key: angles in degrees, counts and scales bare,
/// everything else a length in mm.
fn input(key: &str, v: f64) -> String {
if key.contains("angle") {
deg(v)
} else if key.contains("quantity") || key.contains("scale") || key.contains("factor") || key == "ribs" {
num(v)
} else {
mm(v)
}
}
fn plural(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
fn vec3(v: &Value) -> Option<[f64; 3]> {
serde_json::from_value(v.clone()).ok()
}
fn strings(v: &Value) -> Vec<String> {
v.as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect()
}
/// The feature node `f` of a `geometry.features` reply made readable.
pub fn describe(f: &Value) -> Feature {
let kind_name = f["type"].as_str().unwrap_or_default();
let op = f["operation"].as_str();
let def = &f["definition"];
let get = |k: &str| f["values"][k].as_f64();
let mut used: Vec<&str> = Vec::new();
let mut take = |k: &'static str| {
used.push(k);
get(k)
};
let dir = vec3(&f["direction"]).map(axis);
let along = dir.as_ref().map(|d| format!(" along {d}")).unwrap_or_default();
let joined = match op {
Some("join") => ", joined",
Some("cut") => ", cut",
Some("intersect") => ", intersected",
_ => "",
};
let edges_n = def["edges"].as_array().map(Vec::len);
let on_edges = edges_n.map(|n| format!(" on {}", plural(n, "edge", "edges"))).unwrap_or_default();
let copies = f["transforms"].as_array().map_or(0, Vec::len);
let (kind, summary) = match kind_name {
"extrude" | "revolve" => {
let (kind, verb) = match op {
Some("cut") => (Kind::Cut, "Cut"),
Some("intersect") => (Kind::Cut, "Intersect"),
Some("join") if kind_name == "extrude" => (Kind::Extrude, "Join"),
_ if kind_name == "revolve" => (Kind::Revolve, "Revolve"),
_ => (Kind::Extrude, "Extrude"),
};
let mut s = if kind_name == "revolve" {
format!("{verb} {}", take("angle").map(deg).unwrap_or_default())
} else {
let d = take("distance");
if def["extent"]["through_all"] == true {
format!("{verb} through all{along}")
} else if f["two_sided"] == true {
format!("{verb} {} both sides", d.map(mm).unwrap_or_default())
} else {
format!("{verb} {}{along}", d.map(mm).unwrap_or_default())
}
};
if let Some(a) = take("taper_angle").filter(|a| *a != 0.0) {
s += &format!(", taper {}", deg(a));
}
if let Some(t) = take("thickness") {
s += &format!(", thin {}", mm(t));
}
(kind, s.trim_end().to_string())
}
"box" => {
let (l, w, h) = (take("length"), take("width"), take("height"));
let dims: Vec<String> = [l, w, h].into_iter().flatten().map(num).collect();
(Kind::Primitive, format!("Box {} mm{joined}", dims.join(" × ")))
}
"cylinder" => {
let (r, h) = (take("radius"), take("height"));
let s = format!("Cylinder Ø {} × {} mm{along}{joined}", r.map(|r| num(2.0 * r)).unwrap_or_default(), h.map(num).unwrap_or_default());
(Kind::Primitive, s)
}
"sphere" => (Kind::Primitive, format!("Sphere Ø {}{joined}", take("radius").map(|r| mm(2.0 * r)).unwrap_or_default())),
"torus" => {
let (a, b) = (take("major_radius"), take("minor_radius"));
(Kind::Primitive, format!("Torus R {} × r {}{joined}", a.map(num).unwrap_or_default(), b.map(mm).unwrap_or_default()))
}
"hole" => {
let d = take("diameter").map(num).unwrap_or_default();
let mut s = match take("depth") {
Some(depth) => format!("Hole Ø {d} × {} deep", mm(depth)),
None => format!("Hole Ø {d} through"),
};
if let (Some(a), Some(b)) = (take("counterbore_diameter"), take("counterbore_depth")) {
s += &format!(", counterbore Ø {} × {}", num(a), mm(b));
}
if let (Some(a), Some(b)) = (take("countersink_diameter"), take("countersink_angle")) {
s += &format!(", countersink Ø {} × {}", num(a), deg(b));
}
(Kind::Hole, s)
}
"fillet" => {
let r = match (take("radius"), take("start_radius"), take("end_radius")) {
(Some(r), _, _) => format!("R {}", num(r)),
(None, Some(a), Some(b)) => format!("R {}–{}", num(a), num(b)),
_ => String::new(),
};
(Kind::Fillet, format!("Fillet {r}{on_edges}").replace(" ", " "))
}
"chamfer" => {
let d = take("distance").map(mm).unwrap_or_default();
let a = take("angle").map(|a| format!(" × {}", deg(a))).unwrap_or_default();
(Kind::Chamfer, format!("Chamfer {d}{a}{on_edges}"))
}
"shell" => {
let faces = def["faces"].as_array().map(Vec::len).unwrap_or(0);
let open = if faces > 0 { format!(", {} open", plural(faces, "face", "faces")) } else { String::new() };
(Kind::Shell, format!("Shell, {} wall{open}", take("thickness").map(mm).unwrap_or_default()))
}
"pattern" | "linear_pattern" | "circular_pattern" => {
let n = take("quantity");
let s = match (n, take("spacing").or_else(|| take("distance")), take("total_angle")) {
(Some(n), _, Some(a)) => format!("Pattern {} × over {}", num(n), deg(a)),
(Some(n), Some(d), _) => format!("Pattern {} × {} apart", num(n), mm(d)),
_ => format!("Pattern of {}", plural(copies + 1, "instance", "instances")),
};
(Kind::Pattern, s)
}
"mirror" => (Kind::Mirror, format!("Mirror, {}", plural(copies.max(1), "copy", "copies"))),
"combine" => {
let verb = match op {
Some("cut") => "cut",
Some("intersect") => "intersect",
_ => "join",
};
let tools = strings(&f["tools"]);
let targets = strings(&f["targets"]);
let s = match (tools.is_empty(), targets.is_empty()) {
(false, false) => format!("Combine: {verb} {} with {}", targets.join(", "), tools.join(", ")),
_ => format!("Combine: {verb}"),
};
(Kind::Boolean, s)
}
other => {
let mut s = label(other);
let first: Vec<String> = f["values"]
.as_object()
.into_iter()
.flatten()
.take(2)
.filter_map(|(k, v)| v.as_f64().map(|v| format!("{} {}", label(k).to_lowercase(), input(k, v))))
.collect();
if !first.is_empty() {
s += &format!(", {}", first.join(", "));
}
(Kind::Other, format!("{s}{joined}"))
}
};
let mut details: Vec<(String, String)> = Vec::new();
for (k, v) in f["values"].as_object().into_iter().flatten() {
if let Some(v) = v.as_f64()
&& !used.contains(&k.as_str())
{
details.push((label(k), input(k, v)));
}
}
if let Some(sk) = f["sketch"].as_object() {
let n = sk.get("profiles").and_then(Value::as_array).map_or(0, Vec::len);
let name = sk.get("name").and_then(Value::as_str).unwrap_or("sketch");
details.push(("Sketch".into(), format!("{name} ({})", plural(n, "profile", "profiles"))));
}
if let Some(d) = dir {
details.push(("Direction".into(), d));
}
if let Some(p) = vec3(&f["placement"]) {
details.push(("At".into(), format!("({}, {}, {}) mm", num(p[0]), num(p[1]), num(p[2]))));
}
if let Some(op) = op {
details.push(("Operation".into(), op.replace('_', " ")));
}
for (key, name) in [("targets", "Changes"), ("tools", "Tools"), ("creates", "Creates")] {
let v = strings(&f[key]);
if !v.is_empty() {
details.push((name.into(), v.join(", ")));
}
}
let faces = strings(&f["faces"]);
let edges = f["edges"].as_array().map_or(0, Vec::len);
details.push(("Shapes".into(), format!("{}, {}", plural(faces.len(), "face", "faces"), plural(edges, "edge", "edges"))));
Feature {
id: f["id"].as_u64().unwrap_or(0),
kind,
name: f["name"].as_str().unwrap_or(kind_name).to_string(),
summary,
details,
faces,
error: f["error"].as_str().map(str::to_string),
}
}
/// The feature's glyph, drawn in `rect` with lines in `line` and the part it adds or removes in
/// `accent` (no image assets: every glyph is a few strokes).
pub fn paint_glyph(painter: &egui::Painter, rect: egui::Rect, kind: Kind, line: egui::Color32, accent: egui::Color32) {
use egui::{Shape, Stroke, pos2, vec2};
let r = rect.shrink(rect.width() * 0.12);
let (w, h) = (r.width(), r.height());
let at = |x: f32, y: f32| pos2(r.left() + x * w, r.top() + y * h);
let s = Stroke::new(1.3, line);
let a = Stroke::new(1.5, accent);
let arrow = |from: egui::Pos2, to: egui::Pos2| {
painter.line_segment([from, to], a);
let d = (to - from).normalized() * (w * 0.2);
let n = vec2(-d.y, d.x);
painter.add(Shape::convex_polygon(vec![to, to - d + n * 0.8, to - d - n * 0.8], accent, Stroke::NONE));
};
match kind {
Kind::Extrude | Kind::Cut => {
painter.rect_stroke(egui::Rect::from_min_max(at(0.0, 0.62), at(1.0, 1.0)), 1.0, s, egui::StrokeKind::Inside);
if kind == Kind::Extrude { arrow(at(0.5, 0.62), at(0.5, 0.0)) } else { arrow(at(0.5, 0.0), at(0.5, 0.6)) }
}
Kind::Revolve => {
painter.circle_stroke(r.center(), w * 0.45, s);
arrow(at(0.15, 0.25), at(0.45, 0.05));
}
Kind::Primitive => {
painter.rect_stroke(egui::Rect::from_min_max(at(0.0, 0.25), at(0.75, 1.0)), 1.0, s, egui::StrokeKind::Inside);
painter.add(Shape::line(vec![at(0.0, 0.25), at(0.25, 0.0), at(1.0, 0.0), at(1.0, 0.75), at(0.75, 1.0)], s));
painter.line_segment([at(0.75, 0.25), at(1.0, 0.0)], s);
}
Kind::Hole => {
painter.rect_stroke(r, 1.0, s, egui::StrokeKind::Inside);
painter.circle_stroke(r.center(), w * 0.24, a);
}
Kind::Fillet => {
painter.add(Shape::line(vec![at(0.0, 1.0), at(0.0, 0.5)], s));
painter.add(Shape::line(vec![at(0.5, 0.0), at(1.0, 0.0)], s));
let arc: Vec<egui::Pos2> = (0..=8)
.map(|i| {
let t = std::f32::consts::FRAC_PI_2 * i as f32 / 8.0;
at(0.5 - 0.5 * t.cos(), 0.5 - 0.5 * t.sin())
})
.collect();
painter.add(Shape::line(arc, a));
}
Kind::Chamfer => {
painter.add(Shape::line(vec![at(0.0, 1.0), at(0.0, 0.45)], s));
painter.add(Shape::line(vec![at(0.55, 0.0), at(1.0, 0.0)], s));
painter.line_segment([at(0.0, 0.45), at(0.55, 0.0)], a);
}
Kind::Shell => {
painter.add(Shape::line(vec![at(0.0, 0.0), at(0.0, 1.0), at(1.0, 1.0), at(1.0, 0.0)], s));
painter.add(Shape::line(vec![at(0.25, 0.0), at(0.25, 0.75), at(0.75, 0.75), at(0.75, 0.0)], a));
}
Kind::Pattern => {
for (x, y) in [(0.2, 0.2), (0.8, 0.2), (0.2, 0.8), (0.8, 0.8)] {
painter.circle_stroke(at(x, y), w * 0.16, if (x, y) == (0.2, 0.2) { s } else { a });
}
}
Kind::Mirror => {
let mut y = 0.0;
while y < 1.0 {
painter.line_segment([at(0.5, y), at(0.5, (y + 0.12).min(1.0))], s);
y += 0.22;
}
painter.add(Shape::convex_polygon(vec![at(0.38, 0.15), at(0.38, 0.85), at(0.0, 0.85)], egui::Color32::TRANSPARENT, s));
painter.add(Shape::convex_polygon(vec![at(0.62, 0.15), at(0.62, 0.85), at(1.0, 0.85)], egui::Color32::TRANSPARENT, a));
}
Kind::Boolean => {
painter.circle_stroke(at(0.36, 0.5), w * 0.34, s);
painter.circle_stroke(at(0.64, 0.5), w * 0.34, a);
}
Kind::Other => {
painter.circle_filled(r.center(), w * 0.18, accent);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn summary(f: Value) -> String {
describe(&f).summary
}
#[test]
fn extrudes_and_cuts_say_how_far_and_which_way() {
let f = json!({"type": "extrude", "operation": "new_body", "values": {"distance": 16.0}, "direction": [0, 0, 1], "definition": {}});
assert_eq!(summary(f), "Extrude 16 mm along +Z");
let f = json!({"type": "extrude", "operation": "cut", "values": {"distance": 8.0}, "direction": [0, 0, -1], "definition": {}});
assert_eq!(summary(f), "Cut 8 mm along −Z");
let f = json!({"type": "extrude", "operation": "cut", "values": {"distance": 0.0}, "direction": [0, 0, 1], "definition": {"extent": {"through_all": true}}});
assert_eq!(summary(f), "Cut through all along +Z");
let f = json!({"type": "extrude", "operation": "join", "two_sided": true, "values": {"distance": 10.0}, "definition": {}});
assert_eq!(summary(f), "Join 10 mm both sides");
let f = json!({"type": "extrude", "values": {"distance": 5.0, "taper_angle": 0.0523599}, "direction": [0.6, 0.8, 0], "definition": {}});
assert_eq!(describe(&f).kind, Kind::Extrude);
assert_eq!(summary(f), "Extrude 5 mm along (0.6, 0.8, 0), taper 3°");
}
#[test]
fn primitives_give_their_size() {
let f =
json!({"type": "cylinder", "operation": "join", "values": {"radius": 23.0, "height": 22.0}, "direction": [0, 0, 1], "definition": {}});
assert_eq!(summary(f), "Cylinder Ø 46 × 22 mm along +Z, joined");
let f = json!({"type": "box", "values": {"length": 100.0, "width": 40.0, "height": 5.0}, "definition": {}});
assert_eq!(summary(f), "Box 100 × 40 × 5 mm");
let f = json!({"type": "sphere", "operation": "cut", "values": {"radius": 2.5}, "definition": {}});
assert_eq!(summary(f), "Sphere Ø 5 mm, cut");
}
#[test]
fn holes_say_through_or_how_deep() {
let f = json!({"type": "hole", "operation": "cut", "values": {"diameter": 3.2}, "definition": {}});
assert_eq!(describe(&f).kind, Kind::Hole);
assert_eq!(summary(f), "Hole Ø 3.2 through");
let f = json!({"type": "hole", "values": {"diameter": 6.0, "depth": 10.0, "counterbore_diameter": 11.0, "counterbore_depth": 6.5}, "definition": {}});
assert_eq!(summary(f), "Hole Ø 6 × 10 mm deep, counterbore Ø 11 × 6.5 mm");
}
#[test]
fn edge_features_count_their_edges() {
let f = json!({"type": "fillet", "values": {"radius": 2.0}, "definition": {"edges": [[0, 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]]}});
assert_eq!(describe(&f).kind, Kind::Fillet);
assert_eq!(summary(f), "Fillet R 2 on 4 edges");
let f = json!({"type": "fillet", "values": {"start_radius": 1.0, "end_radius": 3.0}, "definition": {"edges": [[0, 0, 0]]}});
assert_eq!(summary(f), "Fillet R 1–3 on 1 edge");
let f = json!({"type": "chamfer", "values": {"distance": 0.5}, "definition": {"edges": [[0, 0, 0], [1, 1, 1]]}});
assert_eq!(describe(&f).kind, Kind::Chamfer);
assert_eq!(summary(f), "Chamfer 0.5 mm on 2 edges");
}
#[test]
fn shells_patterns_mirrors_and_combines() {
let f = json!({"type": "shell", "values": {"thickness": 2.0}, "definition": {"faces": ["top"]}});
assert_eq!(summary(f), "Shell, 2 mm wall, 1 face open");
let f = json!({"type": "pattern", "values": {"quantity": 4.0, "spacing": 30.0}, "definition": {}});
assert_eq!(describe(&f).kind, Kind::Pattern);
assert_eq!(summary(f), "Pattern 4 × 30 mm apart");
let f = json!({"type": "pattern", "values": {"quantity": 6.0, "total_angle": std::f64::consts::TAU}, "definition": {}});
assert_eq!(summary(f), "Pattern 6 × over 360°");
let f = json!({"type": "mirror", "values": {}, "transforms": [[[1, 0, 0, 0], [0, -1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]], "definition": {}});
assert_eq!(describe(&f).kind, Kind::Mirror);
assert_eq!(summary(f), "Mirror, 1 copy");
let f = json!({"type": "combine", "operation": "cut", "targets": ["Base"], "tools": ["Tool"], "values": {}, "definition": {}});
assert_eq!(describe(&f).kind, Kind::Boolean);
assert_eq!(summary(f), "Combine: cut Base with Tool");
}
#[test]
fn other_kinds_name_their_first_inputs_and_details_hold_the_rest() {
let f = json!({"type": "offset_face", "values": {"distance": 1.5}, "definition": {}});
assert_eq!(summary(f), "Offset face, distance 1.5 mm");
let f = json!({
"id": 2, "name": "Arm", "type": "extrude", "operation": "new_body", "values": {"distance": 16.0, "start_offset": 1.0},
"direction": [0, 0, 1], "creates": ["CrankArm"], "sketch": {"name": "ArmProfile", "profiles": [{}]},
"faces": ["F2:top", "F2:start"], "edges": ["e1"], "definition": {}, "error": "no profile"
});
let d = describe(&f);
assert_eq!((d.id, d.name.as_str(), d.faces.len(), d.error.as_deref()), (2, "Arm", 2, Some("no profile")));
let get = |k: &str| d.details.iter().find(|(l, _)| l == k).map(|(_, v)| v.as_str());
assert_eq!(get("Start offset"), Some("1 mm"));
assert_eq!(get("Distance"), None, "the summary already says it");
assert_eq!(get("Sketch"), Some("ArmProfile (1 profile)"));
assert_eq!(get("Creates"), Some("CrankArm"));
assert_eq!(get("Shapes"), Some("2 faces, 1 edge"));
}
#[test]
fn hostile_nodes_never_panic() {
for f in
[json!(null), json!([]), json!({"type": 3, "values": "x", "direction": [1]}), json!({"type": "hole", "values": {"diameter": f64::MAX}})]
{
let _ = describe(&f);
}
assert_eq!(axis([0.0, 0.0, 0.0]), "(0, 0, 0)");
}
}
+54 -1
View File
@@ -136,6 +136,9 @@ pub const BACKGROUND: &[&str] = &[
"mesh.hex.octree",
"mesh.hex.project",
"mesh.node.relax",
"mesh.plan.apply",
"mesh.cells",
"geometry.partition",
];
/// Most entries the recent-files list keeps, and the longest path it keeps.
@@ -800,7 +803,7 @@ impl ModelApp {
self.recent.add(p);
}
if self.dialog.as_ref().is_some_and(|d| o.form == Some(d.command)) {
self.dialog = None;
self.form_done();
}
let warnings: Vec<&str> = o.reply.get("warnings").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
if warnings.is_empty() {
@@ -1732,6 +1735,56 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// The partition, the cells mesher and the plan's apply run as jobs (#545): the window
/// stays free while they run, the model they leave is the one the command makes run
/// directly, bit for bit, in one undo step, and a cancelled one leaves the model as it was.
#[test]
fn partition_cells_and_plan_apply_run_as_jobs_like_the_command() {
let dir = temp("jobs-545");
let design = dir.join("plate.solidcraft").to_string_lossy().to_string();
std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap();
let mut app = ModelApp::new(Session::default());
app.open_path(&design);
app.wait_loaded();
app.run("geometry.datum", json!({"name": "X30", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap();
let plan = app.run("mesh.plan", json!({"body": "Body1"})).unwrap();
let steps = [
("mesh.plan.apply", json!({"plan": plan["name"]})),
("geometry.partition", json!({"name": "Half", "datum": "X30"})),
("mesh.cells", json!({"bodies": ["Body1/a", "Body1/b"], "size": 2.5})),
];
for (command, params) in steps {
assert!(is_long(command), "{command}");
let before = app.session.clone();
let mut direct = app.session.clone();
let expected = direct.execute(command, &params).unwrap_or_else(|e| panic!("{command}: {e}"));
// Cancelled: nothing changes.
app.start_job(command, params.clone()).unwrap();
app.cancel_loading();
assert!(app.busy().is_none());
assert!(app.session.model == before.model && app.session.journal.len() == before.journal.len(), "{command}: cancelled");
app.start_job(command, params.clone()).unwrap();
assert_eq!(app.busy(), Some(crate::command_label(command)), "{command} runs off the window thread");
app.wait_loaded();
assert!(app.status.as_ref().is_some_and(|s| !s.error), "{command}: {:?}", app.status);
assert!(app.session.model == direct.model, "{command}: the job's model is the command's");
let bodies = |s: &Session| s.design.as_ref().map(|d| d.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>());
assert_eq!(bodies(&app.session), bodies(&direct), "{command}");
assert_eq!(app.session.journal.len(), before.journal.len() + 1, "{command}: one undo step");
let elements = expected["elements"].as_u64().map(|n| n as usize);
assert!(elements.is_none_or(|n| n == app.session.model.elements().len()), "{command}: {expected}");
// Undo takes the whole command back; redo brings it again.
app.run("edit.undo", json!({})).unwrap();
assert!(app.session.model == before.model, "{command}: undo");
app.run("edit.redo", json!({})).unwrap();
assert!(app.session.model == direct.model, "{command}: redo");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// The control channel's `ui.start` never opens a picker: it opens the form.
#[test]
fn programmatic_starts_never_open_a_picker() {
+113 -10
View File
@@ -30,6 +30,7 @@ pub mod context;
pub mod control;
pub mod describe;
pub mod elide;
pub mod feature_graph;
pub mod fields;
pub mod files;
pub mod form;
@@ -39,6 +40,7 @@ pub mod gpu;
pub mod icons;
pub mod menu;
pub mod navigation;
pub mod node_move;
pub mod palette;
pub mod privacy;
pub mod reply;
@@ -239,6 +241,8 @@ pub struct PickState {
pub selection: selection::Selection,
/// What a click would pick under the pointer.
pub hover: Option<selection::Item>,
/// The faces of the feature whose row in the Feature Graph is under the pointer.
feature_hover: Vec<selection::Item>,
/// A left drag draws a selection box (nodes, element faces, elements) instead of orbiting.
pub box_mode: bool,
/// The box being dragged (window points).
@@ -369,6 +373,10 @@ pub struct ModelApp {
preview: std::cell::RefCell<DatumPreview>,
merge_preview: std::cell::RefCell<MergePreview>,
relax_preview: std::cell::RefCell<RelaxPreview>,
/// The open Move Nodes form's move, worked out for the view ([`node_move`]).
node_move: std::cell::RefCell<node_move::Cache>,
/// The Move Nodes drag in progress.
pub node_drag: Option<node_move::Drag>,
/// Forms opened so far (numbers each [`ParamsDialog::opening`]).
forms_opened: u64,
pub viewport: Viewport,
@@ -397,6 +405,8 @@ pub struct ModelApp {
pub xy: Option<XyPlot>,
/// The last command reply with more to it than its status line, shown in the context panel.
pub reply: Option<reply::Reply>,
/// The Feature Graph rows opened to their details: (body, feature id).
pub features_open: std::collections::BTreeSet<(String, u64)>,
/// Whether results were shown last frame (the Results tab comes up when they appear).
results_shown: bool,
/// The framing the viewport does when it next draws ([`framing::Reframe`]).
@@ -515,6 +525,8 @@ impl ModelApp {
preview: Default::default(),
merge_preview: Default::default(),
relax_preview: Default::default(),
node_move: Default::default(),
node_drag: None,
forms_opened: 0,
viewport: Viewport::default(),
icons: icons::IconCache::default(),
@@ -530,6 +542,7 @@ impl ModelApp {
reactions: None,
xy: None,
reply: None,
features_open: Default::default(),
results_shown: false,
reframe: None,
framed_document: None,
@@ -898,6 +911,21 @@ impl ModelApp {
self.dialog = Some(dialog);
}
/// The open form's command succeeded (here or as a job): a definition it made is the
/// selection, its properties in the panel, and the form closes (back to the form it was
/// opened from, if any).
pub(crate) fn form_done(&mut self) {
if let Some(d) = &self.dialog
&& let Some(kind) = browser::Def::ALL.into_iter().find(|k| k.command("create") == d.command)
&& let Ok(params) = serde_json::from_str::<Value>(&d.text)
&& let Some(name) = params.get("name").and_then(Value::as_str)
&& kind.exists(&self.session.model, name)
{
self.selected = Some((kind, name.to_string()));
}
self.dialog_done();
}
/// Run the open parameter form; it closes when the command succeeds.
pub fn run_dialog(&mut self) -> Result<Value, String> {
let (command, params) = self.dialog_params()?;
@@ -916,16 +944,9 @@ impl ModelApp {
}
return Err(message);
}
let made = params.get("name").and_then(Value::as_str).map(str::to_string);
match self.run_form(command, params, true) {
Ok(v) => {
// A definition made in its form is the selection, its properties in the panel.
if let (Some(kind), Some(name)) = (browser::Def::ALL.into_iter().find(|k| k.command("create") == command), made)
&& kind.exists(&self.session.model, &name)
{
self.selected = Some((kind, name));
}
self.dialog_done();
self.form_done();
Ok(v)
}
Err(Failure { message, refused }) => {
@@ -1109,7 +1130,8 @@ impl ModelApp {
let still = key.as_ref().map(|k| PlotKey { frame: None, ..k.clone() });
if self.prepared.0 != still {
let pick = key.as_ref().map(|k| k.pick).unwrap_or_default();
self.prepared = (still, results::prepare(&self.session, &self.display.1, pick).unwrap_or_default());
let bodies: Vec<&str> = self.pick.names.iter().map(|n| n.name.as_str()).collect();
self.prepared = (still, results::prepare(&self.session, &self.display.1, pick, &bodies).unwrap_or_default());
}
let frame = key.as_ref().and_then(|k| k.frame);
let p = key.as_ref().and_then(|_| results::plot(&self.session, &self.display.1, frame, &self.prepared.1));
@@ -1381,6 +1403,10 @@ impl ModelApp {
let mut bounds = self.display.1.bounds;
if let Some(p) = &self.plot.1 {
bounds.union(p.display.bounds);
// A fluid step's slice and streamlines fill its domain.
if let Some(f) = &p.flow {
bounds.union(f.bounds());
}
}
// Reference points are places in the model, framed with it (their symbols are not).
for p in self.session.model.reference_points().values() {
@@ -1567,6 +1593,15 @@ impl ModelApp {
}
}
/// Highlight `items` as hovered (the faces of a Feature Graph row under the pointer; empty:
/// none).
pub fn set_feature_hover(&mut self, items: Vec<selection::Item>) {
if self.pick.feature_hover != items {
self.pick.feature_hover = items;
self.pick.hover_generation = self.pick.hover_generation.wrapping_add(1);
}
}
/// The selection and the hover drawn over the scene in the theme's highlight colours,
/// rebuilt when either or the scene changes; and its key.
pub fn overlay(&mut self) -> (u64, Arc<Scene>) {
@@ -1580,7 +1615,8 @@ impl ModelApp {
let cut = self.partition_preview();
let merge = self.merge_preview();
let relax_key = self.dialog.as_ref().filter(|d| d.command == "mesh.node.relax").map(|d| d.text.clone());
let selected = hash(&|h| (scene, self.pick.generation, &set, &cut, &*merge, &relax_key).hash(h));
let move_key = self.dialog.as_ref().filter(|d| d.command == node_move::COMMAND).map(|d| (d.text.clone(), self.node_move_pending()));
let selected = hash(&|h| (scene, self.pick.generation, &set, &cut, &*merge, &relax_key, &move_key).hash(h));
let key = hash(&|h| (selected, self.pick.hover_generation).hash(h));
if self.pick.overlay.0 == Some(key) {
return (key, self.pick.overlay.1.clone());
@@ -1620,6 +1656,10 @@ impl ModelApp {
if relax_key.is_some() {
self.relax_overlay(&mut s, &t);
}
// The Move Nodes preview: the ghost, the moved elements and the sphere.
if move_key.is_some() {
self.node_move_overlay(&mut s, &t);
}
self.pick.selected_overlay = (Some(selected), Arc::new(s));
}
let mut s = (*self.pick.selected_overlay.1).clone();
@@ -1631,6 +1671,12 @@ impl ModelApp {
s.lines.extend(h.lines);
s.points.extend(h.points);
}
if !self.pick.feature_hover.is_empty() {
let items = self.pick.feature_hover.clone();
let h = self.with_picker(|p| p.items_overlay(&items, theme::rgb(t.pick_hover)));
s.tris.extend(h.tris);
s.lines.extend(h.lines);
}
self.pick.overlay = (Some(key), Arc::new(s));
(key, self.pick.overlay.1.clone())
}
@@ -2058,6 +2104,10 @@ impl ModelApp {
if ctx.memory(|m| m.focused().is_some()) {
return;
}
// Escape cancels a Move Nodes drag first.
if self.node_drag.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
self.node_move_cancel();
}
// Escape clears the viewport's selection first (a second one cancels an open form).
if !self.pick.selection.is_empty()
&& self.ui.menu.is_none()
@@ -2530,6 +2580,59 @@ mod tests {
assert_ne!(app.session.model.node(n).unwrap().xyz, [2.7, 2.6, 2.5]);
}
/// Move Nodes: the form previews the move (the region's quality before and now, the ghost and
/// the coloured elements drawn), Alt + scroll sizes the radius, a drag's release runs the
/// command in one undo step and keeps the form for the next drag, Esc cancels a drag.
#[test]
fn move_nodes_previews_and_runs_on_release() {
let mut app = ModelApp::new(Session::default());
let c = [[0., 0., 0.], [4., 0., 0.], [4., 4., 0.], [0., 4., 0.], [0., 0., 4.], [4., 0., 4.], [4., 4., 4.], [0., 4., 4.]];
app.run("mesh.block", json!({"body": "A", "kind": "hex8", "corners": c, "divisions": [4, 4, 4]})).unwrap();
let n = *app.session.model.nodes().iter().find(|(_, p)| p.xyz == [2.0, 2.0, 2.0]).unwrap().0;
assert!(!app.moving_nodes() && app.node_move_preview().is_none());
app.start(node_move::COMMAND).unwrap();
assert!(app.moving_nodes());
if let Some(d) = app.dialog.as_mut() {
d.text = json!({"nodes": [n], "by": [0.4, 0.2, 0.0], "on_geometry": true, "guard": "no_inversion"}).to_string();
}
assert!(app.node_move_scale_radius(1.0), "a radius to start from");
assert!(app.node_move_scale_radius(5.0), "Alt + scroll grows it");
let note = app.node_move_note().unwrap();
assert!(note.starts_with("Preview:") && note.contains("worse"), "{note}");
let p = app.node_move_preview().unwrap();
let m = p.moved.as_ref().unwrap();
assert!(m.positions.contains_key(&n) && m.moved > 1 && p.sphere.is_some());
let (_, overlay) = app.overlay();
assert!(
!overlay.fills.is_empty() && !overlay.lines.is_empty() && !overlay.on_top.is_empty(),
"sphere, ghost and moved edges, the grabbed node"
);
// A changed radius waits out the debounce (the sphere drawn meanwhile), then prepares.
assert!(app.node_move_scale_radius(1.5));
let waiting = app.node_move_preview().unwrap();
assert!(app.node_move_pending() && waiting.moved.is_none() && waiting.sphere.is_some());
app.now += node_move::DEBOUNCE * 2.0;
assert!(app.node_move_preview().unwrap().moved.is_some() && !app.node_move_pending());
// Esc during a drag: nothing moves, the form stays.
let start = app.session.model.clone();
app.node_drag = Some(node_move::Drag { start: Vec3::new(2.0, 2.0, 2.0), normal: Vec3::Z });
app.node_move_cancel();
assert!(app.node_drag.is_none() && app.moving_nodes() && app.session.model == start);
assert!(app.dialog.as_ref().unwrap().text.contains("\"by\":[0.0,0.0,0.0]"));
// A drag's release runs the move.
if let Some(d) = app.dialog.as_mut() {
let mut p: Value = serde_json::from_str(&d.text).unwrap();
p["by"] = json!([0.4, 0.2, 0.0]);
d.text = p.to_string();
}
app.node_drag = Some(node_move::Drag { start: Vec3::new(2.0, 2.0, 2.0), normal: Vec3::Z });
app.node_move_release().unwrap();
assert_eq!(app.session.model.node(n).unwrap().xyz, [2.4, 2.2, 2.0]);
assert!(app.moving_nodes() && app.node_drag.is_none(), "the form stays open for the next drag");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.model, start);
}
/// Relax Nodes by face (#471): Use selection puts the picked design faces in the form in
/// place of a sphere, clicks then pick faces, and OK relaxes them.
#[test]
+388
View File
@@ -0,0 +1,388 @@
//! The Move Nodes tool (`mesh.node.move`): with its form open, a drag on the mesh grabs the node
//! under the pointer (or the nodes the form lists, when the press is on one of them) and moves it
//! in the screen plane; the nodes round it follow as the form says (radius, Alt + scroll over
//! the view; falloff and strength, or harmonic). While dragging, the view draws the original
//! element edges as a ghost under the moved ones, those coloured by how each element's scaled
//! Jacobian changed (the error colour worse, the ok colour better), and the sphere of influence;
//! the form shows the region's numbers before and now. Release runs the command (one undo
//! step) and keeps the form open for the next drag; Esc during a drag cancels it.
//!
//! The tool only previews: what is drawn comes from [`fieldcraft_engine::cmd::NodeMovePrepared`],
//! the command's own move worked out without doing it. The part that does not depend on the
//! displacement (who follows, the design's faces) is worked out once per form and model
//! revision, so a drag frame only moves the region's nodes and measures its elements; a region
//! of more than [`PREVIEW_NODES`] nodes is not previewed (the move applies on release).
use std::sync::Arc;
use fieldcraft_engine::cmd::{NodeMoveCapped, NodeMovePrepared, NodeMoved};
use fieldcraft_render::{Scene, SceneLine, Vec3};
use serde_json::{Value, json};
use crate::theme::{self, Tokens};
use crate::{ModelApp, scene};
/// The command the tool runs.
pub const COMMAND: &str = "mesh.node.move";
/// Most nodes a live preview moves: a larger region is moved on release only. On the crank arm's
/// hex20 mesh (8264 elements) a region of 492 nodes prepares in 20 ms (falloff) to 27 ms
/// (harmonic) and draws a frame in 12 to 52 ms; one of 4051 nodes takes 0.8 s to prepare
/// harmonically and 0.3 s a frame, too slow to follow the pointer.
pub const PREVIEW_NODES: usize = 1_000;
/// How long the form must stay the same (seconds) before a changed move is prepared again: Alt +
/// scroll changes the radius every frame, and preparing (the design's faces, the harmonic
/// solve) takes longer than a frame on a big region.
pub const DEBOUNCE: f64 = 0.15;
/// The prepared part of the move.
#[derive(Clone)]
enum Prep {
Ready(Arc<NodeMovePrepared>),
/// More than [`PREVIEW_NODES`] would follow: not prepared, applied on release only.
TooLarge(usize),
Failed(String),
}
/// A drag of the Move Nodes tool in progress.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Drag {
/// The point under the press, on the plane the drag moves in.
pub start: Vec3,
/// That plane's normal: the view direction when the drag started.
pub normal: Vec3,
}
/// What the open form's move would do, and what it was worked out for.
#[derive(Default)]
pub struct Cache {
/// The move up to its displacement, by model revision and form text without `by` and `to`.
prepared: Option<((u64, String), Prep)>,
/// The form (without the displacement) last seen and when (app time): a change waits
/// [`DEBOUNCE`] before it is prepared again.
seen: Option<((u64, String), f64)>,
/// The move itself, by model revision and form text.
preview: Option<((u64, String), Option<Arc<Preview>>)>,
}
/// The open form's move, without doing it.
pub struct Preview {
pub sphere: Option<([f64; 3], f64)>,
/// Nodes that take a share of the move.
pub nodes: usize,
/// The move; `None` when the region is too large to preview or the form is not complete.
pub moved: Option<NodeMoved>,
/// Why there is no move to show.
pub note: Option<String>,
}
/// Where the ray `origin + t·dir` meets the plane through `point` with normal `normal`; `None`
/// when it runs along the plane.
pub fn on_plane(origin: Vec3, dir: Vec3, point: Vec3, normal: Vec3) -> Option<Vec3> {
let denom = dir.dot(normal);
if denom.abs() < 1e-12 {
return None;
}
let t = (point - origin).dot(normal) / denom;
let p = origin + dir * t;
p.is_finite().then_some(p)
}
/// The form text without the displacement: the key of the prepared move.
fn without_displacement(params: &Value) -> String {
let mut p = params.clone();
if let Some(o) = p.as_object_mut() {
o.remove("by");
o.remove("to");
}
p.to_string()
}
impl ModelApp {
/// Whether the Move Nodes form is open.
pub fn moving_nodes(&self) -> bool {
self.dialog.as_ref().is_some_and(|d| d.command == COMMAND)
}
fn move_params(&self) -> Option<Value> {
let d = self.dialog.as_ref().filter(|d| d.command == COMMAND)?;
serde_json::from_str::<Value>(&d.text).ok().filter(Value::is_object)
}
fn set_move_params(&mut self, params: Value) {
if let Some(d) = self.dialog.as_mut().filter(|d| d.command == COMMAND) {
d.text = params.to_string();
d.error = None;
d.refused = None;
}
}
/// The open form's move up to its displacement, cached by form and revision; `None` while a
/// changed form waits out [`DEBOUNCE`] (the last one is kept meanwhile, never prepared on
/// every frame of an Alt + scroll).
fn move_prepared(&self, params: &Value) -> Option<Prep> {
let key = (self.revision, without_displacement(params));
let mut cache = self.node_move.borrow_mut();
if let Some((k, p)) = &cache.prepared
&& *k == key
{
return Some(p.clone());
}
if cache.prepared.is_some() {
match &cache.seen {
Some((k, at)) if *k == key => {
if self.now - at < DEBOUNCE {
return None;
}
}
_ => {
cache.seen = Some((key, self.now));
return None;
}
}
}
let p = match fieldcraft_engine::cmd::node_move_prepare_capped(&self.session, params, Some(PREVIEW_NODES)) {
Ok(NodeMoveCapped::Ready(p)) => Prep::Ready(Arc::from(p)),
Ok(NodeMoveCapped::TooLarge(n)) => Prep::TooLarge(n),
Err(e) => Prep::Failed(e.to_string()),
};
cache.prepared = Some((key, p.clone()));
cache.seen = None;
Some(p)
}
/// Whether a changed Move Nodes form waits to be prepared (the view draws again after
/// [`DEBOUNCE`]).
pub fn node_move_pending(&self) -> bool {
self.node_move.borrow().seen.is_some()
}
/// The sphere the form describes, without preparing the move: its centre, or the listed
/// nodes' centroid, and its radius.
fn quick_sphere(&self, params: &Value) -> Option<([f64; 3], f64)> {
let r = params.get("radius").and_then(Value::as_f64).filter(|r| *r > 0.0 && r.is_finite())?;
let xyz = |v: &Value| -> Option<[f64; 3]> {
let a = v.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
};
if let Some(c) = params.get("center").and_then(xyz) {
return Some((c, r));
}
let pts: Vec<[f64; 3]> = params
.get("nodes")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|n| n.as_u64().and_then(|n| u32::try_from(n).ok()))
.filter_map(|n| self.session.model.node(n).map(|p| p.xyz))
.collect();
if pts.is_empty() {
return None;
}
let k = 1.0 / pts.len() as f64;
Some((pts.iter().fold([0.0; 3], |a, p| [a[0] + p[0] * k, a[1] + p[1] * k, a[2] + p[2] * k]), r))
}
/// What the open Move Nodes form would do, worked out once per form text and model revision.
pub fn node_move_preview(&self) -> Option<Arc<Preview>> {
let d = self.dialog.as_ref().filter(|d| d.command == COMMAND)?;
let key = (self.revision, d.text.clone());
if let Some((k, p)) = &self.node_move.borrow().preview
&& *k == key
{
return p.clone();
}
let params = serde_json::from_str::<Value>(&d.text).ok()?;
let Some(prep) = self.move_prepared(&params) else {
// Waiting out the debounce: the sphere as the form has it, nothing cached.
return Some(Arc::new(Preview { sphere: self.quick_sphere(&params), nodes: 0, moved: None, note: Some("Preview: updating…".into()) }));
};
let preview = match prep {
Prep::Failed(e) => Some(Preview { sphere: self.quick_sphere(&params), nodes: 0, moved: None, note: Some(e) }),
Prep::TooLarge(n) => Some(Preview {
sphere: self.quick_sphere(&params),
nodes: n,
moved: None,
note: Some(format!("{n} nodes follow: too many for a live preview (over {PREVIEW_NODES}); the move applies on release")),
}),
Prep::Ready(prep) => {
let mut out = Preview { sphere: prep.sphere, nodes: prep.nodes(), moved: None, note: None };
match prep.displacement(&params).and_then(|d| prep.preview_live(&self.session, d)) {
Ok(m) => out.moved = Some(m),
Err(e) => out.note = Some(e.to_string()),
}
Some(out)
}
}
.map(Arc::new);
self.node_move.borrow_mut().preview = Some((key, preview.clone()));
preview
}
/// The open Move Nodes form's preview in a line: the region's quality before and now.
pub fn node_move_note(&self) -> Option<String> {
let p = self.node_move_preview()?;
let Some(m) = &p.moved else { return p.note.clone() };
let mut line = format!(
"Preview: {} nodes move (largest {:.3}); scaled Jacobian min {:.3} to {:.3}, mean {:.3} to {:.3}; edge ratio max {:.2} to {:.2}; {} worse, {} better",
m.moved,
m.max_displacement,
m.before.min_quality,
m.after.min_quality,
m.mean_before,
m.mean_after,
m.before.max_edge_ratio,
m.after.max_edge_ratio,
m.worse,
m.better
);
if m.scale < 1.0 {
line.push_str(&format!("; scaled to {:.0}% so that no element inverts", m.scale * 100.0));
}
Some(line)
}
/// Start a drag at window point `at` on the mesh: grab the listed nodes when the node under
/// the press is one of them, else that node. Whether a drag started.
pub fn node_move_grab(&mut self, at: egui::Pos2) -> bool {
let Some(mut params) = self.move_params() else { return false };
let (Some(rect), Some(hit)) = (self.viewport.rect, self.surface_point(at)) else { return false };
let Some(node) = fieldcraft_engine::cmd::node_move_nearest(&self.session, [hit.x, hit.y, hit.z]) else { return false };
let listed: Vec<u64> =
params.get("nodes").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
if !listed.contains(&u64::from(node)) {
params["nodes"] = json!([node]);
}
if let Some(o) = params.as_object_mut() {
for key in ["center", "faces", "to"] {
o.remove(key);
}
}
params["by"] = json!([0.0, 0.0, 0.0]);
let (o, d) = self.cam.ray(f64::from(at.x - rect.left()), f64::from(at.y - rect.top()), f64::from(rect.width()), f64::from(rect.height()));
let normal = self.cam.back();
let Some(start) = on_plane(o, d, hit, normal) else { return false };
self.set_move_params(params);
self.node_drag = Some(Drag { start, normal });
true
}
/// The pointer of the drag is at window point `at`: the move follows it in the screen plane.
pub fn node_move_drag(&mut self, at: egui::Pos2) {
let (Some(drag), Some(rect), Some(mut params)) = (self.node_drag, self.viewport.rect, self.move_params()) else { return };
let (o, d) = self.cam.ray(f64::from(at.x - rect.left()), f64::from(at.y - rect.top()), f64::from(rect.width()), f64::from(rect.height()));
let Some(p) = on_plane(o, d, drag.start, drag.normal) else { return };
let by = p - drag.start;
params["by"] = json!([by.x, by.y, by.z]);
self.set_move_params(params);
}
/// The drag ends: run the move (one undo step) and keep the form for the next one.
pub fn node_move_release(&mut self) -> Result<(), String> {
if self.node_drag.take().is_none() {
return Ok(());
}
let Some(mut params) = self.move_params() else { return Ok(()) };
let by = params.get("by").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).map(|x| x * x).sum::<f64>()).unwrap_or(0.0);
if by > 0.0 {
self.run(COMMAND, params.clone())?;
}
params["by"] = json!([0.0, 0.0, 0.0]);
self.set_move_params(params);
Ok(())
}
/// Esc during a drag: nothing moves.
pub fn node_move_cancel(&mut self) {
if self.node_drag.take().is_some()
&& let Some(mut params) = self.move_params()
{
params["by"] = json!([0.0, 0.0, 0.0]);
self.set_move_params(params);
}
}
/// Scale the open Move Nodes form's radius by `factor` (Alt + scroll over the view); a form
/// without one starts at a twentieth of the mesh's size.
pub fn node_move_scale_radius(&mut self, factor: f64) -> bool {
let Some(mut params) = self.move_params() else { return false };
let radius = match params.get("radius").and_then(Value::as_f64).filter(|r| *r > 0.0 && r.is_finite()) {
Some(r) => r * factor,
None => {
let mut bounds = fieldcraft_render::Aabb3::EMPTY;
for n in self.session.model.nodes().values() {
bounds.add(Vec3::from(n.xyz));
}
bounds.diagonal() / 20.0
}
};
if !(radius > 0.0 && radius.is_finite()) {
return false;
}
params["radius"] = json!(radius);
self.set_move_params(params);
true
}
/// The open Move Nodes form drawn over the scene: the sphere of influence, the original edges
/// of the elements it changes as a ghost, and their moved edges coloured by how their scaled
/// Jacobian changed.
pub(crate) fn node_move_overlay(&self, s: &mut Scene, t: &Tokens) {
let Some(p) = self.node_move_preview() else { return };
if let Some((c, r)) = p.sphere {
scene::sphere(s, Vec3::from(c), r, theme::rgb(t.pick_hover));
}
let Some(m) = &p.moved else { return };
let model = &self.session.model;
let ghost = theme::rgb(t.text_dim);
let (worse, better, grabbed) = (theme::rgb(t.error), theme::rgb(t.ok), theme::rgb(t.pick_selected));
let moved = |n: u32| m.positions.get(&n).copied().or_else(|| model.node(n).map(|x| x.xyz)).map(Vec3::from);
let original = |n: u32| model.node(n).map(|x| Vec3::from(x.xyz));
for c in m.elements.iter().filter(|c| c.worse() || c.better()) {
let Some(e) = model.element(c.label) else { continue };
let color = if c.worse() { worse } else { better };
for edge in e.kind.edges() {
let (Some(&a), Some(&b)) = (edge.first().and_then(|&i| e.nodes.get(i)), edge.get(1).and_then(|&i| e.nodes.get(i))) else { continue };
if let (Some(pa), Some(pb)) = (original(a), original(b)) {
s.lines.push(SceneLine { a: pa, b: pb, color: ghost, width: 1.5 });
}
if let (Some(pa), Some(pb)) = (moved(a), moved(b)) {
s.lines.push(SceneLine { a: pa, b: pb, color, width: 3.0 });
}
}
}
// The grabbed nodes where they go, over everything.
if let Some(params) = self.move_params() {
for n in params.get("nodes").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64) {
if let Some(x) = u32::try_from(n).ok().and_then(moved) {
s.on_top.push(SceneLine { a: x, b: x, color: grabbed, width: 7.0 });
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A ray meets the drag plane where it should; one along the plane does not.
#[test]
fn the_drag_plane_meets_the_pointer_ray() {
let p = on_plane(Vec3::new(1.0, 2.0, 10.0), Vec3::new(0.0, 0.0, -1.0), Vec3::new(0.0, 0.0, 3.0), Vec3::Z).unwrap();
assert!(p.dist(Vec3::new(1.0, 2.0, 3.0)) < 1e-12);
let slanted = on_plane(Vec3::new(0.0, 0.0, 1.0), Vec3::new(1.0, 0.0, -1.0), Vec3::new(5.0, 5.0, 0.0), Vec3::Z).unwrap();
assert!(slanted.dist(Vec3::new(1.0, 0.0, 0.0)) < 1e-12);
assert!(on_plane(Vec3::new(0.0, 0.0, 1.0), Vec3::X, Vec3::new(0.0, 0.0, 0.0), Vec3::Z).is_none());
}
#[test]
fn the_prepared_key_leaves_out_the_displacement() {
let a = json!({"nodes": [3], "by": [1.0, 0.0, 0.0], "radius": 2.0});
let b = json!({"nodes": [3], "by": [0.0, 5.0, 0.0], "radius": 2.0});
let c = json!({"nodes": [3], "to": [0.0, 5.0, 0.0], "radius": 3.0});
assert_eq!(without_displacement(&a), without_displacement(&b));
assert_ne!(without_displacement(&a), without_displacement(&c));
}
}
+149
View File
@@ -65,6 +65,15 @@ pub fn summary_in(command: &str, v: &Value, units: UnitSystem) -> String {
plural(count("boundary_conditions") as u64, "boundary condition"),
plural(count("loads") as u64, "load")
),
"geometry.features" => {
let bodies = v["bodies"].as_array().map(Vec::as_slice).unwrap_or_default();
let n: usize = bodies.iter().map(|b| b["features"].as_array().map_or(0, Vec::len)).sum();
format!(
"{} building {}",
plural(n as u64, "feature"),
if bodies.len() == 1 { "1 body".to_string() } else { format!("{} bodies", bodies.len()) }
)
}
"geometry.bodies" => match v.as_array().map(Vec::len) {
Some(0) => "no bodies (open a design first)".to_string(),
Some(n) => plural(n as u64, "body").replace("bodys", "bodies"),
@@ -124,6 +133,18 @@ pub fn done(command: &str, label: &str, params: &Value, v: &Value) -> String {
Some(f) => format!("Showing {step} frame {f}"),
None => format!("Showing {step}, last frame"),
}),
"results.slice" => Some(match (v["slice"]["axis"].as_str(), v["slice"]["position"].as_f64()) {
(Some(axis), Some(at)) => {
let field = v.pointer("/contour/label").and_then(Value::as_str).unwrap_or("The flow");
format!("{field} on a slice at {axis} = {}", crate::describe::quantity(at))
}
_ => format!("Slice off on {step}"),
}),
"results.surface" => Some(if v["surface"] == true { "Obstacles coloured by the flow" } else { "Obstacles in their own colours" }.to_string()),
"results.streamlines" => Some(match v.pointer("/traced/streamlines").and_then(Value::as_u64) {
Some(n) => format!("Traced {}", plural(n, "streamline")),
None => "Streamlines off".to_string(),
}),
"results.clear" => Some("Back to the model".to_string()),
"results.xy" => {
let points = v.get("points").and_then(Value::as_u64).unwrap_or(0);
@@ -217,6 +238,10 @@ enum Act {
Rerun,
/// Pick a design entity in the viewport (a body, face or edge from the Bodies list).
Pick(fieldcraft_model::GeometryRef),
/// Open or close a Feature Graph row's details: (body, feature id).
Open((String, u64)),
/// Select these faces in the viewport (a Feature Graph row's).
Faces(Vec<crate::selection::Item>),
/// Select elements in the viewport and frame them (Mesh Quality's worst and invalid ones).
Elements(Vec<u32>),
/// Open Relax Nodes round an element (Mesh Quality's worst).
@@ -260,6 +285,10 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
"geometry.bodies" => bodies(ui, t, &reply.value, &mut acts),
"mesh.quality" => quality(ui, t, &reply.value, &mut acts),
"geometry.defeature.propose" => defeature_list(ui, t, &reply.value, &mut acts),
"geometry.features" => {
let hovered = feature_graph(ui, t, &reply.value, &app.features_open, &mut acts);
app.set_feature_hover(hovered);
}
_ => value(ui, t, egui::Id::new("fc_reply"), &reply.value, 0),
}
for a in acts {
@@ -274,6 +303,12 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
let _ = app.run(&reply.command, reply.params.clone());
}
Act::Pick(g) => app.select([crate::selection::Item::Geometry(g)], crate::selection::Mode::Replace),
Act::Open(key) => {
if !app.features_open.remove(&key) {
app.features_open.insert(key);
}
}
Act::Faces(items) => app.select(items, crate::selection::Mode::Replace),
Act::Elements(labels) => app.show_elements(&labels),
Act::Relax(element) => {
app.show_elements(&[element]);
@@ -367,6 +402,95 @@ fn defeature_list(ui: &mut egui::Ui, t: &Tokens, v: &Value, acts: &mut Vec<Act>)
more_than(ui, t, candidates.len(), MAX_ENTITIES);
}
/// The faces of `body` named `faces`, as selection items.
fn face_items(body: &str, faces: &[String]) -> Vec<crate::selection::Item> {
faces
.iter()
.take(MAX_ENTITIES)
.filter_map(|f| fieldcraft_model::GeometryRef::new(body, fieldcraft_model::Entity::Face(f.clone())).ok())
.map(crate::selection::Item::Geometry)
.collect()
}
/// The Feature Graph: per body, its timeline's features in order, each with its glyph, name and
/// what it does in words; a click selects the faces it made and opens its details. Returns the
/// faces of the row under the pointer, to highlight.
fn feature_graph(
ui: &mut egui::Ui,
t: &Tokens,
v: &Value,
opened: &std::collections::BTreeSet<(String, u64)>,
acts: &mut Vec<Act>,
) -> Vec<crate::selection::Item> {
use crate::feature_graph::{describe, paint_glyph};
let mut hovered = Vec::new();
let bodies = v["bodies"].as_array().map(Vec::as_slice).unwrap_or_default();
for (bi, b) in bodies.iter().enumerate() {
let body = b["body"].as_str().unwrap_or_default();
let nodes = b["features"].as_array().map(Vec::as_slice).unwrap_or_default();
let head = RichText::new(format!("{body} · {}", plural(nodes.len() as u64, "feature"))).strong();
egui::CollapsingHeader::new(head).id_salt(("fc_features_body", bi)).default_open(true).show(ui, |ui| {
for (fi, node) in nodes.iter().take(MAX_ENTITIES).enumerate() {
let f = describe(node);
let id = ui.id().with(("fc_feature", bi, fi));
let key = (body.to_string(), f.id);
let open = opened.contains(&key);
let bg = ui.painter().add(egui::Shape::Noop);
let inner = egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 4)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal_top(|ui| {
let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), egui::Sense::hover());
let accent = if f.error.is_some() { t.error } else { t.accent };
paint_glyph(ui.painter(), rect, f.kind, t.icon, accent);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.add(egui::Label::new(RichText::new(&f.name).strong().color(t.text)).truncate());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(RichText::new(format!("F{}", f.id)).small().color(t.text_dim));
});
});
ui.add(egui::Label::new(RichText::new(&f.summary).color(t.text)).wrap());
if let Some(e) = &f.error {
ui.add(egui::Label::new(RichText::new(e).color(t.error)).wrap());
}
if open {
ui.add_space(2.0);
ui.spacing_mut().item_spacing.y = 0.0;
ui.spacing_mut().interact_size.y = 0.0;
for (k, val) in &f.details {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
ui.label(RichText::new(k).small().color(t.text_dim));
ui.label(RichText::new(val).small());
});
}
}
});
});
});
let rect = inner.response.rect;
let resp = ui.interact(rect, id.with("row"), egui::Sense::click());
let hover_text = if f.faces.is_empty() { "Show its details" } else { "Select the faces it made; show its details" };
let resp = resp.on_hover_text(hover_text);
if resp.hovered() {
ui.painter().set(bg, egui::Shape::rect_filled(rect, 3.0, t.hover));
hovered = face_items(body, &f.faces);
} else if open {
ui.painter().set(bg, egui::Shape::rect_filled(rect, 3.0, t.subheading_bg));
}
if resp.clicked() {
acts.push(Act::Open(key));
if !f.faces.is_empty() {
acts.push(Act::Faces(face_items(body, &f.faces)));
}
}
}
more_than(ui, t, nodes.len(), MAX_ENTITIES);
});
}
hovered
}
/// The definition a diagnostic's subject names (``section `web` ``), when the model has it.
fn subject_definition(app: &ModelApp, subject: &str) -> Option<(Def, String)> {
let (kind, rest) = subject.split_once(" `")?;
@@ -915,6 +1039,31 @@ mod tests {
/// Propose Defeaturing stays in the panel and follows the model: switching the plate's hole
/// off fills it in the viewport's bodies and the list shows it off; on again brings it back.
#[test]
fn the_feature_graph_reads_a_plate_in_words_and_selects_a_features_faces() {
let dir = std::env::temp_dir().join(format!("fieldcraft-ui-features-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let design = dir.join("plate.solidcraft");
std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap();
let mut app = ModelApp::new(fieldcraft_engine::Session::default());
app.run("geometry.open", json!({"path": design.to_string_lossy()})).unwrap();
app.run("geometry.features", json!({})).unwrap();
let reply = app.reply.clone().expect("the graph is shown");
assert!(summary("geometry.features", &reply.value).contains("building 1 body"));
let body = reply.value["bodies"][0]["body"].as_str().unwrap().to_string();
let features: Vec<_> = reply.value["bodies"][0]["features"].as_array().unwrap().iter().map(crate::feature_graph::describe).collect();
assert!(!features.is_empty());
for f in &features {
assert!(!f.summary.contains('{') && !f.summary.is_empty(), "{f:?}");
}
let last = features.iter().rev().find(|f| !f.faces.is_empty()).expect("a feature made faces");
let items = face_items(&body, &last.faces);
assert!(!items.is_empty());
app.select(items.clone(), crate::selection::Mode::Replace);
assert_eq!(app.pick.selection.len(), items.len());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_defeature_list_switches_a_hole_off_and_on() {
let dir = std::env::temp_dir().join(format!("fieldcraft-ui-defeature-{}", std::process::id()));
+157 -22
View File
@@ -4,14 +4,16 @@
//! ([`fieldcraft_render::contour`]); animation steps the deformation through a fixed number of
//! frames per cycle (an explicit step's animation runs through its field frames, contours
//! following). A plot of a stale result (the model changed since the solve) is drawn the
//! same way and marked.
//! same way and marked. A fluid step draws the part as it is, with the engine's flow display
//! ([`fieldcraft_engine::flow`]: a slice, the obstacles coloured and streamlines) through it.
use std::sync::Arc;
use fieldcraft_engine::flow::FlowPlot;
use fieldcraft_engine::results::{Field, Kind, Pick, ResultsView, auto_scale, displacements, field_values, range};
use fieldcraft_engine::{Session, StepResult};
use fieldcraft_render::contour::{Bands, bands_color, split_triangle};
use fieldcraft_render::{DisplayMesh, Scene, SceneTri, Vec3};
use fieldcraft_render::contour::{Bands, banded, bands_color};
use fieldcraft_render::{DisplayMesh, Scene, Vec3};
use crate::Style;
use crate::theme::{Tokens, rgb};
@@ -29,10 +31,10 @@ const ROUND_OFF: f64 = 1e-9;
pub struct Contour {
pub field: Field,
pub bands: Bands,
/// The smallest and largest value and their nodes.
/// The smallest and largest value and their nodes (a fluid step's: their cells).
pub min: (u32, f64),
pub max: (u32, f64),
/// The value at every node, in the result's node order.
/// The value at every node, in the result's node order (empty for a fluid step's cells).
pub values: Vec<f64>,
}
@@ -56,6 +58,8 @@ pub struct Plot {
/// The elements as drawn (deformed at the current animation frame).
pub display: DisplayMesh,
pub contour: Option<Arc<Contour>>,
/// A fluid step's slice, coloured obstacles and streamlines.
pub flow: Option<Arc<FlowPlot>>,
/// The mode and field frame drawn.
pub pick: Pick,
/// The model's unit system, which the numbers shown with the plot are in.
@@ -67,6 +71,7 @@ pub struct Plot {
pub struct Prepared {
pub scale: f64,
pub contour: Option<Arc<Contour>>,
pub flow: Option<Arc<FlowPlot>>,
}
/// A mode's value as the results show it: its frequency, or a buckling mode's load factor.
@@ -95,6 +100,7 @@ impl Plot {
(frames.len(), &|k| frames.get(k).map(|f| f.load_factor))
}
Kind::Heat(h) if h.times.len() > 1 => (h.times.len(), &|k| h.times.get(k).copied()),
Kind::Fluid(f) => (f.frames.len(), &|k| f.frames.get(k).map(|f| f.time)),
_ => return None,
};
let k = self.pick.frame.unwrap_or(n.checked_sub(1)?);
@@ -105,6 +111,22 @@ impl Plot {
pub fn node_position(&self, label: u32) -> Option<Vec3> {
self.display.node_position(label)
}
/// Where a contour extreme is: its node's position, or a fluid step's cell centre.
pub fn extreme_position(&self, label: u32) -> Option<Vec3> {
if self.view.contour == Some(Field::WallHeatFlux) {
return self.flow.as_ref()?.wall_positions.get(label as usize).copied().flatten();
}
match self.result.kind() {
Kind::Fluid(f) => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, usize::try_from(label).ok()?))),
_ => self.node_position(label),
}
}
/// Whether it shows a fluid step (fields per cell, no deformation).
pub fn is_fluid(&self) -> bool {
matches!(self.result.kind(), Kind::Fluid(_))
}
}
/// The result the session's view shows, and whether it is current.
@@ -156,8 +178,19 @@ pub fn scale(view: &ResultsView, result: &StepResult, pick: Pick, base: &Display
/// The part of the session's results plot that depends on what is drawn (`pick`), not on the
/// animation's deformation multiplier: the deformation scale and the contour values.
pub fn prepare(session: &Session, base: &DisplayMesh, pick: Pick) -> Option<Prepared> {
pub fn prepare(session: &Session, base: &DisplayMesh, pick: Pick, bodies: &[&str]) -> Option<Prepared> {
let (view, result, _) = shown(session)?;
if matches!(result.kind(), Kind::Fluid(_)) {
let mut view = view.clone();
view.frame = pick.frame;
let flow = fieldcraft_engine::flow::plot_with_surfaces(&view, result, &session.traced, session, base, bodies).ok();
let cell = |(k, v): (usize, f64)| (u32::try_from(k).unwrap_or(u32::MAX), v);
let contour = flow
.as_ref()
.and_then(|p| p.contour.as_ref())
.map(|c| Arc::new(Contour { field: c.field, bands: c.bands, min: cell(c.min), max: cell(c.max), values: Vec::new() }));
return Some(Prepared { scale: 0.0, contour, flow: flow.map(Arc::new) });
}
let contour = view.contour.and_then(|field| {
let mut values = field_values(&session.model, result, field, pick).ok()?;
// The range (and the bands) follow what is drawn: a hidden body's nodes have no value.
@@ -169,7 +202,7 @@ pub fn prepare(session: &Session, base: &DisplayMesh, pick: Pick) -> Option<Prep
let (min, max) = range(result.nodes(), &values)?;
Some(Arc::new(Contour { field, bands: view.bands(min.1, max.1), min, max, values }))
});
Some(Prepared { scale: scale(view, result, pick, base), contour })
Some(Prepared { scale: scale(view, result, pick, base), contour, flow: None })
}
/// The plot of the session's results view on the model's display data at animation `frame`,
@@ -180,10 +213,15 @@ pub fn plot(session: &Session, base: &DisplayMesh, frame: Option<u64>, prepared:
let pick = pick(view, result, frame);
let factor = if view.deformed { prepared.scale * swing(result, frame) } else { 0.0 };
let u = displacements(result, pick).unwrap_or(&[]);
let display = base.displaced(|label| {
let i = nodes.binary_search(&label).ok()?;
u.get(i).map(|d| Vec3::from(*d) * factor)
});
// A fluid step does not move the part: it stays drawn as it is, geometry included.
let display = if matches!(result.kind(), Kind::Fluid(_)) {
base.clone()
} else {
base.displaced(|label| {
let i = nodes.binary_search(&label).ok()?;
u.get(i).map(|d| Vec3::from(*d) * factor)
})
};
Some(Plot {
view: view.clone(),
result: result.clone(),
@@ -191,30 +229,32 @@ pub fn plot(session: &Session, base: &DisplayMesh, frame: Option<u64>, prepared:
scale: prepared.scale,
display,
contour: prepared.contour.clone(),
flow: prepared.flow.clone(),
pick,
units: session.model.units(),
})
}
/// The scene of a plot: the deformed elements drawn as the model is ([`scene::build`]), with
/// their faces split into contour bands when a field is shown. Over a plot, hiding the mesh hides
/// the element edges: the result itself never vanishes with them.
/// their faces split into contour bands when a field is shown; a fluid step's part as it is with
/// the flow drawn through it ([`fieldcraft_engine::flow::draw`]). Over a plot, hiding the mesh
/// hides the element edges: the result itself never vanishes with them.
pub fn scene(plot: &Plot, style: Style, show: crate::scene::Show, t: &Tokens) -> Scene {
let (style, show) = if show.mesh { (style, show) } else { (Style::Shaded, crate::scene::Show { mesh: true, ..show }) };
let mut s = crate::scene::build(&plot.display, style, show, t);
if let Some(flow) = &plot.flow {
let stops = t.contour_stops();
let colors = fieldcraft_engine::flow::FlowColors { stops: &stops, over: rgb(t.contour_over), plain: rgb(t.streamline) };
fieldcraft_engine::flow::draw(flow, &mut s, &colors);
return s;
}
let Some(c) = plot.contour.as_ref().filter(|_| style != Style::Wireframe && show.mesh) else { return s };
let nodes = plot.result.nodes();
let stops = t.contour_stops();
let color = |band: Option<u32>| band.map_or(rgb(t.no_result), |k| bands_color(&stops, rgb(t.contour_over), &c.bands, k));
s.tris.clear();
for tri in &plot.display.tris {
split_triangle(tri.p, tri.nodes.map(|l| c.at(nodes, l)), &c.bands, &mut |piece, band| {
let color = band.map_or(rgb(t.no_result), |k| bands_color(&stops, rgb(t.contour_over), &c.bands, k));
for k in 1..piece.len().saturating_sub(1) {
if let (Some(&a), Some(&b), Some(&cc)) = (piece.first(), piece.get(k), piece.get(k + 1)) {
s.tris.push(SceneTri { p: [a, b, cc], normal: tri.normal, color });
}
}
});
banded(tri.p, tri.nodes.map(|l| c.at(nodes, l)), tri.normal, &c.bands, &color, &mut s.tris);
}
s
}
@@ -301,7 +341,8 @@ pub(crate) mod tests {
fn plot_at(s: &Session, base: &DisplayMesh, frame: Option<u64>) -> Option<Plot> {
let (view, result, _) = shown(s)?;
plot(s, base, frame, &prepare(s, base, pick(view, result, frame))?)
let bodies: Vec<&str> = s.design.iter().flat_map(|d| &d.bodies).map(|b| b.name.as_str()).collect();
plot(s, base, frame, &prepare(s, base, pick(view, result, frame), &bodies)?)
}
/// The sample cantilever, solved (static step `Load`, frequency step `Modes`).
@@ -376,6 +417,100 @@ pub(crate) mod tests {
assert!(hidden.lines.iter().all(|l| l.color != rgb(t.mesh_edge)), "no element edges");
}
/// A 40 × 30 × 10 mm plate in a tank of 5 mm cells, a liquid flowing past it along x, solved.
fn plate_in_a_tank(thermal: bool) -> Session {
use fieldcraft_geometry::sample::{Plate, plate_design};
let dir = std::env::temp_dir().join(format!("fieldcraft-ui-flow-{}-{thermal}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("plate.solidcraft");
std::fs::write(&path, plate_design(&Plate::default()).unwrap()).unwrap();
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let mut script = json!([
{"command": "model.material.create", "params": {"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}}},
{"command": "model.fluid_domain.create", "params": {"name": "Tank", "material": "Water", "min": [-20, -20, -20], "max": [60, 50, 30], "spacing": 5, "obstacles": ["Body1"],
"faces": {"x_min": {"type": "velocity", "velocity": [10, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}}}},
{"command": "model.step.create", "params": {"name": "Flow", "procedure": {"type": "fluid", "domain": "Tank", "steps": 60, "frames": 2}}},
]);
if thermal {
script[0]["params"]["thermal"] = json!({"conductivity": 1.0, "specific_heat": 1e8});
let faces: Vec<_> = s.design.as_ref().unwrap().bodies[0]
.faces()
.iter()
.map(|f| {
json!({
"face": {"body": "Body1", "face": f.name}, "condition": {"type": "temperature", "value": 60.0}
})
})
.collect();
script[1]["params"]["thermal"] =
json!({"initial_temperature": 20.0, "surfaces": faces, "faces": {"x_min": {"type": "temperature", "value": 20.0}}});
}
s.run_script(&script).unwrap();
if thermal {
s.execute("model.units", &json!({"system": "mm-t-s-N-MPa"})).unwrap();
}
s.execute("solve.fluid", &json!({})).unwrap();
s
}
/// After a fluid solve the part stays drawn and the flow is drawn through it: velocity on a
/// slice and on the part, with its legend, and streamlines (#193).
#[test]
fn a_fluid_step_keeps_the_part_and_draws_the_flow() {
let mut s = plate_in_a_tank(false);
let (base, _) = fieldcraft_engine::view::display(&s);
assert!(!base.surface_tris.is_empty());
let p = plot_at(&s, &base, None).unwrap();
assert!(p.is_fluid() && p.flow.is_some());
assert_eq!(p.display.surface_tris, base.surface_tris, "the part stays");
let legend = p.contour.clone().unwrap();
assert!(legend.field == Field::Velocity && legend.max.1 > legend.min.1, "{legend:?}");
let at = p.extreme_position(legend.max.0).unwrap();
assert!(at.x > -20.0 && at.x < 60.0, "{at:?}");
assert_eq!(p.frame().map(|(k, _, n)| (k, n)), Some((2, 3)));
let t = Tokens::light();
let scene = scene(&p, Style::ShadedEdges, crate::scene::Show::DEFAULT, &t);
assert!(scene.tris.len() > base.surface_tris.len(), "the slice is drawn");
assert!(!scene.lines.is_empty(), "streamlines and the part's edges are drawn");
// Pressure on the slice; the first frame (at rest at the inlet speed everywhere).
s.execute("results.slice", &json!({"field": "pressure", "frame": 0})).unwrap();
let p = plot_at(&s, &base, None).unwrap();
assert_eq!((p.contour.as_ref().map(|c| c.field), p.pick.frame), (Some(Field::Pressure), Some(0)));
}
#[test]
fn heated_flow_fields_have_units_colours_and_face_markers() {
let mut s = plate_in_a_tank(true);
let (base, _) = fieldcraft_engine::view::display(&s);
s.execute("results.slice", &json!({"field": "temperature"})).unwrap();
let p = plot_at(&s, &base, None).unwrap();
assert_eq!(p.contour.as_ref().unwrap().field, Field::Temperature);
assert!(Field::Temperature.title(&p.result, p.units).contains("°C or K"));
assert!(Field::available(&p.result).any(|f| f == Field::Temperature));
assert!(p.contour.as_ref().unwrap().max.1 > 20.0);
let last = p.frame().unwrap().2 - 1;
s.execute("results.frame", &json!({"frame": 0})).unwrap();
assert_eq!(plot_at(&s, &base, None).unwrap().contour.as_ref().unwrap().max.1, 20.0, "the frame selector changes the temperature shown");
s.execute("results.frame", &json!({"frame": last})).unwrap();
s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
let p = plot_at(&s, &base, None).unwrap();
let c = p.contour.as_ref().unwrap();
assert_eq!(c.field, Field::WallHeatFlux);
assert_eq!(c.field.unit(&p.result, p.units).as_deref(), Some("mW/mm²"));
assert!(c.min.1 < 0.0, "hot wall gives heat to fluid");
assert!(p.extreme_position(c.min.0).is_some());
assert!(p.flow.as_ref().unwrap().slice.is_none());
for t in [Tokens::light(), Tokens::dark()] {
let scene = scene(&p, Style::ShadedEdges, crate::scene::Show::DEFAULT, &t);
assert_eq!(scene.tris.len(), base.surface_tris.len());
assert!(scene.tris.windows(2).any(|t| t[0].color != t[1].color));
}
s.execute("results.frame", &json!({"frame": 0})).unwrap();
assert!(plot_at(&s, &base, None).unwrap().contour.is_none());
}
#[test]
fn modes_swing_and_static_steps_grow_when_animated() {
let mut s = solved_cantilever();
+198 -50
View File
@@ -1,7 +1,8 @@
//! What the viewport shows over a results plot: the legend (field and unit, bands, range,
//! deformation scale, a stale-result warning), markers at the contour's largest and smallest
//! value, and the probed node; and the results controls of the context panel (right), which run
//! the `results.*` commands. Numbers go through one formatter ([`crate::results::number`]) with
//! the `results.*` commands (a fluid step's: its slice, coloured obstacles, streamlines and
//! frames). Numbers go through one formatter ([`crate::results::number`]) with
//! the units of the model's unit system ([`Quantity`]).
use egui::{Align2, Color32, FontId, Pos2, Rect, RichText, Stroke, pos2, vec2};
@@ -40,7 +41,8 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
probe(app, &plot, &painter, rect, t);
let mut focus = None;
for (i, (line, node)) in extremes.into_iter().enumerate() {
let r = ui.interact(line, ui.id().with(("fc_legend_extreme", i)), egui::Sense::click()).on_hover_text(format!("Show and probe node {node}"));
let tip = if plot.is_fluid() { "Centre this field extreme".into() } else { format!("Show and probe node {node}") };
let r = ui.interact(line, ui.id().with(("fc_legend_extreme", i)), egui::Sense::click()).on_hover_text(tip);
if r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
@@ -53,11 +55,15 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
}
}
/// Turn the view about node `node` of the plot (keeping the zoom) and probe it.
/// Turn the view about node `node` of the plot (keeping the zoom) and probe it; a fluid step's
/// extreme is a cell, turned about only.
fn focus_node(app: &mut ModelApp, plot: &Plot, node: u32) {
if let Some(at) = plot.node_position(node).filter(|p| p.is_finite()) {
if let Some(at) = plot.extreme_position(node).filter(|p| p.is_finite()) {
app.cam.target = at;
}
if plot.is_fluid() {
return;
}
// A failure (a stale result) shows in the status bar.
let _ = app.probe_node(node);
}
@@ -81,6 +87,7 @@ fn title(plot: &Plot) -> String {
match (&plot.contour, plot.mode()) {
(Some(l), _) => l.field.title(&plot.result, plot.units),
(None, Some(_)) => "Mode shape (normalised)".into(),
(None, None) if plot.is_fluid() => plot.view.contour.map_or_else(|| "Flow".into(), |f| f.title(&plot.result, plot.units)),
(None, None) => "Deformed shape".into(),
}
}
@@ -103,7 +110,11 @@ fn extreme_lines(plot: &Plot) -> Option<[(String, u32); 2]> {
let l = plot.contour.as_ref()?;
let unit = l.field.unit(&plot.result, plot.units);
let scale = l.max.1.abs().max(l.min.1.abs());
let line = |what: &str, (node, v): (u32, f64)| (format!("{what} {} at node {node}", with_unit(number_in(v, scale), unit.as_deref())), node);
let at = |node: u32| match plot.extreme_position(node).filter(|_| plot.is_fluid()) {
Some(p) => format!("at {}", components(&[p.x, p.y, p.z], 0.0)),
None => format!("at node {node}"),
};
let line = |what: &str, (node, v): (u32, f64)| (format!("{what} {} {}", with_unit(number_in(v, scale), unit.as_deref()), at(node)), node);
Some([line("Max", l.max), line("Min", l.min)])
}
@@ -125,7 +136,9 @@ fn legend_layout(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) -
}
}
lines.push((step, t.overlay_text));
lines.push((deformation(plot), t.text_dim));
if !plot.is_fluid() {
lines.push((deformation(plot), t.text_dim));
}
if let Some(l) = plot.contour.as_ref().filter(|_| plot.view.range_min.is_some() || plot.view.range_max.is_some()) {
let scale = l.max.1.abs().max(l.min.1.abs());
lines.push((format!("Legend clamped to {} … {}", number_in(l.bands.min, scale), number_in(l.bands.max, scale)), t.warm));
@@ -206,17 +219,32 @@ fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) -> Vec<(
out
}
/// A box of `text` beside the screen point `at`, kept inside `bounds`: right of the point, or left
/// of it when it would run off the right edge; held inside top and bottom.
fn label_beside(painter: &egui::Painter, at: Pos2, text: String, bounds: Rect, t: &Tokens) {
let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.overlay_text);
let size = galley.size() + 2.0 * LABEL_PAD;
/// Where a label box of `size` goes beside the screen point `at`, kept inside `bounds`: right of
/// the point, or left of it when it would run off the right edge; held inside top and bottom.
/// When it would cover `avoid` (a label already placed), it goes under that box, or over it
/// when under runs off the bottom.
fn label_box(at: Pos2, size: egui::Vec2, bounds: Rect, avoid: Option<Rect>) -> Rect {
let right = at + vec2(LABEL_GAP, -size.y / 2.0);
let x = if right.x + size.x > bounds.right() { at.x - LABEL_GAP - size.x } else { right.x };
let y = right.y.clamp(bounds.top(), (bounds.bottom() - size.y).max(bounds.top()));
let box_rect = Rect::from_min_size(pos2(x.max(bounds.left()), y), size);
let x = if right.x + size.x > bounds.right() { at.x - LABEL_GAP - size.x } else { right.x }.max(bounds.left());
let inside = |y: f32| y.clamp(bounds.top(), (bounds.bottom() - size.y).max(bounds.top()));
let at_y = |y: f32| Rect::from_min_size(pos2(x, inside(y)), size);
let beside = at_y(right.y);
match avoid.filter(|a| a.intersects(beside)) {
None => beside,
Some(a) => {
let under = at_y(a.bottom() + LABEL_PAD.y);
if under.intersects(a) { at_y(a.top() - LABEL_PAD.y - size.y) } else { under }
}
}
}
/// A box of `text` beside the screen point `at` ([`label_box`]); returns where it went.
fn label_beside(painter: &egui::Painter, at: Pos2, text: String, bounds: Rect, avoid: Option<Rect>, t: &Tokens) -> Rect {
let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.overlay_text);
let box_rect = label_box(at, galley.size() + 2.0 * LABEL_PAD, bounds, avoid);
painter.rect_filled(box_rect, 4.0, t.overlay);
painter.galley(box_rect.left_top() + LABEL_PAD, galley, t.overlay_text);
box_rect
}
/// Markers with their values at the contour's largest and smallest value.
@@ -225,13 +253,15 @@ fn markers(app: &ModelApp, plot: &Plot, painter: &egui::Painter, rect: Rect, t:
let unit = l.field.unit(&plot.result, plot.units);
let scale = l.max.1.abs().max(l.min.1.abs());
let (high, low) = (t.contour[4], t.contour[0]);
// The Min label keeps off the Max label when the two cells are close on screen.
let mut placed = None;
for (what, (node, v), color) in [("Max", l.max, high), ("Min", l.min, low)] {
let Some(at) = plot.node_position(node).and_then(|w| crate::viewport::to_screen(app, w)) else { continue };
let Some(at) = plot.extreme_position(node).and_then(|w| crate::viewport::to_screen(app, w)) else { continue };
if !rect.contains(at) {
continue;
}
painter.circle(at, 5.0, color, Stroke::new(2.0, t.overlay_text));
label_beside(painter, at, format!("{what} {}", with_unit(number_in(v, scale), unit.as_deref())), rect, t);
placed = Some(label_beside(painter, at, format!("{what} {}", with_unit(number_in(v, scale), unit.as_deref())), rect, placed, t));
}
}
@@ -249,7 +279,7 @@ fn probe(app: &ModelApp, plot: &Plot, painter: &egui::Painter, rect: Rect, t: &T
let Some(p) = app.probe.as_ref().filter(|p| p.step == plot.view.step && p.mode == plot.pick.mode && p.frame == plot.pick.frame) else { return };
let Some(at) = plot.node_position(p.node).and_then(|w| crate::viewport::to_screen(app, w)) else { return };
painter.circle(at, 5.0, t.accent, Stroke::new(2.0, t.overlay_text));
label_beside(painter, at, p.lines.join("\n"), rect, t);
label_beside(painter, at, p.lines.join("\n"), rect, None, t);
}
/// The results controls (the context panel's Results section): step, contour field,
@@ -308,40 +338,11 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens)
ui.end_row();
}
}
ui.label("Deformed");
ui.horizontal(|ui| {
let mut deformed = plot.view.deformed;
if ui.checkbox(&mut deformed, "").changed() {
run.push(("results.deformed", json!({"show": deformed})));
}
let mut auto = plot.view.scale.is_none();
let tip = if true_scale(&plot.result) {
"Draw the displacements at true scale (they are large already)"
} else {
"Scale the largest displacement to a tenth of the model"
};
if ui.checkbox(&mut auto, "Auto").on_hover_text(tip).changed() {
run.push(("results.deformed", json!({"scale": if auto { json!("auto") } else { json!(plot.scale) }})));
}
// The scale ticking Deformed draws with, also while it is off.
let mut scale = plot.scale;
let drag = egui::DragValue::new(&mut scale)
.speed(plot.scale.abs().max(1e-6) * 0.01)
.range(0.0..=1e12)
.prefix("× ")
.custom_formatter(|v, _| number(v))
.custom_parser(|s| s.replace(',', "").trim().parse::<f64>().ok());
if ui.add_enabled(plot.view.deformed, drag).changed() {
run.push(("results.deformed", json!({"scale": scale})));
}
});
ui.end_row();
ui.label("Animate");
let mut animate = plot.view.animate;
if ui.checkbox(&mut animate, "").changed() {
run.push(("results.animate", json!({"on": animate})));
if plot.is_fluid() {
flow_rows(plot, ui, &mut run);
} else {
deformation_rows(plot, ui, &mut run);
}
ui.end_row();
ui.label("Show");
ui.horizontal(|ui| {
if plot.contour.is_some() {
@@ -354,6 +355,17 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens)
if let Kind::Explicit(e) = plot.result.kind() {
ui.separator();
explicit(ui, e, plot, t, &mut run);
} else if let Some((k, at, n)) = plot.frame().filter(|_| plot.is_fluid()) {
// A fluid step: its frames, the slice, the obstacles and the streamlines following.
ui.separator();
ui.horizontal(|ui| {
ui.label("Frame");
let mut frame = k;
if ui.add(egui::Slider::new(&mut frame, 0..=n.saturating_sub(1))).changed() {
run.push(("results.frame", json!({"frame": frame})));
}
});
ui.label(RichText::new(format!("t = {}", with_unit(number(at), Quantity::Time.unit(plot.units).as_deref()))).color(t.text_dim));
} else if let Some((k, factor, n)) = plot.frame().filter(|_| matches!(plot.result.kind(), Kind::Static(_))) {
// A nonlinear static step: its increments, the deformed shape following the slider.
ui.separator();
@@ -421,6 +433,114 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens)
}
}
/// The deformation rows of the results grid: Deformed (with its scale) and Animate.
fn deformation_rows(plot: &Plot, ui: &mut egui::Ui, run: &mut Vec<(&'static str, Value)>) {
ui.label("Deformed");
ui.horizontal(|ui| {
let mut deformed = plot.view.deformed;
if ui.checkbox(&mut deformed, "").changed() {
run.push(("results.deformed", json!({"show": deformed})));
}
let mut auto = plot.view.scale.is_none();
let tip = if true_scale(&plot.result) {
"Draw the displacements at true scale (they are large already)"
} else {
"Scale the largest displacement to a tenth of the model"
};
if ui.checkbox(&mut auto, "Auto").on_hover_text(tip).changed() {
run.push(("results.deformed", json!({"scale": if auto { json!("auto") } else { json!(plot.scale) }})));
}
// The scale ticking Deformed draws with, also while it is off.
let mut scale = plot.scale;
let drag = egui::DragValue::new(&mut scale)
.speed(plot.scale.abs().max(1e-6) * 0.01)
.range(0.0..=1e12)
.prefix("× ")
.custom_formatter(|v, _| number(v))
.custom_parser(|s| s.replace(',', "").trim().parse::<f64>().ok());
if ui.add_enabled(plot.view.deformed, drag).changed() {
run.push(("results.deformed", json!({"scale": scale})));
}
});
ui.end_row();
ui.label("Animate");
let mut animate = plot.view.animate;
if ui.checkbox(&mut animate, "").changed() {
run.push(("results.animate", json!({"on": animate})));
}
ui.end_row();
}
/// A fluid step's rows of the results grid: the slice (its normal, position), the obstacles'
/// colours and the streamlines (their count from the inlet).
fn flow_rows(plot: &Plot, ui: &mut egui::Ui, run: &mut Vec<(&'static str, Value)>) {
use fieldcraft_engine::flow::{Axis, slice_position};
let (Kind::Fluid(f), Some(flow)) = (plot.result.kind(), plot.flow.as_ref()) else { return };
if plot.view.contour == Some(Field::WallHeatFlux) {
ui.label("Wall heat");
ui.label("Positive into wall; face average");
ui.end_row();
if plot.contour.is_none() {
ui.label("History");
ui.label("No wall heat recorded at this frame");
ui.end_row();
}
}
let view = &flow.view;
ui.label("Slice");
ui.horizontal(|ui| {
if plot.view.contour == Some(Field::WallHeatFlux) {
ui.label("Hidden for wall heat");
return;
}
let shown = view.slice.as_ref().map_or("None", |s| s.axis.id());
egui::ComboBox::from_id_salt("fc_results_slice").selected_text(format!("Normal {}", shown.to_uppercase())).width(90.0).show_ui(ui, |ui| {
if ui.selectable_label(view.slice.is_none(), "None").clicked() {
run.push(("results.slice", json!({"axis": "none"})));
}
for a in Axis::ALL {
if ui.selectable_label(view.slice.as_ref().is_some_and(|s| s.axis == a), format!("Normal {}", a.id().to_uppercase())).clicked() {
run.push(("results.slice", json!({"axis": a.id()})));
}
}
});
if let Some(s) = &view.slice {
let (lo, hi) = (flow.grid.start(s.axis.index()), flow.grid.end(s.axis.index()));
let mut at = slice_position(f, s);
let drag = egui::DragValue::new(&mut at)
.speed((hi - lo).abs().max(1e-9) * 0.002)
.range(lo..=hi)
.custom_formatter(|v, _| number(v))
.custom_parser(|s| s.replace(',', "").trim().parse::<f64>().ok());
if ui.add(drag).on_hover_text("Where the slice crosses its axis").changed() {
run.push(("results.slice", json!({"position": at})));
}
}
});
ui.end_row();
ui.label("Obstacles");
let mut surface = view.surface;
if ui.checkbox(&mut surface, "Coloured").on_hover_text("Colour the part by the selected fluid field or wall heat").changed() {
run.push(("results.surface", json!({"show": surface})));
}
ui.end_row();
ui.label("Streamlines");
ui.horizontal(|ui| {
let mut on = view.streamlines.is_some();
if ui.checkbox(&mut on, "").on_hover_text("Streamlines from the inlet, coloured by speed or temperature when selected").changed() {
run.push(("results.streamlines", json!({"show": on})));
}
if let Some(sl) = &view.streamlines {
let mut count = sl.count;
let drag = egui::DragValue::new(&mut count).range(1..=fieldcraft_engine::flow::MAX_STREAMLINES).speed(1.0).suffix(" seeds");
if ui.add(drag).on_hover_text("Seeds on the inlet face, upstream of the obstacles").changed() {
run.push(("results.streamlines", json!({"count": count})));
}
}
});
ui.end_row();
}
/// The probed node's values, one row each, or how to probe one; the export's parameters when
/// its Export CSV… is clicked.
fn probed(app: &ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens) -> Option<Value> {
@@ -819,3 +939,31 @@ fn reactions_table(ui: &mut egui::Ui, r: &Value, plot: &Plot, t: &Tokens) {
ui.label(RichText::new(text).small().color(color));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A label goes right of its point, left when right runs off, inside the top and bottom;
/// one that would cover a placed label goes under it, or over it at the bottom edge.
#[test]
fn labels_keep_inside_the_view_and_off_each_other() {
let bounds = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0));
let size = vec2(80.0, 20.0);
let a = label_box(pos2(100.0, 150.0), size, bounds, None);
assert_eq!(a, Rect::from_min_size(pos2(100.0 + LABEL_GAP, 140.0), size));
assert_eq!(label_box(pos2(380.0, 150.0), size, bounds, None).min, pos2(380.0 - LABEL_GAP - 80.0, 140.0));
assert_eq!(label_box(pos2(100.0, 2.0), size, bounds, None).min.y, 0.0);
assert_eq!(label_box(pos2(100.0, 299.0), size, bounds, None).max.y, 300.0);
// Far apart: unmoved. Overlapping: under the placed one, apart by the label padding.
assert_eq!(label_box(pos2(100.0, 250.0), size, bounds, Some(a)), Rect::from_min_size(pos2(100.0 + LABEL_GAP, 240.0), size));
let b = label_box(pos2(104.0, 156.0), size, bounds, Some(a));
assert_eq!(b.min, pos2(104.0 + LABEL_GAP, a.max.y + LABEL_PAD.y));
assert!(!b.intersects(a));
// Placed at the bottom edge: the second goes over it.
let low = label_box(pos2(100.0, 299.0), size, bounds, None);
let over = label_box(pos2(100.0, 295.0), size, bounds, Some(low));
assert_eq!(over.max.y, low.min.y - LABEL_PAD.y);
assert!(!over.intersects(low));
}
}
+1
View File
@@ -20,6 +20,7 @@ const THUMBNAILS: &[(&str, &[u8])] = &[
("heated-bracket", include_bytes!("../thumbnails/heated-bracket.png")),
("bracket-hex", include_bytes!("../thumbnails/bracket-hex.png")),
("flow-past", include_bytes!("../thumbnails/flow-past.png")),
("heated-flow", include_bytes!("../thumbnails/heated-flow.png")),
("cantilever", include_bytes!("../thumbnails/cantilever.png")),
("impact", include_bytes!("../thumbnails/impact.png")),
("parts", include_bytes!("../thumbnails/parts.png")),
+20
View File
@@ -62,6 +62,9 @@ pub struct Tokens {
pub line_element: Color32,
/// Element edges (the mesh wireframe).
pub mesh_edge: Color32,
/// Streamlines when the legend shows another field than velocity: stands out from the
/// viewport background.
pub streamline: Color32,
pub node: Color32,
// ---- geometry ----
/// Faces of the open design and imported surfaces (SolidCraft's body colour).
@@ -149,6 +152,7 @@ impl Tokens {
shell: Color32::from_rgb(0xc0, 0x8c, 0x78),
line_element: Color32::from_rgb(0xc8, 0x8c, 0x1e),
mesh_edge: Color32::from_rgb(0x1e, 0x23, 0x2a),
streamline: Color32::from_rgb(0x28, 0x2e, 0x38),
node: Color32::from_rgb(0xb4, 0x3c, 0x8c),
geometry: Color32::from_rgb(176, 186, 198),
geometry_edge: Color32::from_rgb(40, 44, 52),
@@ -216,6 +220,7 @@ impl Tokens {
t.shell = Color32::from_rgb(0xc8, 0x96, 0x82);
t.line_element = Color32::from_rgb(0xf0, 0xc0, 0x50);
t.mesh_edge = Color32::from_rgb(0x14, 0x18, 0x1e);
t.streamline = Color32::from_rgb(0xd8, 0xde, 0xe8);
t.node = Color32::from_rgb(0xf2, 0xe6, 0x6e);
t.geometry = Color32::from_rgb(152, 156, 162);
t.geometry_edge = Color32::from_rgb(16, 16, 18);
@@ -272,6 +277,7 @@ impl Tokens {
shell: rgb(self.shell),
line_element: rgb(self.line_element),
mesh_edge: rgb(self.mesh_edge),
streamline: rgb(self.streamline),
node: rgb(self.node),
geometry: rgb(self.geometry),
geometry_edge: rgb(self.geometry_edge),
@@ -445,6 +451,20 @@ mod tests {
}
}
/// Streamlines in one colour (the legend showing pressure) stand out from the viewport
/// background in both themes (#193 review: they were near-black on the dark one).
#[test]
fn streamlines_stand_out_from_the_background() {
let luma = |c: Color32| i32::from(c.r()) * 3 + i32::from(c.g()) * 6 + i32::from(c.b());
for dark in [true, false] {
let t = Tokens::get(dark);
for bg in [t.viewport_top, t.viewport_bottom] {
assert!((luma(t.streamline) - luma(bg)).abs() > 1200, "dark={dark}");
}
assert_eq!(t.palette().streamline, rgb(t.streamline));
}
}
/// The selection and hover colours differ from each other, from every element, geometry,
/// node and symbol colour, and from the background.
#[test]
+42 -4
View File
@@ -40,6 +40,7 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) {
app.apply_reframe();
let response = ui.allocate_rect(rect, Sense::click_and_drag());
let boxing = app.pick.box_mode && app.pick.filter.boxes();
node_drag(app, ui, &response, boxing);
navigate(app, ui, &response, rect, boxing);
if boxing && app.viewport.drag.is_none() {
box_drag(app, ui, &response);
@@ -172,6 +173,41 @@ fn box_drag(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response) {
}
}
/// The Move Nodes tool: with its form open, a plain left drag that starts on the mesh moves the
/// node under the press in the screen plane ([`crate::node_move`]); release runs the move.
fn node_drag(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response, boxing: bool) {
if response.drag_started_by(egui::PointerButton::Primary)
&& app.moving_nodes()
&& !boxing
&& mode(ui.input(|i| i.modifiers)) == Mode::Replace
&& let Some(at) = ui.input(|i| i.pointer.press_origin()).or(response.interact_pointer_pos())
{
app.node_move_grab(at);
}
// A changed form is prepared once it settles (here, before the overlay is drawn): draw
// again then.
if app.moving_nodes() {
app.node_move_preview();
}
if app.node_move_pending() {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::node_move::DEBOUNCE));
}
if app.node_drag.is_none() {
return;
}
// The release is handled on the next frame even when no more input comes.
ui.ctx().request_repaint();
if response.dragged_by(egui::PointerButton::Primary)
&& let Some(at) = response.interact_pointer_pos()
{
app.node_move_drag(at);
}
if response.drag_stopped_by(egui::PointerButton::Primary) || !response.dragged() {
// A failure shows in the status bar.
let _ = app.node_move_release();
}
}
/// What a click would pick under the pointer, worked out again when the pointer, the camera, the
/// scene or the filter change.
fn hover(app: &mut ModelApp, response: &egui::Response) {
@@ -351,7 +387,8 @@ fn navigate(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response, rect:
(egui::PointerButton::Middle, NavButton::Middle),
(egui::PointerButton::Secondary, NavButton::Right),
] {
if response.drag_started_by(pressed) {
// A Move Nodes drag is no navigation.
if response.drag_started_by(pressed) && app.node_drag.is_none() {
let at = press.or(response.interact_pointer_pos());
let preset = navigation::preset(&app.ui.nav_preset);
// The pick is needed only for an orbit's pivot and an unbound Ctrl + left drag.
@@ -389,9 +426,10 @@ fn navigate(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response, rect:
let options = ui.ctx().options(|o| o.input_options);
let (scroll, pinch, at) = ui.input(|i| (wheel_points(i, &options), i.zoom_delta(), i.pointer.hover_pos()));
let factor = (-f64::from(scroll) * ZOOM_PER_POINT * zoom_sign(app)).exp() / f64::from(pinch).max(1e-3);
// Alt + scroll sizes the Relax Nodes sphere instead of zooming.
let sized =
app.relaxing() && ui.input(|i| i.modifiers.alt) && scroll != 0.0 && app.relax_scale_radius((f64::from(scroll) * ZOOM_PER_POINT).exp());
// Alt + scroll sizes the Relax Nodes sphere, or the Move Nodes radius, instead of zooming.
let alt = ui.input(|i| i.modifiers.alt) && scroll != 0.0;
let step = (f64::from(scroll) * ZOOM_PER_POINT).exp();
let sized = alt && ((app.relaxing() && app.relax_scale_radius(step)) || (app.moving_nodes() && app.node_move_scale_radius(step)));
if !sized && (factor - 1.0).abs() > 1e-9 {
let anchor = at.and_then(|p| {
app.surface_point(p).or_else(|| app.cam.target_plane_point(f64::from(p.x - rect.left()), f64::from(p.y - rect.top()), w, h))
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+9
View File
@@ -1,6 +1,15 @@
[advisories]
unmaintained = "all"
yanked = "warn"
# zip 3 comes only through solidcraft-io (pinned rev, still on zip 3; our own zip is 9.0.4,
# which is fixed). All three are in `ZipArchive::extract` / `ZipStreamReader::extract`, which
# neither FieldCraft nor solidcraft-io calls: 3MF parts are read by name into memory. Drop these
# once SolidCraft moves to a fixed zip.
ignore = [
{ id = "RUSTSEC-2026-0367", reason = "zip 3 via solidcraft-io; extract() is never called" },
{ id = "RUSTSEC-2026-0368", reason = "zip 3 via solidcraft-io; extract() is never called" },
{ id = "RUSTSEC-2026-0369", reason = "zip 3 via solidcraft-io; extract() is never called" },
]
[bans]
multiple-versions = "warn"
+32 -16
View File
@@ -24,15 +24,16 @@ crates in strictly lower layers.
| L1 | `fieldcraft-mesh` | Surface preparation (weld, watertightness, feature edges), robust predicates, Delaunay tetrahedralization and refinement, Voronoi-based optimization, hex meshing, quality metrics. | started |
| L1 | `fieldcraft-fem` | Element kernels shared by every structural solver: the element table (tet4, tet10, wedge6, hex8 full and incompatible modes, hex20 full and reduced), shape functions, quadrature, element stiffness, consistent and lumped mass, body and pressure loads, stresses and their extrapolation, isotropic and orthotropic elasticity, the uniform-strain (one-point) hex8 and tet4 kinematics, hourglass shape vectors and element frequency bounds of the explicit solver, objective stress rates and J2/Johnson–Cook return mapping. Pure functions, no solver loop. | started |
| L1 | `fieldcraft-decks` | Abaqus input, Nastran bulk data and LS-DYNA keyword readers and writers, to and from `model`. Text out, no file system; `deck.write` saves it. | started |
| L1 | `fieldcraft-render` | Display data without a GPU dependency: the visible faces, element edges and nodes of a model, the orbit camera, a CPU rasterizer for headless snapshots, geometry surfaces with their face boundaries, deformed shapes and banded contour plots of nodal results, picking. | started |
| L1 | `fieldcraft-render` | Display data without a GPU dependency: the visible faces, element edges and nodes of a model, the orbit camera, a CPU rasterizer for headless snapshots, geometry surfaces with their face boundaries, deformed shapes and banded contour plots of nodal results, slices, surface values and streamlines of flow fields on grids of cells, picking. | started |
| L2 | `fieldcraft-solver` | Implicit solver: assembly from the resolved model, boundary conditions, sparse direct solution, linear static and modal analysis, stresses and reactions. | started |
| L2 | `fieldcraft-explicit` | Explicit dynamics: central-difference time integration with lumped mass, one-point hex8 with hourglass control, nodal-pressure tet4, bulk viscosity, rigid walls, large deformation with objective rates, plasticity, damage and erosion, energy account, histories and field frames. | started |
| L2 | `fieldcraft-lbm` | Lattice Boltzmann flow solver on the CPU: D3Q19 two-relaxation-time scheme, bounce-back and slip walls, Zou–He inlets and outlets, obstacles voxelised from the face-tagged surface with exact predicates, momentum-exchange forces per face. Uses `mesh` for the predicates and the boundary surface. | started |
| L2 | `fieldcraft-lbm` | Lattice Boltzmann flow solver on the CPU: D3Q19 two-relaxation-time scheme, bounce-back and slip walls, Zou–He inlets and outlets, obstacles voxelised from the face-tagged surface with exact predicates, momentum-exchange forces per face; a D3Q7 temperature lattice carried by the flow (held temperatures, heat fluxes, adiabatic faces, Boussinesq buoyancy, the heat into every face). Uses `mesh` for the predicates and the boundary surface. | started |
| L3 | `fieldcraft-fsi` | Fluid–structure coupling between the L2 solvers (later). | planned |
| L4 | `fieldcraft-doc` | The document: geometry, mesh, model, results, history. | planned |
| L5 | `fieldcraft-io` | `.fieldcraft` files (docs/formats/fieldcraft.md): text model definition, binary mesh, geometry references with content hashes, the journal; atomic saves; autosave storage for crash recovery; read and write roots that confine an automation client's file access (`roots`). Bytes in, bytes out; the file system only in `fs`, `recovery` and `roots` (in the browser, files kept in memory: `memory`). Result files later. | started |
| L6 | `fieldcraft-engine` | Commands (`CommandSpec`), `Session::execute` with rollback and the panic guard, scripts; every file a command names checked against the session's roots (`Session::file_access`); headless pictures of the session (`view`: model, geometry, deformed shape and contours on the CPU rasterizer). | started |
| L7 | `fieldcraft-gpu` | The viewport's GPU pipelines (lit faces, wide lines, MSAA, depth) without a window toolkit, the adapter policy (`FIELDCRAFT_GPU`: hardware GPU, software GPU, CPU rasterizer) and offscreen pictures (`fieldcraft-cli snapshot`). The window draws through the same pipelines. | started |
| L7 | `fieldcraft-lbm-gpu` | The lattice Boltzmann solver's GPU backend: the CPU scheme's step (TRT collision with Guo forcing, the per-link wall rules, Zou–He boundaries, momentum exchange, and the temperature lattice with its wall conditions, buoyancy and heat exchange) as wgpu compute shaders, driven through `lbm`'s `Backend` trait from the flow the CPU prepared; single precision, one population buffer streamed in place (the AA pattern, `docs/lbm-gpu-performance.md`), deterministic per device, checked against the CPU within its tests' tolerances; opt-in per solve (`solve.fluid` with `backend: "gpu"`). Opens its device with `gpu`'s adapter policy. | started |
| L7 | `fieldcraft-ui-egui`, `fieldcraft-mcp` | Desktop UI in the Craft family's layout (title bar with quick actions, workspace ribbon from the catalogue, browser, viewport with navigation bar and orientation gizmo, context panel, command search and command line, journal timeline; native file pickers as host services, background opening and solving; results display with legend and probe; offscreen capture of the whole window) and control channel requests (docs/control-protocol.md); MCP server over stdio (docs/mcp.md): tools over engine commands, a headless session or the running app through its control channel, read and write roots. | ui, mcp started |
| testkit | `fieldcraft-testkit` | Test tooling, a dev-dependency only: the meshing test corpus (`tests/corpus/*/recipe.json`, analytic shapes) and its runner (`cargo xtask corpus`). | started |
| apps | `fieldcraft`, `fieldcraft-cli`, `fieldcraft-web` | Desktop, command line, browser (docs/web.md). | started |
@@ -308,21 +309,26 @@ cantilever's free-vibration period against `solve.frequency`.
## Lattice Boltzmann solver
`fieldcraft-lbm` solves a model's fluid steps (`solve.fluid`; `solve.probe` reads velocity and
pressure at points and wall force histories). Papers are cited in the module docs and in
`docs/solvers/literature.md` (Part I).
`fieldcraft-lbm` solves a model's fluid steps (`solve.fluid`; `solve.probe` reads velocity,
pressure and temperature at points and wall force and heat histories). Papers are cited in the
module docs and in `docs/solvers/literature.md` (Part I).
1. **Model.** A fluid material (density, Newtonian viscosity), a fluid domain (box, cubic cell
spacing, a condition on each box face: wall, moving wall, slip, velocity inlet with a uniform
or parabolic profile, pressure outlet, periodic pair; obstacle bodies and moving-wall
conditions on their named faces; initial velocity; body acceleration) and a fluid step (end
1. **Model.** A fluid material (density, Newtonian viscosity; conductivity and specific heat
for heat), a fluid domain (box, cubic cell spacing, a condition on each box face: wall,
moving wall, slip, velocity inlet with a uniform or parabolic profile, pressure outlet,
periodic pair; obstacle bodies and moving-wall conditions on their named faces; initial
velocity; body acceleration; optional `thermal` settings: the initial temperature, a thermal
condition per box face and per named obstacle face — held temperature, heat flux or
adiabatic — and gravity with the expansion coefficient for buoyancy) and a fluid step (end
time or steps, optional time step, frames, the TRT parameter Λ). Deck writers refuse fluid
steps.
2. **Setup.** Lattice units from the spacing, the time step and the density. Without a given
time step the solver keeps the characteristic lattice velocity at 0.05 and τ ≤ 1. A lattice
Mach number above 0.3, τ below 0.505 or a lattice acceleration above 0.01 is refused with the
numbers and what to change; caps on cells, steps, cell updates and frame memory are checked
before anything is allocated.
before anything is allocated. With heat, the scalar relaxation time follows from the
diffusivity k / (ρ c) in lattice units (refused below 0.505, warned above 2) and a flux on an
inlet, outlet or periodic face is refused.
3. **Grid.** The engine triangulates each obstacle body (or takes an imported surface) and
prepares its `BoundarySurface`; cells whose centres have a positive winding number are solid,
decided with exact `orient2d`/`orient3d` and one symbolic perturbation, so faces through cell
@@ -331,17 +337,27 @@ pressure at points and wall force histories). Papers are cited in the module doc
direction with per-link rules for the cells next to walls and box faces; Zou–He on inlet and
outlet layers. Wall links give the momentum exchanged with every box face and B-rep face
(the face of the triangle nearest the link). A non-positive density or a lattice speed above
0.5 stops the run with an error.
5. **Results.** Frames of velocity and gauge pressure per cell (single precision), force
histories per wall and the fluid mass, in model units, stored per step like the implicit
results.
0.5 stops the run with an error. With heat, a D3Q7 BGK scalar lattice (weights ¼ and ⅛,
c_s² = ¼) steps after the flow on the same links: it is advected by the cell velocity and
diffuses with the scalar relaxation time; held temperatures are anti-bounce-back, adiabatic
faces bounce-back, a flux is added to the bounced population; velocity inlets carry the
held temperature in at the cell's velocity, pressure outlets copy their neighbour. The heat
crossing every wall link is summed per face as the flow's force is. The temperature feeds
back through a per-cell Boussinesq force −β (T − T₀) g.
5. **Results.** Frames of velocity, gauge pressure and (with heat) temperature per cell (single
precision), force and heat histories per wall and the fluid mass, in model units, stored
per step like the implicit results.
Verification (`crates/lbm/tests/verification.rs`, analytic): plane Poiseuille exact to round-off
with Λ = 3/16 and second order with Λ = 1/4; Couette exact with the wall shear μU/H; Taylor–Green
decay second order; Poiseuille in a staircase pipe; Stokes drag of a simple cubic sphere array
against Hasimoto (1959); a parabolic-inlet, pressure-outlet channel against Poiseuille; mass
conserved to round-off; bit-for-bit reproducibility. `crates/lbm/tests/benchmark.rs` (ignored,
published reference values): Schäfer–Turek 2D-1.
conserved to round-off; bit-for-bit reproducibility. `crates/lbm/tests/thermal.rs` (heat):
linear conduction between held plates to round-off; a flux wall against q H / k and the wall's
heat balance; an inlet temperature carried through a plug flow; a periodic blob that keeps its
total and returns after one lap; (ignored, minutes) the de Vahl Davis cavity at Ra 10³ and 10⁴
against the published Nusselt numbers (both within 0.1 %). `crates/lbm/tests/benchmark.rs` (ignored, published
reference values): Schäfer–Turek 2D-1.
## Commands
+26 -1
View File
@@ -129,7 +129,7 @@ files named on the command line go through native file pickers and open in the b
`engine.execute` of a solve (`solve.run`, `solve.static`, `solve.nonlinear`, `solve.heat`,
`solve.frequency`, `solve.buckle`, `solve.explicit`, `solve.fluid`) or a mesher (`mesh.tet.generate`,
`mesh.hex.*`) runs it off the window thread, as the Solve button and a mesher form's OK do: the
`mesh.hex.*`, `mesh.node.relax`, `mesh.plan.apply`, `mesh.cells`) or a partition (`geometry.partition`) runs it off the window thread, as the Solve button and a mesher form's OK do: the
window keeps drawing its progress (step, stage and count, elapsed time) with Cancel. The reply is
the command's result when it ends, however long that takes; with `"wait": false` it is
`{"job": n}` at once, and `job.status`, `job.wait` and `job.cancel` follow it. Cancel (the button
@@ -139,3 +139,28 @@ factorization's supernodes, the tet mesher's refinement and improvement passes,
attempts) and stops with `cancelled`, leaving the model unchanged. One job runs at a
time; another long command meanwhile is refused, naming the job. The MCP server's `solve` and
`mesh` tools wait for the reply.
## Heated flow results
A solved fluid step with temperature in every saved frame offers `temperature` in the
Results field list and through `results.contour`, `results.slice`, `results.surface` and
`results.streamlines` (`{"field":"temperature"}`). Slices and obstacle surfaces sample
fluid-cell temperature; streamline colours sample temperature along the velocity traces.
The legend and existing `solve.probe` use the model's temperature scale, without conversion.
An isothermal step hides the field and rejects an explicit request with an explanation.
The frame slider and `results.frame` select the field's time, including the initial frame.
`results.surface {"field":"wall_heat_flux","frame":10}` shows the signed heat received
by each obstacle face divided by that face's complete tessellated geometric area. Positive
values heat the wall; a hot wall heating the fluid is negative. This is a face average,
not a resolved local distribution. The legend uses the model's consistent power/area unit:
for mm–tonne–second it is mW/mm² (not W/mm²); other systems follow their own units.
Wall heat uses the recorded face-group histories, linearly interpolated at the shown frame
only between recorded times. The initial frame precedes the history and remains uncoloured;
no heat is invented or extrapolated. Recorded adiabatic faces are zero. Unrecorded/imported
faces remain in their own colour. The command reply includes `wall_heat.faces` with body,
face, area, power and flux (null when unavailable), plus the sign and sampling convention.
This surface-only field suppresses the slice and leaves streamlines plain; selecting a cell
field restores the saved slice settings. `results.contour` also selects the wall field and
controls its bands/range. The desktop and headless picture use the same mapping and palette.
+24 -3
View File
@@ -6,7 +6,7 @@ press **Solve** (`solve.run`). Each opens fully defined — geometry, mesh, mate
conditions, loads and steps — in mm, t, s, N and MPa, and opens and solves within half a minute. The geometry is our
own: SolidCraft scripts run when the sample opens (a recipe from [`tests/corpus`](../tests/corpus)
or one written in `crates/engine/src/sample.rs`). Each sample has a test in
`crates/engine/src/sample.rs` that opens it, checks it clean, solves it and holds its result to
`crates/engine/src/sample.rs` or its modules that opens it, checks it clean, solves it and holds its result to
the check given below.
## Drop test (`drop-test`)
@@ -67,8 +67,10 @@ Reynolds number and rises as it falls); the lattice's mass changes by less than
![The L-bracket the air flows past, as the sample opens](media/examples/flow-past.png)
The picture shows the bracket only: the viewport does not draw flow results yet (velocity
slices and streamlines are planned in M12), so the results are numbers for now.
The picture shows the bracket as the sample opens. After Solve the viewport draws the flow:
velocity magnitude on a slice through the middle of the box (`results.slice`; pressure with
`field: "pressure"`), the bracket coloured by the flow beside it (`results.surface`) and
streamlines seeded upstream of it, coloured by speed (`results.streamlines`).
## Heated bracket (`heated-bracket`)
@@ -198,3 +200,22 @@ SolidCraft geometry with persistent face names, swept all-hex (hex8 of 4 mm, inc
clamped at `F2:start` and loaded with 1000 N down through a kinematic coupling to `F2:top`.
**Look at:** the bending stress in the flanges, highest at the clamp; the tip moves 0.18 mm down
(beam theory with shear: 0.184 mm).
## Heated flow past a bracket (`heated-flow`)
Air at 20 °C enters at 1 mm/s and passes the original upright bracket, whose faces are
held at 80 °C. The 130 × 80 × 70 mm domain has 5 mm cells (5,824 cells), a pressure outlet,
slip sides and top, and a no-slip floor. There are 180 steps and six saved output intervals,
plus the initial frame. Open `file.sample {"name":"heated-flow"}` and solve on CPU or GPU.
The slow prescribed flow keeps this coarse lattice away from its small-relaxation-time limit.
**Look at:** Results → Temperature, or `results.slice {"field":"temperature"}`, for the
warming air. `results.surface {"field":"wall_heat_flux"}` shows heat received by each
bracket face per area: the hot wall heating the air is negative. In this model's mm–tonne–second
units the wall flux is mW/mm². The initial frame has no recorded wall heat and stays uncoloured.
**Check:** the fluid warms above its inlet temperature while staying below the held wall
temperature (with a small numerical tolerance); recorded hot walls give heat to the fluid.
This coarse, short transient is a quick display demonstration, not a converged convection
benchmark. It has neither buoyancy nor solid heat conduction. The sample is isolated in
`crates/engine/src/sample/heated_flow.rs` and its gallery registration.
+5 -4
View File
@@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
84 ready, 37 coming soon.
86 ready, 36 coming soon.
## Geometry
@@ -55,6 +55,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Improve | Untangle — Repair inverted elements | coming soon (M6) | |
| Improve | Layers — Boundary padding and layers through thin walls | coming soon (M6) | |
| Improve | Relax Nodes — Relax the nodes inside a sphere to fix spikes and slivers; surface nodes slide on the geometry; kept as a field to apply again after a remesh | ready | `mesh.node.relax`, `mesh.node.relax.apply` |
| Improve | Move Nodes — Drag nodes of the mesh, the nodes round them following by a falloff or harmonically, on the geometry; the original drawn as a ghost and the elements coloured by how their quality changes | ready | `mesh.node.move` |
| Edit | Node — Add a node | ready | `mesh.node.add`, `mesh.node.remove` |
| Edit | Element — Add an element on existing nodes | ready | `mesh.element.add`, `mesh.element.remove`, `mesh.clear` |
| Edit | Mirror Mesh — Mirror a body's mesh across a plane, joined on the plane (mesh half a symmetric part) | ready | `mesh.mirror` |
@@ -108,7 +109,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Boundary Conditions | Boundary Condition — Fixed, displacement or symmetry condition on a set or reference point | ready | `model.boundary_condition.create`, `model.boundary_condition.edit`, `model.boundary_condition.delete`, `model.boundary_condition.symmetry` |
| Loads | Load — Force, moment, pressure, traction, gravity or body force | ready | `model.load.create`, `model.load.edit`, `model.load.delete` |
| Flow | Fluid Domain — The box of fluid cells, its fluid, and the obstacle bodies voxelised from their face-tagged surfaces | ready | `model.fluid_domain.create`, `model.fluid_domain.edit`, `model.fluid_domain.delete` |
| Flow | Inlet & Outlet — Velocity inlets, pressure outlets, walls, moving and slip walls, periodic faces | ready | `model.fluid_domain.boundary` |
| Flow | Inlet & Outlet — Velocity inlets, pressure outlets, walls, moving and slip walls, periodic faces; held temperatures, heat fluxes and adiabatic faces | ready | `model.fluid_domain.boundary`, `model.fluid_domain.thermal_boundary` |
## Interactions
@@ -141,7 +142,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Implicit | Frequency — Natural frequencies and mode shapes of a frequency step | ready | `solve.frequency` |
| Explicit | Explicit — Transient dynamics of an explicit step by central differences (contact and erosion coming in M10) | ready | `solve.explicit` |
| Explicit | Blast & Impact — EOS-driven fluids with fluid–structure contact (ALE or SPH) | coming soon (M14) | |
| Fluids | Lattice Boltzmann — Lattice Boltzmann flow of a fluid step on the CPU, with wall forces | ready | `solve.fluid` |
| Fluids | Lattice Boltzmann — Lattice Boltzmann flow of a fluid step on the CPU or a GPU, with wall forces, and the heat it carries | ready | `solve.fluid` |
| Fluids | Fluid–Structure — Coupled fluid and structure solve | coming soon (M13) | |
## Results
@@ -159,7 +160,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Plots | Path Plot — Plot a quantity along a path | coming soon (M9) | |
| Plots | Cut — Section cuts through the model | coming soon (M9) | |
| Plots | Display Group — Show and hide parts of the model | coming soon (M9) | |
| Plots | Streamlines — Streamlines and flow fields | coming soon (M12) | |
| Plots | Streamlines — A fluid step's velocity, pressure or temperature on slices and obstacles, speed or temperature along streamlines, and signed wall heat per area | ready | `results.streamlines`, `results.slice`, `results.surface` |
| Files | Export VTU — Write a solved step as VTK unstructured grids for ParaView | ready | `results.export.vtu` |
| Files | Read Results — Read results in public formats | coming soon (M9) | |
| Files | Convergence — Mesh convergence studies | coming soon (M9) | |
+49
View File
@@ -0,0 +1,49 @@
# GPU lattice Boltzmann: streaming layout and performance
The momentum populations stream in one of two layouts, chosen per flow with
`fieldcraft_lbm_gpu::Options::streaming`:
- **In place** (the AA pattern: Bailey, Myre, Walsh, Lilja and Saar, "Accelerating lattice
Boltzmann fluid flow simulations using graphics processors", ICPP 2009; Wittmann, Zeiser, Hager
and Wellein, Comput. Math. Appl. 65 (2013) 924–935, §6). One population buffer. Even steps read
population `q` of cell `x` at `(q, x)` and write the collided one at `(opposite(q), x)`; odd steps
read it at `(opposite(q), x − c_q)` and write it at `(q, x + c_q)`. In both, a cell writes exactly
the addresses it read, so cells never race, and streaming happens inside the collision: 152
population bytes per cell update.
- **Two buffers** (A–B). Collide in place, then pull into the other buffer: 304 bytes per update.
The wall, reflection and open-face rules are the same per-link rules in both. In place, a gather
pass reads every link's post-collision source into a scratch value before a scatter pass writes,
so no link overwrites another's source; the momentum exchange is taken in the gather of the step
whose forces are read back (a submission's last). The temperature lattice is two-buffer in both.
`Streaming::Auto`, what a solve uses, streams in place: it was faster on every device measured
(the ignored throughput test, million cell updates per second, means of three runs; the 4090 row
is from the first version of this layout, before its addressing was made cheaper):
| device | case | in place | two buffers |
|---|---|---|---|
| RTX 4090, Vulkan | periodic 128³ | 3897 | 2578 |
| RTX 4090, Vulkan | sphere 128³ | 3572 | 2441 |
| GB10 (unified memory), Vulkan | periodic 128³ | 1224 | 656 |
| GB10 (unified memory), Vulkan | sphere 128³ | 1146 | 630 |
| M4 Max, Metal | periodic 128³ | 1364 | 937 |
| M4 Max, Metal | sphere 128³ | 1256 | 872 |
On the 4090 and the GB10 the two-buffer step is bound by memory bandwidth (~0.8 TB/s and ~0.2 of
the GB10's 0.27), so halving the bytes pays directly; on the M4 Max it gains once the in-place
addressing is cheap (a cell's coordinates once, neighbours wrapped by compare and add). With
128³ cells in place also needs one 152 MiB population buffer less. 64³ fits in cache, where the
two layouts are closer and vary more between runs.
## Measuring
```
cargo test --release -p fieldcraft-lbm-gpu --test throughput -- --ignored --nocapture
```
runs each case in both layouts on the device `FIELDCRAFT_GPU` picks, marks the one `Auto` chooses
and prints how long creating the pipelines took (on Metal this reads near zero: the driver
compiles lazily or from its cache, so the cost lands in the first dispatches instead). With `FIELDCRAFT_LBM_GPU_PROFILE=1` it also prints
each kernel's GPU time (`Options::profile`, timestamp queries; the throughput is then not
representative, as every dispatch gets its own pass).
+4 -1
View File
@@ -402,7 +402,10 @@ Delaunay methods with Voronoi/ODT improvement, then (2) all-hex and hex-dominant
(barycentres of boundary faces, of the tetrahedron, midpoints of boundary edges, and a point inside off a flat
boundary edge). Deviations: the inserted vertex takes the Delaunay cavity rather than the optimal cut of §3.4; each pass
smooths and flips the tetrahedra below sin 40° and inserts only when the worst did not improve; passes and the
insertion list are capped. Objective: the minimum dihedral sine. Every operation is a checked replacement of a region
insertion list are capped. Objective: the smaller of the minimum dihedral sine and the scaled Jacobian at the worst
corner weighted so that a Jacobian of 0.3 counts as a 30° dihedral angle: the sine misses needles (every dihedral
angle fine, one corner nearly flat; Shewchuk 2002); the scaled Jacobian analysts check (Knupp 2001, B6.6) catches
them, and with the weight it decides only near that limit. Every operation is a checked replacement of a region
(positive by exact `orient3d`, faces matched with opposite orientation, removed vertices fully inside), so the mesh is
valid after each step.
+34 -44
View File
@@ -19,7 +19,7 @@ with (a set on them finds nothing); `marked` the elements failing the limits bef
relaxing, and what relaxing changed; ¹ a tet mesh has no edge ratio: its largest aspect ratio
stands in; `duplicates` coincident nodes and cracks; `geometry.check` its findings at the size;
`verdict` pass when the minimum scaled Jacobian and the largest edge ratio meet the limits and
the volume is within 2 % (`pass (fallback)` when a fallback meshed it).
the volume is within 1 % (`pass (fallback)` when a fallback meshed it).
The section pictures cut each part through the middle of its longest side, marked elements red.
Times are wall clock on the machine that ran it.
@@ -27,43 +27,41 @@ Times are wall clock on the machine that ran it.
| part | method | symmetry | elements | min SJ | mean SJ | max edge ratio¹ | volume error | untagged faces | marked → after relax | duplicates | geometry.check | time s | verdict | section |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| bent-tube | plan (sweep) | – | 192 (192 hex8) | 0.861 | 0.865 | 5.0 | -1.7e-1 | 0 | 0 (not relaxed) | none | 8 near_faces, 2 sliver_face | 1.9 | fail (volume) | [y=37.500](skill/bent-tube.png) |
| bolted-assembly | plan (sweep), mesh.hex.generate (auto) | – | 6272 (6272 hex8) | 0.583 | 0.939 | 3.6 | 2.0e-4 | 0 | 0 (not relaxed) | 8 coincident, 0 cracks | – | 3.9 | pass | [x=50.000](skill/bolted-assembly.png) |
| cantilever-i-beam | plan (sweep) | – | 61600 (61600 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 5.7 | pass | [x=100.000](skill/cantilever-i-beam.png) |
| cross-cylinders | none | – | – | – | – | – | – | – | – | – | – | 18.3 | no mesh | – |
| filleted-bracket | tet10 | – | 19557 (19557 tet10) | 0.042 | 0.519 | 9.1 | -6.7e-6 | 0 | 1709 (not relaxed) | none | – | 35.5 | fail (SJ) | [x=30.000](skill/filleted-bracket.png) |
| filleted-bracket-blocks | plan (sweep) | – | 75410 (75410 hex8) | 0.704 | 0.954 | 3.5 | 3.7e-5 | 0 | 0 (not relaxed) | none (after merging 2348 nodes) | – | 16.7 | pass | [x=30.000](skill/filleted-bracket-blocks.png) |
| i-beam | plan (sweep) | – | 61600 (61600 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 9.3 | pass | [z=100.000](skill/i-beam.png) |
| loft-transition | plan (sweep) | – | 2873 (2873 hex8) | 0.189 | 0.960 | 2.2 | -2.1e-3 | 0 | 4 → 4 (4 relaxed, 0 undone; min SJ 0.189 → 0.189, max ratio 1.7 → 2.2) | none | – | 2.3 | fail (SJ) | [z=25.000](skill/loft-transition.png) |
| perforated-plate | plan (project) | 2 plane(s) | 8576 (8576 hex8) | 0.707 | 0.888 | 3.0 | 7.7e-4 | 0 | 0 (not relaxed) | none | – | 33.5 | pass | [x=60.000](skill/perforated-plate.png) |
| pinhole-block | plan (project) | 2 plane(s) | 5068 (5068 hex8) | 0.707 | 0.942 | 18.4 | 8.1e-5 | 0 | 1680 (not relaxed) | none | 32 sliver_face | 24.0 | fail (ratio) | [y=25.000](skill/pinhole-block.png) |
| rounded-box | plan (project) | 2 plane(s) | 9260 (9260 hex8) | 0.734 | 0.985 | 2.2 | -4.1e-3 | 0 | 0 (not relaxed) | none | – | 10.3 | pass | [x=20.000](skill/rounded-box.png) |
| bent-tube | plan (sweep) | – | 22272 (22272 hex8) | 0.998 | 0.998 | 1.4 | -2.8e-3 | 0 | 0 (not relaxed) | none | – | 3.7 | pass | [y=37.500](skill/bent-tube.png) |
| bolted-assembly | plan (sweep), mesh.hex.generate (auto) | – | 6272 (6272 hex8) | 0.583 | 0.939 | 3.6 | 2.0e-4 | 0 | 0 (not relaxed) | 8 coincident, 0 cracks | – | 3.7 | pass | [x=50.000](skill/bolted-assembly.png) |
| cantilever-i-beam | plan (sweep) | – | 61600 (61600 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 8.5 | pass | [x=100.000](skill/cantilever-i-beam.png) |
| cross-cylinders | none | – | – | – | – | – | – | – | – | – | – | 27.2 | no mesh | – |
| filleted-bracket | tet10 | – | 19991 (19991 tet10) | 0.318 | 0.564 | 4.1 | -6.8e-6 | 0 | 0 (not relaxed) | none | – | 15.1 | pass (fallback) | [x=30.000](skill/filleted-bracket.png) |
| filleted-bracket-blocks | plan (sweep) | – | 75410 (75410 hex8) | 0.704 | 0.954 | 3.5 | 3.7e-5 | 0 | 0 (not relaxed) | none (after merging 2348 nodes) | – | 6.9 | pass | [x=30.000](skill/filleted-bracket-blocks.png) |
| i-beam | plan (sweep) | – | 61600 (61600 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 4.5 | pass | [z=100.000](skill/i-beam.png) |
| loft-transition | plan (sweep) | – | 2873 (2873 hex8) | 0.189 | 0.959 | 1.7 | -2.1e-3 | 0 | 4 → 4 (4 relaxed, 0 undone; min SJ 0.189 → 0.189, max ratio 1.7 → 1.7) | none | – | 0.9 | fail (SJ) | [z=25.000](skill/loft-transition.png) |
| perforated-plate | plan (project) | 2 plane(s) | 8576 (8576 hex8) | 0.707 | 0.888 | 3.0 | 7.7e-4 | 0 | 0 (not relaxed) | none | – | 9.5 | pass | [x=60.000](skill/perforated-plate.png) |
| pinhole-block | plan (project) | 2 plane(s) | 40768 (40768 hex8) | 0.707 | 0.984 | 8.0 | 8.1e-5 | 0 | 0 (not relaxed) | none | – | 25.5 | pass | [y=25.000](skill/pinhole-block.png) |
| rounded-box | plan (project) | 2 plane(s) | 11520 (11520 hex8) | 0.717 | 0.973 | 4.0 | -4.1e-3 | 0 | 0 (not relaxed) | none | – | 8.7 | pass | [x=20.000](skill/rounded-box.png) |
| shallow-groove | plan (sweep) | – | 1960 (1960 hex8) | 0.991 | 0.999 | 1.1 | 2.7e-4 | 0 | 0 (not relaxed) | none | – | 0.2 | pass | [x=30.000](skill/shallow-groove.png) |
| sharp-wedge | plan (sweep) | – | 1674 (1674 hex8) | 0.014 | 0.301 | 149.6 | 2.7e-15 | 0 | 1524 (not relaxed) | none | – | 0.2 | fail (SJ, ratio) | [x=40.000](skill/sharp-wedge.png) |
| sphere-on-plate | tet10 | – | 15073 (15073 tet10) | 0.121 | 0.558 | 6.2 | -1.1e-4 | 0 | 662 (not relaxed) | none | – | 5.0 | fail (SJ) | [y=0.000](skill/sphere-on-plate.png) |
| spline-vase | plan (sweep) | – | 4536 (4536 hex8) | 0.665 | 0.945 | 9.9 | -6.6e-3 | 0 | 0 (not relaxed) | none | – | 0.8 | pass | [z=-35.000](skill/spline-vase.png) |
| stepped-block | plan (cells) | – | 1416 (1416 hex8) | 1.000 | 1.000 | 1.3 | -4.8e-15 | 0 | 0 (not relaxed) | none | – | 0.5 | pass | [x=30.000](skill/stepped-block.png) |
| thin-ribs | plan (sweep) | – | 301308 (301308 hex8) | 0.999 | 1.000 | 1.3 | -3.4e-12 | 0 | 0 (not relaxed) | none | – | 34.0 | pass | [x=50.000](skill/thin-ribs.png) |
| thin-wall-enclosure | plan (sweep) | – | 124512 (124512 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 21.8 | pass | [x=40.000](skill/thin-wall-enclosure.png) |
| analytic-sphere | mesh.hex.project (sphere) | – | 864 (864 hex8) | 0.702 | 0.929 | 2.7 | -3.0e-2 | 0 | 0 (not relaxed) | none | – | 2.5 | fail (volume) | [z=0.000](skill/analytic-sphere.png) |
| analytic-torus | plan (sweep) | – | 1080 (1080 hex8) | 0.834 | 0.909 | 2.4 | -4.7e-2 | 0 | 0 (not relaxed) | none | – | 1.0 | fail (volume) | [x=0.000](skill/analytic-torus.png) |
| analytic-cylinder | plan (project) | – | 1700 (1700 hex8) | 0.845 | 0.970 | 1.9 | -1.6e-2 | 0 | 0 (not relaxed) | none | – | 1.4 | pass | [z=20.000](skill/analytic-cylinder.png) |
| analytic-pipe | plan (project) | – | 1008 (1008 hex8) | 0.996 | 0.996 | 1.4 | -5.0e-3 | 0 | 0 (not relaxed) | none | – | 1.0 | pass | [z=30.000](skill/analytic-pipe.png) |
| analytic-plate-hole | plan (project) | – | 5856 (5856 hex8) | 0.937 | 0.996 | 3.0 | 4.3e-5 | 0 | 0 (not relaxed) | none | – | 5.8 | pass | [x=100.000](skill/analytic-plate-hole.png) |
| crank-arm | plan (project) | – | 1372 (1372 hex8) | 0.544 | 0.860 | 10.5 | -3.3e-3 | 0 | 9 (9 relaxations undone: worse) | none | 16 short_edge, 9 sliver_face | 48.2 | fail (ratio) | [x=81.000](skill/crank-arm.png) |
| upright-bracket | plan (sweep) | – | 1364 (1364 hex8) | 0.707 | 0.968 | 3.1 | 2.1e-4 | 0 | 0 (not relaxed) | none | – | 0.2 | pass | [x=30.000](skill/upright-bracket.png) |
| pi5-base | plan (project) | 1 plane(s) | 29852 (29852 hex8) | 0.683 | 0.969 | 4.9 | -3.0e-3 | 0 | 0 (not relaxed) | none | 8 short_edge, 4 sliver_face | 197.5 | pass | [x=56.000](skill/pi5-base.png) |
| pi5-lid | plan (project) | – | 5130 (5130 hex8) | 0.724 | 0.868 | 3.9 | -2.9e-4 | 0 | 0 (not relaxed) | none | – | 10.7 | pass | [x=56.000](skill/pi5-lid.png) |
| cup | plan (project) | – | 1114 (1114 hex8) | 0.701 | 0.921 | 5.0 | -4.5e-3 | 0 | 0 (not relaxed) | none | – | 3.0 | pass | [x=5.000](skill/cup.png) |
| plain-cup | mesh.hex.generate (auto) | – | 2000 (2000 hex8) | 0.820 | 0.995 | 4.7 | -2.3e-3 | 0 | 0 (not relaxed) | none | – | 0.9 | pass (fallback) | [x=0.000](skill/plain-cup.png) |
| sphere-on-plate | tet10 | – | 25613 (25613 tet10) | 0.335 | 0.607 | 4.4 | -8.8e-5 | 0 | 0 (not relaxed) | none | – | 7.7 | pass (fallback) | [y=0.000](skill/sphere-on-plate.png) |
| spline-vase | plan (sweep) | – | 4536 (4536 hex8) | 0.665 | 0.945 | 9.9 | -6.6e-3 | 0 | 0 (not relaxed) | none | – | 2.3 | pass | [z=-35.000](skill/spline-vase.png) |
| stepped-block | plan (cells) | – | 1416 (1416 hex8) | 1.000 | 1.000 | 1.3 | -4.8e-15 | 0 | 0 (not relaxed) | none | – | 1.6 | pass | [x=30.000](skill/stepped-block.png) |
| thin-ribs | plan (sweep) | – | 301308 (301308 hex8) | 0.999 | 1.000 | 1.3 | -3.4e-12 | 0 | 0 (not relaxed) | none | – | 34.7 | pass | [x=50.000](skill/thin-ribs.png) |
| thin-wall-enclosure | plan (sweep) | – | 124512 (124512 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 13.5 | pass | [x=40.000](skill/thin-wall-enclosure.png) |
| analytic-sphere | mesh.hex.project (sphere) | – | 3400 (3400 hex8) | 0.728 | 0.968 | 3.3 | -1.1e-2 | 0 | 0 (not relaxed) | none | – | 8.8 | fail (volume) | [z=0.000](skill/analytic-sphere.png) |
| analytic-torus | plan (sweep) | – | 48360 (48360 hex8) | 0.837 | 0.986 | 2.9 | -4.2e-3 | 0 | 0 (not relaxed) | none | – | 9.8 | pass | [x=0.000](skill/analytic-torus.png) |
| analytic-cylinder | plan (project) | – | 4347 (4347 hex8) | 0.859 | 0.985 | 2.4 | -4.9e-3 | 0 | 0 (not relaxed) | none | – | 7.3 | pass | [z=20.000](skill/analytic-cylinder.png) |
| analytic-pipe | plan (project) | – | 2244 (2244 hex8) | 0.997 | 0.997 | 1.3 | -3.3e-3 | 0 | 0 (not relaxed) | none | – | 3.6 | pass | [z=30.000](skill/analytic-pipe.png) |
| analytic-plate-hole | plan (project) | – | 5856 (5856 hex8) | 0.937 | 0.996 | 3.0 | 4.3e-5 | 0 | 0 (not relaxed) | none | – | 4.7 | pass | [x=100.000](skill/analytic-plate-hole.png) |
| crank-arm | plan (project) | – | 4014 (4014 hex8) | 0.578 | 0.933 | 5.8 | -8.4e-4 | 0 | 0 (not relaxed) | none | 4 short_edge, 2 sliver_face | 21.7 | pass | [x=81.000](skill/crank-arm.png) |
| upright-bracket | plan (sweep) | – | 1364 (1364 hex8) | 0.707 | 0.968 | 3.1 | 2.1e-4 | 0 | 0 (not relaxed) | none | – | 0.1 | pass | [x=30.000](skill/upright-bracket.png) |
| pi5-base | plan (project) | 1 plane(s) | 29852 (29852 hex8) | 0.683 | 0.969 | 4.9 | -3.0e-3 | 0 | 0 (not relaxed) | none | 8 short_edge, 4 sliver_face | 131.2 | pass | [x=56.000](skill/pi5-base.png) |
| pi5-lid | plan (project) | – | 5130 (5130 hex8) | 0.724 | 0.868 | 3.9 | -2.9e-4 | 0 | 0 (not relaxed) | none | – | 10.5 | pass | [x=56.000](skill/pi5-lid.png) |
| cup | plan (project) | – | 1258 (1258 hex8) | 0.702 | 0.929 | 5.0 | -3.5e-3 | 0 | 0 (not relaxed) | none | – | 3.0 | pass | [x=5.000](skill/cup.png) |
| plain-cup | mesh.hex.generate (auto) | – | 2000 (2000 hex8) | 0.820 | 0.995 | 4.7 | -2.3e-3 | 0 | 0 (not relaxed) | none | – | 1.3 | pass (fallback) | [x=0.000](skill/plain-cup.png) |
## Why a part fails
Each earlier method's error, in the order the skill tries them, and for a part that fails the
limits the cause read from its numbers and section picture.
- `bent-tube` (corpus: A 10 mm tube with a 1 mm wall, bent through R30: a thin-walled sweep along a line-arc-line path.):
- **fail (volume)**: the plan's default size (1/25 of the diagonal) puts six elements round the Ø10 tube: the swept section is a hexagon and loses 17 % of the volume. Size from the tube's radius (§5), not the part's diagonal.
- `bolted-assembly` (corpus: A base plate with a clamp block component placed twice, once turned 90 degrees: bodies placed by component transforms.):
- Clamp body: mesh.plan.apply: mesh plan `Plan-Clamp_body`: the projected layout does not mesh: invalid input: node 3076 at [11.000000000000004, 10.0, 5.0] lies on the edge between nodes 2640 and 2715: new elements must meet the mesh face to face, with the same divisions…
- Clamp body (Clamp:2): mesh.plan.apply: mesh plan `Plan-Clamp_body__Clamp_2_`: the projected layout does not mesh: invalid input: node 5884 at [66.42857142857143, 5.000000000000001, 5.0] lies on the edge between nodes 984 and 1059: new elements must meet the mesh face to face, wi…
@@ -71,34 +69,26 @@ limits the cause read from its numbers and section picture.
- Cross: mesh.plan.apply: mesh plan `Plan-Cross`: the cells ["Cross"] (cuts []) do not mesh all-hex: body `Cross`: invalid boundary surface: 28 defects; triangles 142 and 254 are oriented inconsistently across edge 0–396 at (-15.488656021315903, 4.577998041166734, 7…
- Cross: mesh.hex.generate: body `Cross`: invalid boundary surface: 28 defects; triangles 146 and 258 are oriented inconsistently across edge 0–402 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 255 and 258 are oriented inconsistently across…
- Cross: mesh.hex.project (sphere): body `Cross`: invalid boundary surface: 28 defects; triangles 146 and 258 are oriented inconsistently across edge 0–402 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 255 and 258 are oriented inconsistently across…
- Cross: mesh.tet.generate: invalid boundary surface: 28 defects; triangles 146 and 258 are oriented inconsistently across edge 0–402 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 255 and 258 are oriented inconsistently across edge 0–565 at…
- Cross: mesh.tet.generate: invalid boundary surface: 28 defects; triangles 76 and 78 are oriented inconsistently across edge 9–230 at (-18.75000000000002, 5.634596901641276, 7.632255089815496); triangles 77 and 78 are oriented inconsistently across edge 9–232 at (-15…
- **no mesh**: the boundary tessellation of the joined cylinders is invalid (inconsistent orientation at the two singular points), so every mesher refuses the body.
- `filleted-bracket` (corpus: An 8 mm L-bracket with an R6 inside fillet and a hole through each leg: a blend beside holes.):
- Bracket: mesh.plan.apply: mesh plan `Plan-Bracket`: the cells ["Bracket"] (cuts []) do not mesh all-hex: body `Bracket`: not supported yet: no structured hex method fits this body (extrude: cylindrical face 8 is not parallel to the direction; sweep: from face 1: lin…
- Bracket: mesh.hex.generate: body `Bracket`: not supported yet: no structured hex method fits this body (extrude: cylindrical face 8 is not parallel to the direction; sweep: from face 1: linking face 6 has 2 boundary loops (periodic or holed linking faces are not swept…
- Bracket: mesh.hex.project (sphere): body `Bracket`: meshing failed: 120 of 5104 elements are inverted, twisted or degenerate after projection (certified Jacobian; smallest corner scaled Jacobian -0.760): block 1 cell [4, 0, 0] near (20.373, 3.413, 3.606); block 1 cell [5, 0, …
- **fail (SJ)**: no structured method takes the whole body (the plan has no cuts for the fillet beside the holes), and tet10 at the plan's size leaves slivers (min SJ 0.04, 1709 marked). The same part partitioned into mapped cells (`filleted-bracket-blocks`) passes: partition (§3.4).
- Bracket: mesh.hex.project (sphere): body `Bracket`: meshing failed: 91 of 5104 elements are inverted, twisted or degenerate after projection (certified Jacobian; smallest corner scaled Jacobian -0.760): block 1 cell [3, 3, 0] near (12.519, 11.405, 1.604); block 1 cell [3, 4, …
- `loft-transition` (corpus: A 40 mm square lofted to an R15 circle 50 mm above: a ruled free-form surface between unlike sections.):
- **fail (SJ)**: the lofted sides are swept one to one, and the elements where the section changes shape are skewed (min SJ 0.19). Relaxing the four marked elements did not raise it: the blocks are wrong, not the nodes (§6.3). A loft has no template yet (`unmapped`).
- `pinhole-block` (corpus: A 50 mm block drilled through by sixteen 0.6 mm pinholes: features 80 times smaller than the part, genus 16.):
- **fail (ratio)**: the plan's cuts run beside the small holes' frames and leave strips of thin elements along the cut planes (red in the picture, edge ratio 18): 1680 marked, a clustered failure. §4: offset a cut one element out of the feature.
- `sharp-wedge` (corpus: A wedge whose faces meet at under 3 degrees: a sharp edge with a needle-thin cross-section.):
- **fail (SJ, ratio)**: the wedge's sharp edge is swept as hex, so every element tapers to it (min SJ 0.01, edge ratio 150). §4: a column of wedge6 at the edge; the plan has no wedge template.
- **fail (SJ, ratio)**: the wedge's sharp edge is swept as hex, so every element tapers to it (min SJ 0.01, edge ratio 150). A column of wedge6 at the edge (§4) raises the SJ but not the ratio: any element with a corner on a 2.9° edge has sides at least 1/tan 2.9° ≈ 20 times its width there, twice the limit. The edge has to be blunted (defeatured) or the limit waived along it.
- `sphere-on-plate` (corpus: A sphere sunk 0.1 mm into a plate: the faces meet at under 6 degrees around a 2 mm circle.):
- Plate: mesh.plan.apply: mesh plan `Plan-Plate`: the cells ["Plate"] (cuts []) do not mesh all-hex: body `Plate`: not supported yet: no structured hex method fits this body (extrude: face 6 is curved across the direction; sweep: from face 0: 6 faces are not on the …
- Plate: mesh.hex.generate: body `Plate`: not supported yet: no structured hex method fits this body (extrude: face 6 is curved across the direction; sweep: from face 0: 6 faces are not on the sweep path; from face 5: linking face 11 has 2 boundary loops (periodic or …
- Plate: mesh.hex.project (sphere): body `Plate`: meshing failed: the mesh volume 32014.728344815157 is far from the body's 69353.30815403847: the blocks do not cover the part (every outer block face must stand for part of its surface)
- **fail (SJ)**: no structured method takes the plate with the sunk sphere, and tet10 leaves slivers (min SJ 0.12, 662 marked).
- Plate: mesh.hex.project (sphere): body `Plate`: meshing failed: the mesh volume 32917.42909007507 is far from the body's 69353.30815403847: the blocks do not cover the part (every outer block face must stand for part of its surface)
- `analytic-sphere` (corpus: Sphere r 20: curvature everywhere, no edges.):
- Sphere: mesh.plan.apply: mesh plan `Plan-Sphere`: the cells ["Sphere"] (cuts []) do not mesh all-hex: body `Sphere`: not supported yet: no structured hex method fits this body (extrude: no direction to try; sweep: no planar face to sweep from; revolve: no axis to t…
- Sphere: mesh.hex.generate: body `Sphere`: not supported yet: no structured hex method fits this body (extrude: no direction to try; sweep: no planar face to sweep from; revolve: no axis to try; mapped: meshing failed: meshing failed: mesh validity gate: 8 inverted or…
- **fail (volume)**: the sphere template meshes it cleanly, but hex8 faces chord the sphere at the plan's size and the mesh is 3 % short of its volume: refine, or hex20 (`order: 2`).
- `analytic-torus` (corpus: Torus 30/8: genus 1 with doubly curved faces and no edges.):
- **fail (volume)**: the revolve is clean, but hex8 faces chord the torus at the plan's size and the mesh is 4.7 % short of its volume: refine, or hex20.
- `crank-arm` (sample `crank-arm`: the 170 mm bicycle crank, mirror symmetric across y = 0):
- **fail (ratio)**: the edge ratio is 10.5 in the nine elements at the square bore's R1.5 corners, just over the limit. Relaxing each raised its region's edge ratio, so each was undone (§6.3). `mesh.plan.apply` did not find the y = 0 symmetry plane; the sample meshes the partitioned half and mirrors it.
- **fail (volume)**: no structured method takes a sphere (it has no face to sweep from or axis to revolve about), so the plan does not apply and the fallback's sphere template meshes it at the plan's size (2.45 mm, which keeps a sphere's chords to 0.75 % on paper), 1.1 % short of the volume: the template's elements at the cube's eight corners span more of the sphere than the size. A sphere template in the plan (§3) would size its caps; hex20 (`order: 2`) closes it too.
- `plain-cup` (the cup without its handle (#418)):
- Cup: mesh.plan.apply: mesh plan `Plan-Cup`: the projected layout does not mesh: body `Cup`: meshing failed: the mesh volume 4994.312243612882 is far from the body's 16760.91944296662: the blocks do not cover the part (every outer block face must stand for part o…
- Cup: mesh.plan.apply: mesh plan `Plan-Cup`: the projected layout does not mesh: body `Cup`: meshing failed: the mesh volume 5012.205781249979 is far from the body's 16760.91944296662: the blocks do not cover the part (every outer block face must stand for part o…
## Parts
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more