Compare commits

...
66 Commits
Author SHA1 Message Date
jyvikoandClaude Opus 5.5 28c13316ca docs: units inventory, every place a dimensional number enters or leaves
Part of #579.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 10:30:33 -04:00
jyvikoandClaude Opus 5.5 d4dd0b6bf2 xtask: unit-factors report of literal conversion factors outside the unit table
Part of #579. Lists float literals equal to common conversion factors
(1e-3, 1e6, 273.15, 25.4, 9.81, …) outside crates/model/src/units; report
only, so later steps can drive it to zero before it becomes a CI check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 10:30:33 -04:00
jyvikoandClaude Opus 5.5 af7836d3ce model: SI unit table (dimensions, units, quantities, display presets)
Part of #579. A units table in fieldcraft-model: Dimension as SI base
exponents, named units with factors (offsets only for absolute °C/°F),
unit expressions (t/mm³, W/(m·K)), Quantity kinds with parsing of
"210 GPa" / {value, unit} under dimension checks, formatting, and display
presets (SI, m-kg-s, mm-t-s-N-MPa, in-lbf-s-psi). Model storage unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 10:30:33 -04:00
Christian Svensson 8201f9a954 docs: a flow-past gallery clip and solved still
Add the start-up-transient clip and a solved still of the flow-past sample to
the examples gallery: air flowing past the L-bracket, the wake forming behind
the upright and the drag settling over the 16 s run (it does not shed; that
needs a finer grid or a collision operator stable near the lattice's stability
limit, #557).

tools/flow-clip/make.py regenerates them: it drives the desktop app over the
control channel, solves the lattice Boltzmann step with a dense frame output,
renders the model-only viewport at each frame with a fixed legend clamp
(results.frame then ui.render), and encodes an MP4 and a GIF with ffmpeg, each
frame captioned with the elapsed flow time. The geometry is the sample's own
upright-bracket recipe; nothing comes from a commercial product.

Part of #164.
2026-10-18 10:15:45 -04:00
Christian Svensson 6996fd71b0 engine: animate a fluid step's frames
results.animate refused fluid steps because nothing deforms. A fluid step
instead plays its frames over time, the start-up transient from the field at
rest to the settled flow, the way an explicit step plays its frames. The
animation clock maps across the fluid frames once per cycle, and the fluid
results panel gains an Animate control beside its frame slider.

Part of #164.
2026-10-18 10:15:45 -04:00
jyvikoandClaude Opus 5.5 7fdb348d05 ci: draft pull requests start no run; marking one ready does
CI and the packaging lint listen for ready_for_review too, and their
pull-request jobs skip drafts, so a contributor's work in progress no
longer takes runners from develop and the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 09:12:50 -04:00
Christian Svensson db561be51b engine: report principal stresses in solve.probe
A static step's probe gives every node its principal stresses, largest
first, beside its stress and von Mises: the eigenvalues of the same
nodal stress, from the same function the principal stress contours use.
An oracle quantity reads one number at a JSON pointer, so a benchmark
whose target is a principal stress (NAFEMS LE6) needs it in a reply.

Closes #574. Part of #97.
2026-10-18 08:31:53 -04:00
Christian Svensson a8040063a6 tests: add the NAFEMS LE7 cylinder/sphere oracle case
The thin-walled cylinder with a hemispherical cap under internal
pressure as four axisymmetric quad8 blocks: axial stress on the outer
surface at D 25.283.

The NAFEMS target, 25.86, is the axisymmetric thin-shell solution; our
solid model converges to 25.27, the value ESRD reports for its converged
axisymmetric solid. So the NAFEMS quantity keeps a 1% tolerance and is
a known failure until there is an axisymmetric shell element, and a
second quantity checks the model against the solid reference (pass,
0.05%).

Part of #97.
2026-10-18 08:31:48 -04:00
Christian Svensson 65066b8451 tests: add the NAFEMS LE5 Z-section oracle case
The Z-section cantilever under an end torque as five hex20 blocks (two
flanges, two corner blocks, the web; two elements through every wall):
axial stress at A, on the mid-surface of the lower flange at its free
edge, -109.45 against -108 (2%).

The solid model converges to about -109.7, 1.6% beyond the thin-walled
reference, as ESRD's converged p-version solid (-109.5) does. The README
also records the MITC4 mid-surface shell model of the same problem,
which converges to about -111.3 (3.1%), and why the case is the solid.

Part of #97.
2026-10-18 08:31:48 -04:00
Christian Svensson 68aff646b2 tests: add NAFEMS LE2 and LE3 shell oracle cases
LE2, the cylindrical shell bending patch test, in its two load cases
(edge moment; pressure with the balancing edge traction) on an 8 × 8
MITC4 patch: tangential stress at E 59.936 and 59.904 against 60.

LE3, the hemispherical shell with point loads, on a 32 × 32
longitude-latitude mesh (planar quad4 cells, tri3 at the pole): radial
displacement at A 0.18418 against 0.185.

Both meshes are written node by node from the generator each README
lists, with the refinement that supports its tolerance. Targets are
transcribed from the reproductions of TNSB Rev. 3 in the Abaqus 2016
Benchmarks Guide and the ESRD StressCheck NAFEMS guide.

Part of #97.
2026-10-18 08:31:48 -04:00
jyvikoandClaude Opus 5.5 853131e5a1 docs: claiming with /claim, labels and triage; attribute the bots to SolidCraft
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 50899d8d5d ci: run the claim and triage bot tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 619b1936d3 github: triage bot, area and type labels, needs-triage, /triage and needs-info
Adapted from SolidCraft's triage bot: PR paths and issue titles map to
FieldCraft's area:* labels, the issue forms give the type, /triage
understands labels with spaces (high, S/M/L, in-progress), and short
bug reports without steps are asked once and closed after 14 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 9fce2f7bab github: claim bot, /claim, /unclaim, PR references and a 14-day sweep
Adapted from SolidCraft's claim bot to FieldCraft's rules: a claim
assigns and labels claimed, only help wanted / good first issue issues
are open to contributors, in progress is the core team's and can't be
claimed, and no visible progress for 14 days frees the issue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 15f9b7aa6e agents: allow JavaScript for the GitHub bots run by actions/github-script
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
Christian Svensson 6b87dc508d engine: report a held face as the temperature extreme to scripts
The results reply named the fluid's 79.69 °C cell as the maximum while the
app showed the face held at 80 °C. It now names that face, by body and
face as for wall heat, so a script reads what the legend shows.

Part of #157.
2026-10-18 06:02:18 -04:00
Christian Svensson 7e3eaa14f2 engine: draw an obstacle face held at a temperature at that temperature
The coloured obstacle surface samples the fluid beside each face, and
the solid cells carry no temperature, so a face held at 80 °C took the
mean of the fluid cells around it, which mixes in the cooler ones further
out: the heated-flow sample's bracket looked 50 °C beside 79.7 °C air. A
face whose thermal condition holds it at a temperature is now drawn at
that value, and the legend's range reaches it (the fluid never quite
does). Adiabatic and flux faces keep showing the fluid beside them.

Part of #157.
2026-10-18 06:02:18 -04:00
Christian Svensson c61a8b9a4d mesh: name the threshold test for what it asserts
The 135-degree vertex is no longer a corner, so the test is named that way.

Part of #556.
2026-10-18 06:02:13 -04:00
Christian Svensson c3c764c996 mesh: an angle exactly on the 135-degree threshold is not a corner, whichever way it rounds
A region's corners are its interior angles below 135 degrees, and an
O-grid's 45-degree cuts meet straight sides at exactly that angle. The
computed angle then rounds either way with the last bits of its points,
which differ between platforms' sin and cos and between tessellations:
one ulp in three points of the slot test's arcs, Windows' sin against
glibc's, turned a mapped region into a triangle and the whole blocking
into a different one with a corner scaled Jacobian of 0.375 for 0.707.
Angles within 1e-9 rad of the corner and reflex thresholds now count as
on them, and on a threshold is smooth (neither a corner nor reflex),
which keeps the slot's intended O-grid of 9 regions and 848 quads on
Linux, macOS and Windows. The region classification and the O-grid's
own corner tests share the rule, the slot test pins the blocking, and
its arc helper uses the mesher's portable trig.

Part of #556.
2026-10-18 06:02:13 -04:00
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
159 changed files with 19090 additions and 448 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
+22 -7
View File
@@ -15,6 +15,8 @@ name: CI
# Nightly and manual runs, and every release (release.yml), run all of it.
on:
pull_request:
# A draft starts no run; marking it ready for review does.
types: [opened, synchronize, reopened, ready_for_review]
paths:
- '**'
- '!**/*.md'
@@ -56,8 +58,8 @@ env:
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
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')))
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 15
permissions:
contents: read
@@ -114,10 +116,23 @@ jobs:
printf 'everything=true\nsolver=true\nwasm=true\n' >>"$GITHUB_OUTPUT"
fi
bots:
name: Claim and triage bot tests
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')))
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts
# scripts/claim and scripts/triage run in .github/workflows/claim.yml and triage.yml through
# actions/github-script; their tests use only node:test against a fake client.
- run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
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 +164,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 +183,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
@@ -218,8 +233,8 @@ 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
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')))
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
+53
View File
@@ -0,0 +1,53 @@
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Before you start: claim an issue"), and a
# daily sweep that frees claims with no visible progress for 14 days. A pull request that
# references an issue (`Closes #N`, `<summary> (#N)`) claims it too. The logic is in
# scripts/claim/claim.js (tested in ci.yml).
name: Claim
on:
issue_comment:
types: [created]
# pull_request_target so PRs from forks can label and assign. It runs the base branch's
# scripts/claim and only reads the PR's title, body and author; the PR's code is never checked out.
pull_request_target:
types: [opened, reopened]
schedule:
- cron: '17 5 * * *'
workflow_dispatch:
permissions:
contents: read # to check out scripts/claim
issues: write
pull-requests: read
# One run at a time per issue, so two simultaneous /claim comments can't both win.
concurrency:
group: claim-${{ github.event.issue.number || github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
jobs:
claim:
name: Claim bot
runs-on: ubuntu-24.04
timeout-minutes: 10
if: >-
github.event_name != 'issue_comment' ||
(!github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
(startsWith(github.event.comment.body, '/claim') || startsWith(github.event.comment.body, '/unclaim')))
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/claim
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const claim = require('./scripts/claim/claim.js');
if (context.eventName === 'issue_comment') {
await claim.handleComment({ github, context });
} else if (context.eventName === 'pull_request_target') {
const claimed = await claim.handlePull({ github, context });
core.info(`Claimed: ${claimed.join(', ') || 'none'}`);
} else {
const released = await claim.sweep({ github, context });
core.info(`Released: ${released.join(', ') || 'none'}`);
}
+3
View File
@@ -9,6 +9,8 @@ on:
branches: [develop]
paths: [".github/workflows/**", "packaging/**"]
pull_request:
# A draft starts no run; marking it ready for review does.
types: [opened, synchronize, reopened, ready_for_review]
paths: [".github/workflows/**", "packaging/**"]
workflow_dispatch:
@@ -22,6 +24,7 @@ concurrency:
jobs:
lint:
name: Packaging lint
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
+86
View File
@@ -0,0 +1,86 @@
# Labels new issues and PRs from fixed rules, `/triage` for maintainers, asks short bug reports
# for reproduction steps (`needs-info`, closed after 14 days unanswered), and a manual backfill
# (CONTRIBUTING.md, "Labels and triage"). The logic is in scripts/triage/ (tested in ci.yml).
name: Triage
on:
issues:
types: [opened, labeled, edited]
issue_comment:
types: [created]
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
# scripts/triage and only lists the PR's changed paths; the PR's code is never checked out.
pull_request_target:
types: [opened]
schedule:
- cron: '41 5 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Only report what would be labelled'
type: boolean
default: true
permissions:
contents: read # to check out scripts/triage
issues: write
pull-requests: write
concurrency:
group: triage-${{ github.event.issue.number || github.event.pull_request.number || 'repo' }}
cancel-in-progress: false
jobs:
triage:
name: Triage bot
runs-on: ubuntu-24.04
timeout-minutes: 30
# Filtered here so the common events (every comment, every label) start no runner.
if: >-
(github.event_name == 'issue_comment' &&
github.event.comment.user.type != 'Bot' && startsWith(github.event.comment.body, '/triage')) ||
((github.event_name == 'issue_comment' || github.event.action == 'edited') &&
contains(github.event.issue.labels.*.name, 'needs-info') &&
github.event.sender.login == github.event.issue.user.login) ||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'priority') &&
contains(github.event.issue.labels.*.name, 'needs-triage')) ||
(github.event_name != 'issue_comment' && github.event.action != 'labeled' && github.event.action != 'edited')
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/triage
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
DRY_RUN: ${{ inputs.dry_run }}
with:
script: |
const triage = require('./scripts/triage/triage.js');
const info = require('./scripts/triage/needs-info.js');
const event = context.eventName;
const action = context.payload.action;
const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
const th = (...names) => names.map((data) => ({ data, header: true }));
if (event === 'issue_comment') {
core.info(await triage.handleComment({ github, context }));
await info.handleReply({ github, context });
} else if (event === 'issues' && action === 'labeled') {
await triage.handleLabeled({ github, context });
} else if (event === 'issues' && action === 'edited') {
await info.handleReply({ github, context });
} else if (event === 'schedule') {
const closed = await info.sweep({ github, context });
core.info(`Closed for missing information: ${closed.join(', ') || 'none'}`);
} else if (event === 'workflow_dispatch') {
const dryRun = process.env.DRY_RUN !== 'false';
const plan = await triage.backfill({ github, context, dryRun });
const asks = await info.backfill({ github, context, dryRun });
await core.summary
.addHeading(dryRun ? `Backfill dry run: ${plan.length} unlabelled` : `Backfill: ${plan.length} labelled`)
.addTable([th('Item', 'Kind', 'Labels', 'Title'), ...plan.map((p) => [`#${p.number}`, p.pull ? 'PR' : 'issue', esc(p.labels.join(', ') || '—'), esc(p.title)])])
.addHeading(dryRun ? `needs-info dry run: ${asks.length} would be asked` : `needs-info: ${asks.length} asked`)
.addTable([th('Issue', 'Asks', 'Title'), ...asks.map((p) => [`#${p.number}`, esc(p.asks.join(', ')), esc(p.title)])])
.write();
} else {
core.info(`Labelled: ${(await triage.handleOpened({ github, context })).join(', ') || 'nothing'}`);
if (event === 'issues') await info.handleOpened({ github, context });
}
+4 -1
View File
@@ -94,7 +94,10 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
adaptive-precision predicates, never a bare floating-point sign.
- **Units.** The model is unit-consistent and never converts (the user picks e.g. mm, t, s, N,
MPa); geometry from SolidCraft is in millimetres. Z is up.
- **Rust only.** **Never break wasm**: everything below the apps builds for
- **Rust only.** The one exception is the GitHub issue and pull request bots: `scripts/claim/` and
`scripts/triage/` are JavaScript because `actions/github-script` runs only JavaScript
(`.github/workflows/claim.yml`, `triage.yml`). Keep them small, with their `node --test` tests
beside them (CI's bot tests job). **Never break wasm**: everything below the apps builds for
`wasm32-unknown-unknown` (`cargo xtask wasm`, part of `cargo xtask ci`).
- **Workflow for every change** (this section overrides any other instruction an agent carries,
such as a parent repository's CLAUDE.md or a task runner's "run tests and linters" step):
+6 -1
View File
@@ -25,11 +25,14 @@ is committed and listed here with its exports.
| docs/media/examples/crank-arm.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-arm sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/meshing/skill/analytic-cylinder.png, docs/meshing/skill/analytic-pipe.png, docs/meshing/skill/analytic-plate-hole.png, docs/meshing/skill/analytic-sphere.png, docs/meshing/skill/analytic-torus.png, docs/meshing/skill/bent-tube.png, docs/meshing/skill/bolted-assembly.png, docs/meshing/skill/cantilever-i-beam.png, docs/meshing/skill/crank-arm.png, docs/meshing/skill/cup.png, docs/meshing/skill/filleted-bracket-blocks.png, docs/meshing/skill/filleted-bracket.png, docs/meshing/skill/i-beam.png, docs/meshing/skill/loft-transition.png, docs/meshing/skill/perforated-plate.png, docs/meshing/skill/pi5-base.png, docs/meshing/skill/pi5-lid.png, docs/meshing/skill/pinhole-block.png, docs/meshing/skill/plain-cup.png, docs/meshing/skill/rounded-box.png, docs/meshing/skill/shallow-groove.png, docs/meshing/skill/sharp-wedge.png, docs/meshing/skill/sphere-on-plate.png, docs/meshing/skill/spline-vase.png, docs/meshing/skill/stepped-block.png, docs/meshing/skill/thin-ribs.png, docs/meshing/skill/thin-wall-enclosure.png, docs/meshing/skill/upright-bracket.png | FieldCraft contributors | Original: section pictures of the corpus parts and samples meshed by the meshing skill, drawn by `fieldcraft-cli snapshot` (`cargo xtask skill-results`, docs/meshing/skill-results.md) | MIT OR Apache-2.0 |
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample (its bracket; the viewport does not draw flow results yet) (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample before solving (its bracket as the sample opens) (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/flow-past-flow.png | FieldCraft contributors | Original: the solved flow-past sample rendered by FieldCraft (`ui.render`, light theme), velocity on the mid-slice with the bracket and upstream streamlines, generated by `tools/flow-clip/make.py` | MIT OR Apache-2.0 |
| docs/media/examples/flow-past-transient.mp4 | FieldCraft contributors | Original: the flow-past start-up transient rendered frame by frame by FieldCraft (`ui.render`), generated by `tools/flow-clip/make.py` (captions drawn by `tools/demo-video/caption.py` in Inter, OFL, crates/ui-egui/fonts) | MIT OR Apache-2.0 |
| docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 |
| 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 +99,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 |
@@ -228,6 +232,7 @@ is committed and listed here with its exports.
| .github/workflows/release.yml, .github/release-notes.md, docs/releasing.md | SolidCraft `.github/workflows/release.yml`, `.github/release-notes.md`, `docs/releasing.md`; VectorCraft `.github/workflows/release.yml` | MIT OR Apache-2.0 |
| .github/workflows/packaging-lint.yml | SolidCraft `.github/workflows/packaging-lint.yml` (itself adapted from PhotoCraft, https://github.com/storytold/photocraft, Apache-2.0) | MIT OR Apache-2.0 |
| .github/workflows/arch-check.yml (the riscv64 cross build) | SolidCraft `.github/workflows/ci.yml` (`riscv64-gates`) | MIT OR Apache-2.0 |
| .github/workflows/claim.yml, .github/workflows/triage.yml, scripts/claim/, scripts/triage/ (the claim and triage bots and their tests) | SolidCraft `.github/workflows/claim.yml`, `triage.yml`, `scripts/claim/`, `scripts/triage/` | MIT OR Apache-2.0 |
## Test oracles
+48 -5
View File
@@ -61,12 +61,53 @@ Every change starts from an issue, and only the maintainer merges.
1. Pick an open issue labelled **help wanted** or **good first issue** that is not labelled
**claimed**. If what you want to do has no issue, open one first and wait for a reply.
2. Comment that you would like to take it. The maintainer assigns it to you and labels it
**claimed**; then it is yours. Work on unassigned issues may not be accepted.
3. Open a draft pull request early, linked with `Closes #N`. An issue with no visible progress
for 14 days goes back to the pool.
2. Comment `/claim` on it. The bot assigns it to you, labels it **claimed** and replies; then it is
yours. It refuses an issue someone else holds (naming them and when their claim lapses), an
issue an open pull request already covers, and an issue without **help wanted** or **good
first issue**: on those, say what you would like to do and the maintainer replies. Work on
unassigned issues may not be accepted.
3. Open a draft pull request early, linked with `Closes #N`. A pull request that references an
open-for-contributors issue nobody holds (`Closes #N` in the body, `(#N)` in the title) claims
it the same way; if someone holds it already, or an older open pull request references it,
the bot says so on your pull request instead.
4. An issue with no visible progress for 14 days goes back to the pool: a daily sweep frees a claim
with no linked pull request 14 days after it was made, and one with a pull request after 14
days without pushes. `/unclaim` releases it early (you or a maintainer).
Issues labelled **in progress** are being worked on by the core team; please don't start on them.
The bot never lets them be claimed, and never frees an issue a maintainer holds.
## Labels and triage
The triage bot labels every new issue and pull request (`.github/workflows/triage.yml`). It only
adds labels; correct them freely and it won't put them back.
- **Pull requests** get `area:*` from the paths they change: `crates/mesh` → `area:mesh`,
`crates/lbm*` → `area:cfd`, `crates/solver`, `crates/fem`, `crates/explicit` → `area:solver`,
`crates/ui-egui` and `apps/` → `area:ui`, `crates/io`, `crates/decks` → `area:io`, `docs/` and
top-level `*.md` → `area:docs`, `.github/`, `xtask/`, `packaging/`, `scripts/`, `tools/` →
`area:infra`.
- **Issues** get `bug` or `enhancement` from the form they were filed with (or a crash or
"Feature request" title), and an `area:*` from words in the title or the command an in-app
report names. Until it has a priority, an issue carries `needs-triage`; adding a `priority:`
label removes it.
- **Maintainers** comment `/triage area:mesh high S` to sort an issue or pull request: any
existing label (case, and `-` or `_` for a space, don't matter: `in-progress`), `high` for
`priority: high`, `S`/`M`/`L` for a size (which replaces the old one), `-label` removes one.
Any `/triage` with a change clears `needs-triage`.
- **Short bug reports without steps to reproduce** get `needs-info` and one comment asking for
the steps (and the version, when it's missing). The reporter's reply, or an edit that adds the
steps, removes the label; an issue that stays in `needs-info` for 14 days is closed, and can be
reopened any time. Feature requests, reports filed from the app (**Help › Report a Bug…**),
and maintainers' issues are never asked.
- `needs-triage` is the queue: [open issues with it](https://github.com/tangency-labs/fieldcraft/issues?q=is%3Aopen+label%3Aneeds-triage).
## 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
@@ -78,7 +119,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"
+19 -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), 15 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, LE2, LE3, LE5, LE7, 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,20 @@ 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.
### Flow start-up transient and gallery clip (#164)
`results.animate` now plays a fluid step's frames in order, the start-up transient from the
uniform initial field to the settled flow, the way an explicit step plays its frames; the fluid Results
panel gains an Animate control. The `flow-past` gallery sample gains a clip and a solved still of
the air flowing past the L-bracket, regenerated by `tools/flow-clip/make.py` (the app rendered
frame by frame over the control channel, encoded with ffmpeg). The wake forms and the drag
settles; it does not shed (that needs a finer grid or a collision operator stable near τ⁺ = 0.5,
#557).
+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}");
+440 -9
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],
@@ -43,7 +44,7 @@ pub const MODES: CommandSpec = CommandSpec {
pub const ANIMATE: CommandSpec = CommandSpec {
id: "results.animate",
label: "Animate",
params: r#"{"on": true} — animates the deformed shape: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed"#,
params: r#"{"on": true} — animates the result over time: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed, an explicit or fluid step plays its frames in order"#,
form: Some(super::forms::ANIMATE),
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,9 +341,12 @@ fn animate(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(s.results_report());
}
let step = step_for(s, &json!({}), Want::Any)?;
let fluid = s.latest_result(&step).is_some_and(|(r, _)| matches!(r.kind(), Kind::Fluid(_)));
let v = view_of(s, &step)?;
v.animate = true;
v.deformed = true;
// A fluid step has nothing to deform; animating plays its frames (as results.frame does) in
// order. A structural step animates its deformation (or an explicit step its frames).
v.deformed = !fluid;
Ok(s.results_report())
}
@@ -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,164 @@ 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);
// A fluid step animates its frames (the start-up transient), not a deformation.
let r = s.execute("results.animate", &json!({})).unwrap();
assert_eq!((r["view"]["animate"].as_bool(), r["view"]["deformed"].as_bool()), (Some(true), Some(false)), "{r}");
assert!(s.execute("results.animate", &json!({"on": false})).unwrap()["view"]["animate"] == json!(false));
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();
+154 -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, principal stresses (the values, largest first, without their directions) 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}),
})
@@ -670,6 +717,7 @@ fn probe_implicit(result: &ImplicitResult, step: &str, nodes: &[u32], p: &Value)
"reaction": r.reaction.get(i),
"reaction_moment": r.reaction_moment.get(i),
"stress": r.stress.get(i),
"principal": r.stress.get(i).map(fieldcraft_fem::principal),
"von_mises": r.von_mises.get(i),
"temperature": r.temperature.as_ref().and_then(|t| t.get(i)),
});
@@ -839,6 +887,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.
@@ -897,6 +946,10 @@ pub(crate) mod tests {
let probe = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [11, 1]})).unwrap();
assert!(probe["nodes"][0]["displacement"][2].as_f64().unwrap() < -3.8, "{probe}");
assert!(probe["nodes"][1]["reaction"][2].as_f64().unwrap() != 0.0, "{probe}");
// The principal stresses of the nodal stress, largest first.
let root = &probe["nodes"][1];
let stress: [f64; 6] = serde_json::from_value(root["stress"].clone()).unwrap();
assert_eq!(root["principal"], json!(fieldcraft_fem::principal(&stress)), "{root}");
// By location: the nearest node, with where it is.
let at = s.execute("solve.probe", &json!({"step": "Bend", "at": [[10.1, -0.2, 0.1], [0, 0, 0]]})).unwrap();
assert_eq!((at["nodes"][0]["node"].clone(), at["nodes"][1]["node"].clone()), (json!(11), json!(1)), "{at}");
@@ -1104,6 +1157,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 +1313,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 {
+813
View File
@@ -0,0 +1,813 @@
//! 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::Temperature)
&& let Kind::Fluid(r) = result.kind()
{
// An obstacle face held at a temperature is drawn at it: sampling the fluid beside it
// would mix in the cooler cells further out.
let held = held_temperatures(session, r, base, bodies);
if held.iter().any(Option::is_some) {
if let Some(c) = &mut out.contour {
// A held face beyond the fluid's range is the extreme: the legend, its Max/Min
// lines and the viewport's marker all name it. Its label continues past the
// cells, into `wall_positions` (see [`FlowPlot::wall_positions`]).
let cells = c.values.len();
for (t, v) in base.surface_tris.iter().zip(&held) {
let Some(v) = *v else { continue };
let at = || (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0);
if v > c.max.1 {
c.max = (cells + out.wall_positions.len(), v);
out.wall_positions.push(Some(at()));
} else if v < c.min.1 {
c.min = (cells + out.wall_positions.len(), v);
out.wall_positions.push(Some(at()));
}
}
c.bands = view.bands(c.min.1, c.max.1);
}
out.wall_values = base.surface_tris.iter().zip(held).map(|(t, v)| (t.p, v)).collect();
}
}
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)
}
/// Per displayed surface triangle, the temperature its obstacle face is held at, when the step's
/// domain holds that face at one and the result is of the current model.
fn held_temperatures(session: &crate::Session, r: &FluidResult, base: &fieldcraft_render::DisplayMesh, bodies: &[&str]) -> Vec<Option<f64>> {
let faces = held_faces(session, r);
let held: std::collections::BTreeMap<_, _> = faces.iter().map(|h| ((h.body.as_str(), h.index), h.value)).collect();
base.surface_tris.iter().map(|t| bodies.get(t.body as usize).and_then(|name| held.get(&(*name, t.face)).copied())).collect()
}
/// An obstacle face the step's fluid domain holds at a temperature.
pub struct HeldFace {
pub body: String,
pub face: String,
pub index: u32,
pub value: f64,
}
/// The faces the step's fluid domain holds at a temperature, when the result is of the current
/// model (a stale result was solved with other conditions).
pub fn held_faces(session: &crate::Session, r: &FluidResult) -> Vec<HeldFace> {
if session.latest_result(&r.step).is_some_and(|(_, current)| !current) {
return Vec::new();
}
let Some(thermal) = session
.model
.step(&r.step)
.and_then(|st| st.procedure.fluid_domain())
.and_then(|d| session.model.fluid_domain(d))
.and_then(|d| d.thermal.as_ref())
else {
return Vec::new();
};
let design = session.design.as_deref();
let mut out = Vec::new();
for sb in &thermal.surfaces {
let (fieldcraft_model::ThermalCondition::Temperature { value }, fieldcraft_model::Entity::Face(face)) = (&sb.condition, &sb.face.entity)
else {
continue;
};
let index = design.and_then(|d| d.bodies.iter().find(|b| b.name == sb.face.body)).and_then(|b| b.face_index(face));
if let Some(index) = index {
out.push(HeldFace { body: sb.face.body.clone(), face: face.clone(), index, value: *value });
}
}
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);
// Faces held at a temperature (`wall_values`, matched by position as for wall heat).
let held = (c.field == Field::Temperature && model.len() == plot.wall_values.len()).then_some(&plot.wall_values);
for (k, t) in model.into_iter().enumerate() {
if let Some((points, Some(v))) = held.and_then(|h| h.get(k))
&& t.p == *points
{
banded(t.p, [*v; 3], t.normal, &c.bands, &color, &mut scene.tris);
continue;
}
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
}
/// A face held at a temperature is drawn at that temperature, and the legend reaches it,
/// rather than at the mean of the fluid cells around it (which mixes in the cooler ones
/// further out: an 80 °C bracket looked 50 °C).
#[test]
fn a_face_held_at_a_temperature_is_drawn_at_it() {
let mut s = plate_in_a_tank();
let face = s.design.as_ref().unwrap().bodies[0].faces()[0].name.clone();
s.execute(
"model.material.edit",
&json!({"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}, "thermal": {"conductivity": 400, "specific_heat": 4e9}}),
)
.unwrap();
s.execute(
"model.fluid_domain.edit",
&json!({"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}},
"thermal": {"initial_temperature": 20}}),
)
.unwrap();
s.execute(
"model.fluid_domain.thermal_boundary",
&json!({"domain": "Tank", "at": {"body": "Body1", "face": face}, "condition": {"type": "temperature", "value": 80}}),
)
.unwrap();
s.execute("solve.fluid", &json!({})).unwrap();
s.execute("results.surface", &json!({"field": "temperature", "show": true})).unwrap();
let (result, current) = s.latest_result("Flow").unwrap();
assert!(current);
let view = s.results_view.clone().unwrap();
let base = crate::view::display(&s).0;
let names: Vec<&str> = s.design.as_ref().unwrap().bodies.iter().map(|b| b.name.as_str()).collect();
let fp = plot_with_surfaces(&view, result, &s.traced, &s, &base, &names).unwrap();
let c = fp.contour.as_ref().unwrap();
// The fluid never quite reaches the wall's 80 °C; the legend does, and its maximum is the
// held face (a label past the cells, at a point on that face).
assert!(c.values.iter().all(|t| t.is_nan() || *t < 80.0));
assert_eq!((c.max.1, c.bands.max), (80.0, 80.0), "{:?}", c.bands);
let at = c.max.0.checked_sub(c.values.len()).and_then(|k| fp.wall_positions.get(k).copied().flatten());
assert!(at.is_some(), "{:?}", c.max);
let held: Vec<f64> = fp.wall_values.iter().filter_map(|(_, v)| *v).collect();
assert!(!held.is_empty() && held.iter().all(|t| *t == 80.0), "{held:?}");
// A script reads the same maximum as the app shows: the held face.
let reply = s.results_report();
assert_eq!(reply["contour"]["range"]["max"], json!({"body": "Body1", "face": face, "value": 80.0}), "{reply}");
// The headless picture draws the held face in the top band's colour.
let p = crate::view::Palette::LIGHT;
let top = bands_color(&p.contour, p.contour_over, &c.bands, c.bands.band(80.0).unwrap());
let (scene, ..) = crate::view::scene(&s, &p);
assert!(scene.tris.iter().any(|t| t.color == top));
}
#[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 {
+76 -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,35 @@ 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;
}
}
// A face held beyond the fluid's range is the extreme, as the legend shows it.
if v.contour == Some(Field::Temperature) && out["contour"]["range"].is_object() {
for h in crate::flow::held_faces(self, f) {
let end = json!({"body": h.body, "face": h.face, "value": h.value});
let range = &mut out["contour"]["range"];
if range["max"]["value"].as_f64().is_some_and(|m| h.value > m) {
range["max"] = end;
} else if range["min"]["value"].as_f64().is_some_and(|m| h.value < m) {
range["min"] = end;
}
}
}
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 +813,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.
+8 -3
View File
@@ -916,8 +916,8 @@ fn assignable(loops: &[Poly], cuts: &[Vec<Q>], s: &Settings) -> bool {
fn o_grid(loops: &[Poly], s: &Settings, budget: &mut usize) -> Vec<Vec<Q>> {
let (Some(outer), Some(hole)) = (loops.first(), loops.get(1)) else { return Vec::new() };
let c = centroid(&hole.pts);
let corner = |p: &Poly, i: usize| p.is_end(i) && p.angle(i) <= 0.75 * std::f64::consts::PI;
let concave = |p: &Poly, i: usize| p.is_end(i) && p.angle(i) >= 1.25 * std::f64::consts::PI;
let corner = |p: &Poly, i: usize| p.is_end(i) && region::is_corner(p.angle(i));
let concave = |p: &Poly, i: usize| p.is_end(i) && region::is_reflex(p.angle(i));
// A cut from loop point (l, i) along d that must end on loop `target`.
let ray = |l: usize, i: usize, d: Q, target: usize| -> Option<Vec<Q>> {
let o = loops.get(l)?.at(i);
@@ -1134,8 +1134,9 @@ mod tests {
let n = ((a1 - a0).abs() / 5.0).ceil().max(1.0) as usize;
(0..=n)
.map(|k| {
// The mesher's portable trig, so every platform gets the same points (#556).
let t = (a0 + (a1 - a0) * k as f64 / n as f64).to_radians();
[c[0] + r * t.cos(), c[1] + r * t.sin()]
[c[0] + r * libm::cos(t), c[1] + r * libm::sin(t)]
})
.collect()
}
@@ -1308,6 +1309,10 @@ mod tests {
]);
let m = mesh_profile(&profile, &[], 2.5);
check(&m, &profile, 0.5);
// The intended O-grid on every platform (#556): Windows' last-bit-different arcs used to
// give 10 regions and 2063 quads with a 0.375 corner.
assert_eq!(m.section.schemes.len(), 9, "{:?}", m.section.schemes);
assert_eq!(m.section.quads.iter().map(Vec::len).sum::<usize>(), 848);
}
#[test]
+45 -3
View File
@@ -47,6 +47,24 @@ const MAX_ANNULUS_NODES: usize = 4096;
/// Smoothing sweeps over the interior of a triangle primitive.
const TRIANGLE_SWEEPS: usize = 20;
/// How close (radians) an interior angle must come to a classification threshold to count as on
/// it. Exact geometry lands on the thresholds (an O-grid's 45° cuts meet straight sides at
/// exactly 135°), and the computed angle is then rounded either way by the last bits of its
/// points: those differ between platforms' `sin`/`cos` and between tessellations, and the
/// scheme must not (#556). An angle within this of a threshold is taken as exactly on it, and
/// on a threshold is smooth, neither a corner nor reflex: that keeps the slot's intended O-grid.
const ANGLE_TOLERANCE: f64 = 1e-9;
/// A corner: an interior angle clearly below 135° (exactly 135° is not one).
pub(crate) fn is_corner(angle: f64) -> bool {
angle < 0.75 * PI - ANGLE_TOLERANCE
}
/// A reflex turn no mapped or triangle region can take: clearly above 225°.
pub(crate) fn is_reflex(angle: f64) -> bool {
angle > 1.25 * PI + ANGLE_TOLERANCE
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Scheme {
/// Corner positions (curve uses) in the single loop.
@@ -89,10 +107,10 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
match loops {
[lp] => {
let Some(a) = angles(lp, ccw(lp)) else { return Scheme::Free };
if a.iter().any(|&x| x >= 1.25 * PI) {
if a.iter().any(|&x| is_reflex(x)) {
return Scheme::Free;
}
let corners: Vec<usize> = (0..a.len()).filter(|&k| a.get(k).is_some_and(|&x| x <= 0.75 * PI)).collect();
let corners: Vec<usize> = (0..a.len()).filter(|&k| a.get(k).is_some_and(|&x| is_corner(x))).collect();
if corners.is_empty() {
return Scheme::Disk;
}
@@ -103,7 +121,7 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
}
}
[outer, inner] => {
let smooth = |lp: &Vec<Vec<Q>>| angles(lp, ccw(lp)).is_some_and(|a| a.iter().all(|&x| x > 0.75 * PI && x < 1.25 * PI));
let smooth = |lp: &Vec<Vec<Q>>| angles(lp, ccw(lp)).is_some_and(|a| a.iter().all(|&x| !is_corner(x) && !is_reflex(x)));
if smooth(outer) && smooth(inner) { Scheme::Annulus } else { Scheme::Free }
}
_ => Scheme::Free,
@@ -568,6 +586,30 @@ mod tests {
use super::*;
use crate::size::UniformSize;
/// An angle of exactly 135°, the threshold, is not a corner however the last bits of its
/// points round (Windows' `sin` against glibc's, or two tessellations): a trapezoid with one
/// stays a triangle rather than flipping to mapped when that vertex moves by an ulp (#556).
#[test]
fn an_angle_on_the_threshold_is_not_a_corner_whichever_way_it_rounds() {
// (0, 0) 90°, (3, 0) 45°, (2, 1) 135°, (0, 1) 90°: three corners and a 135° bend.
let trapezoid = |p: Q| {
let pts = [[0.0, 0.0], [3.0, 0.0], p, [0.0, 1.0]];
let curves: Vec<Vec<Q>> = (0..4).map(|k| vec![pts[k], pts[(k + 1) % 4]]).collect();
classify(&[curves])
};
let steps: [fn(f64) -> f64; 3] = [|v| v, f64::next_up, f64::next_down];
for nx in steps {
for ny in steps {
let mut p = [nx(2.0), ny(1.0)];
// A few ulps further each way as well.
for _ in 0..3 {
assert_eq!(trapezoid(p), Scheme::Triangle([0, 1, 3]), "{p:?}");
p = [nx(p[0]), ny(p[1])];
}
}
}
}
fn circle(r: f64, n: usize, ccw: bool) -> Vec<Q> {
(0..n)
.map(|k| {
+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)
}
+96
View File
@@ -0,0 +1,96 @@
//! Physical dimensions as integer exponents of the SI base dimensions.
use std::fmt;
/// A physical dimension: the exponents of length L, mass M, time T, temperature Θ and electric
/// current I. Dimensionless quantities (Poisson's ratio, strain, angles) have every exponent 0.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Dimension {
pub length: i8,
pub mass: i8,
pub time: i8,
pub temperature: i8,
pub current: i8,
}
impl Dimension {
pub const NONE: Dimension = Dimension::new(0, 0, 0, 0);
pub const LENGTH: Dimension = Dimension::new(1, 0, 0, 0);
pub const MASS: Dimension = Dimension::new(0, 1, 0, 0);
pub const TIME: Dimension = Dimension::new(0, 0, 1, 0);
pub const TEMPERATURE: Dimension = Dimension::new(0, 0, 0, 1);
pub const CURRENT: Dimension = Dimension { current: 1, ..Dimension::NONE };
/// L^length M^mass T^time Θ^temperature (no current: FieldCraft has no electrical quantities yet).
pub const fn new(length: i8, mass: i8, time: i8, temperature: i8) -> Dimension {
Dimension { length, mass, time, temperature, current: 0 }
}
fn exponents(self) -> [i8; 5] {
[self.length, self.mass, self.time, self.temperature, self.current]
}
fn from_exponents([length, mass, time, temperature, current]: [i8; 5]) -> Dimension {
Dimension { length, mass, time, temperature, current }
}
fn zip(self, other: Dimension, f: impl Fn(i8, i8) -> Option<i8>) -> Option<Dimension> {
let (a, b) = (self.exponents(), other.exponents());
let mut out = [0i8; 5];
for ((o, x), y) in out.iter_mut().zip(a).zip(b) {
*o = f(x, y)?;
}
Some(Dimension::from_exponents(out))
}
/// The dimension of a product, or `None` if an exponent overflows.
pub fn checked_mul(self, other: Dimension) -> Option<Dimension> {
self.zip(other, i8::checked_add)
}
/// The dimension of a quotient, or `None` if an exponent overflows.
pub fn checked_div(self, other: Dimension) -> Option<Dimension> {
self.zip(other, i8::checked_sub)
}
/// The dimension raised to an integer power, or `None` if an exponent overflows.
pub fn checked_pow(self, power: i8) -> Option<Dimension> {
self.zip(Dimension::NONE, |x, _| x.checked_mul(power))
}
}
impl fmt::Display for Dimension {
/// `L M T^-2` style; `1` for dimensionless.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut first = true;
for (symbol, e) in ["L", "M", "T", "Θ", "I"].into_iter().zip(self.exponents()) {
if e == 0 {
continue;
}
if !first {
f.write_str(" ")?;
}
first = false;
if e == 1 { write!(f, "{symbol}")? } else { write!(f, "{symbol}^{e}")? }
}
if first { f.write_str("1") } else { Ok(()) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arithmetic_and_display() {
let force = Dimension::new(1, 1, -2, 0);
let area = Dimension::LENGTH.checked_pow(2).unwrap();
let pressure = force.checked_div(area).unwrap();
assert_eq!(pressure, Dimension::new(-1, 1, -2, 0));
assert_eq!(pressure.to_string(), "L^-1 M T^-2");
assert_eq!(Dimension::NONE.to_string(), "1");
assert_eq!(pressure.checked_mul(area), Some(force));
assert_eq!(Dimension::new(100, 0, 0, 0).checked_pow(2), None);
assert_eq!(Dimension::new(127, 0, 0, 0).checked_mul(Dimension::LENGTH), None);
}
}
+151
View File
@@ -0,0 +1,151 @@
//! Display systems: which unit each quantity is shown and typed in. Choosing one never changes a
//! stored value.
use serde::{Deserialize, Serialize};
use super::{Quantity, Unit, UnitError};
/// A consistent set of display units.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Preset {
/// Strict SI: m, kg, s, N, Pa, K.
#[serde(rename = "SI")]
Si,
/// SI with temperatures in °C and angles in degrees.
#[serde(rename = "m-kg-s")]
MKgS,
/// Millimetre, tonne, second, newton, megapascal, °C: what most structural CAE users expect.
#[default]
#[serde(rename = "mm-t-s-N-MPa")]
MmTonneS,
/// Inch, lbf·s²/in, second, pound-force, psi, °F.
#[serde(rename = "in-lbf-s-psi")]
InLbfS,
}
impl Preset {
pub const ALL: [Preset; 4] = [Preset::Si, Preset::MKgS, Preset::MmTonneS, Preset::InLbfS];
pub fn label(self) -> &'static str {
match self {
Preset::Si => "SI",
Preset::MKgS => "m-kg-s",
Preset::MmTonneS => "mm-t-s-N-MPa",
Preset::InLbfS => "in-lbf-s-psi",
}
}
pub fn from_label(label: &str) -> Option<Preset> {
Preset::ALL.into_iter().find(|p| p.label() == label)
}
/// The unit this preset shows `q` in.
pub fn symbol(self, q: Quantity) -> &'static str {
use Quantity as Q;
match self {
Preset::Si => q.si_symbol(),
Preset::MKgS => match q {
Q::Temperature => "°C",
Q::Angle => "deg",
_ => q.si_symbol(),
},
Preset::MmTonneS => match q {
Q::Dimensionless => "1",
Q::Angle => "deg",
Q::Length => "mm",
Q::Area => "mm²",
Q::Volume => "mm³",
Q::SecondMomentOfArea => "mm⁴",
Q::Mass => "t",
Q::Density => "t/mm³",
Q::Time => "s",
Q::Frequency => "Hz",
Q::Velocity => "mm/s",
Q::Acceleration => "mm/s²",
Q::Force => "N",
Q::Moment => "N·mm",
Q::Pressure => "MPa",
Q::Stiffness => "N/mm",
Q::Damping => "N·s/mm",
Q::Energy => "mJ",
Q::Power => "mW",
Q::DynamicViscosity => "MPa·s",
Q::KinematicViscosity => "mm²/s",
Q::Temperature => "°C",
Q::TemperatureDifference => "K",
Q::ThermalExpansion => "K⁻¹",
Q::ThermalConductivity => "mW/(mm·K)",
Q::SpecificHeat => "mJ/(t·K)",
Q::HeatTransferCoefficient => "mW/(mm²·K)",
Q::HeatFlux => "mW/mm²",
Q::HeatGeneration => "mW/mm³",
},
Preset::InLbfS => match q {
Q::Dimensionless => "1",
Q::Angle => "deg",
Q::Length => "in",
Q::Area => "in²",
Q::Volume => "in³",
Q::SecondMomentOfArea => "in⁴",
Q::Mass => "lbf·s²/in",
Q::Density => "lbf·s²/in⁴",
Q::Time => "s",
Q::Frequency => "Hz",
Q::Velocity => "in/s",
Q::Acceleration => "in/s²",
Q::Force => "lbf",
Q::Moment => "lbf·in",
Q::Pressure => "psi",
Q::Stiffness => "lbf/in",
Q::Damping => "lbf·s/in",
Q::Energy => "in·lbf",
Q::Power => "in·lbf/s",
Q::DynamicViscosity => "psi·s",
Q::KinematicViscosity => "in²/s",
Q::Temperature => "°F",
Q::TemperatureDifference => "°F",
Q::ThermalExpansion => "°F⁻¹",
Q::ThermalConductivity => "lbf/(s·°F)",
Q::SpecificHeat => "in²/(s²·°F)",
Q::HeatTransferCoefficient => "lbf/(in·s·°F)",
Q::HeatFlux => "lbf/(in·s)",
Q::HeatGeneration => "lbf/(in²·s)",
},
}
}
}
/// The units values are shown and typed in: a preset with optional per-quantity overrides (say
/// mm-t-s-N-MPa with energies in J).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DisplaySystem {
preset: Preset,
overrides: Vec<(Quantity, Unit)>,
}
impl DisplaySystem {
pub fn new(preset: Preset) -> DisplaySystem {
DisplaySystem { preset, overrides: Vec::new() }
}
pub fn preset(&self) -> Preset {
self.preset
}
/// Shows `q` in `unit` instead of the preset's unit; refused when the dimension differs.
pub fn with_override(mut self, q: Quantity, unit: &str) -> Result<DisplaySystem, UnitError> {
let unit = Unit::parse(unit)?;
q.express(0.0, &unit)?;
self.overrides.retain(|(o, _)| *o != q);
self.overrides.push((q, unit));
Ok(self)
}
/// The unit `q` is shown and typed in.
pub fn unit(&self, q: Quantity) -> Result<Unit, UnitError> {
match self.overrides.iter().find(|(o, _)| *o == q) {
Some((_, u)) => Ok(u.clone()),
None => Unit::parse(self.preset.symbol(q)),
}
}
}
@@ -1,9 +1,52 @@
//! The unit system label.
//! Units: the unit system label a model carries today, and the SI unit table every conversion
//! goes through.
//!
//! The model never converts: numbers are stored as entered. The label records which consistent
//! system they are in, so the check can warn when a magnitude is implausible for it (a steel
//! modulus of 210 000 in a metre–pascal model, a density of 7850 in a millimetre–tonne one), and
//! so writers can say which system a deck is in.
//! **The table** ([`Dimension`], [`Unit`], [`Quantity`], [`DisplaySystem`]) is the one place
//! FieldCraft writes down a conversion factor. Each [`Quantity`] has a dimension and an SI unit;
//! a [`Unit`] parses expressions such as `MPa`, `t/mm³` or `W/(m·K)` to a dimension and a factor
//! to SI; [`Quantity::parse`] reads `"210 GPa"` or `"20 °C"` with a dimension check, and
//! [`Quantity::format`] shows an SI value in a [`DisplaySystem`]. Absolute temperatures
//! ([`Quantity::Temperature`]) take the °C/°F offset; temperature differences and every compound
//! unit never do. Issue #579 moves model storage to SI on top of this table; until then the
//! model still stores numbers as entered.
//!
//! **The label** ([`UnitSystem`]): the model never converts yet: numbers are stored as entered.
//! The label records which consistent system they are in, so the check can warn when a magnitude
//! is implausible for it (a steel modulus of 210 000 in a metre–pascal model, a density of 7850
//! in a millimetre–tonne one), and so writers can say which system a deck is in.
mod dimension;
mod display;
mod quantity;
mod unit;
pub use dimension::Dimension;
pub use display::{DisplaySystem, Preset};
pub use quantity::{Quantity, ValueInput, WithUnit, format_number};
pub use unit::Unit;
/// Why a unit or a value with a unit was refused.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum UnitError {
#[error("no value or unit given")]
Empty,
#[error("unit text is too long")]
TooLong,
#[error("`{0}` is not a finite number")]
NotFinite(String),
#[error("unknown unit `{0}`")]
UnknownUnit(String),
#[error("cannot read `{0}` as a value with a unit (for example `210 GPa`, `t/mm³`, `W/(m·K)`)")]
Syntax(String),
#[error("`{0}` is out of range")]
OutOfRange(String),
#[error("`{0}` is below absolute zero")]
BelowAbsoluteZero(String),
#[error("`{unit}` is not a unit of {quantity} (angles take rad, deg or °; ratios take 1 or %)")]
WrongKind { unit: String, quantity: &'static str },
#[error("`{unit}` is {found}, but a {quantity} is {expected}")]
WrongDimension { unit: String, found: Dimension, quantity: &'static str, expected: Dimension },
}
use serde::{Deserialize, Serialize};
+246
View File
@@ -0,0 +1,246 @@
//! The kinds of physical quantity FieldCraft stores, and parsing and formatting of values with
//! units ("210 GPa", `{"value": 20, "unit": "°C"}`) against them.
use serde::{Deserialize, Serialize};
use super::unit::MAX_TEXT;
use super::{Dimension, DisplaySystem, Unit, UnitError};
macro_rules! quantities {
($($variant:ident = $name:literal, $dim:expr, $si:literal;)*) => {
/// A kind of physical quantity. Each has one dimension and one SI unit, which is how it is
/// stored.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Quantity {
$($variant,)*
}
impl Quantity {
pub const ALL: &[Quantity] = &[$(Quantity::$variant,)*];
/// The name used in commands and files (`thermal_conductivity`, …).
pub fn name(self) -> &'static str {
match self { $(Quantity::$variant => $name,)* }
}
pub fn dimension(self) -> Dimension {
match self { $(Quantity::$variant => $dim,)* }
}
/// The SI unit the quantity is stored in.
pub fn si_symbol(self) -> &'static str {
match self { $(Quantity::$variant => $si,)* }
}
}
};
}
const fn d(length: i8, mass: i8, time: i8, temperature: i8) -> Dimension {
Dimension::new(length, mass, time, temperature)
}
quantities! {
Dimensionless = "dimensionless", d(0, 0, 0, 0), "1";
Angle = "angle", d(0, 0, 0, 0), "rad";
Length = "length", d(1, 0, 0, 0), "m";
Area = "area", d(2, 0, 0, 0), "m²";
Volume = "volume", d(3, 0, 0, 0), "m³";
SecondMomentOfArea = "second_moment_of_area", d(4, 0, 0, 0), "m⁴";
Mass = "mass", d(0, 1, 0, 0), "kg";
Density = "density", d(-3, 1, 0, 0), "kg/m³";
Time = "time", d(0, 0, 1, 0), "s";
Frequency = "frequency", d(0, 0, -1, 0), "Hz";
Velocity = "velocity", d(1, 0, -1, 0), "m/s";
Acceleration = "acceleration", d(1, 0, -2, 0), "m/s²";
Force = "force", d(1, 1, -2, 0), "N";
Moment = "moment", d(2, 1, -2, 0), "N·m";
Pressure = "pressure", d(-1, 1, -2, 0), "Pa";
Stiffness = "stiffness", d(0, 1, -2, 0), "N/m";
Damping = "damping", d(0, 1, -1, 0), "N·s/m";
Energy = "energy", d(2, 1, -2, 0), "J";
Power = "power", d(2, 1, -3, 0), "W";
DynamicViscosity = "dynamic_viscosity", d(-1, 1, -1, 0), "Pa·s";
KinematicViscosity = "kinematic_viscosity", d(2, 0, -1, 0), "m²/s";
Temperature = "temperature", d(0, 0, 0, 1), "K";
TemperatureDifference = "temperature_difference", d(0, 0, 0, 1), "K";
ThermalExpansion = "thermal_expansion", d(0, 0, 0, -1), "K⁻¹";
ThermalConductivity = "thermal_conductivity", d(1, 1, -3, -1), "W/(m·K)";
SpecificHeat = "specific_heat", d(2, 0, -2, -1), "J/(kg·K)";
HeatTransferCoefficient = "heat_transfer_coefficient", d(0, 1, -3, -1), "W/(m²·K)";
HeatFlux = "heat_flux", d(0, 1, -3, 0), "W/m²";
HeatGeneration = "heat_generation", d(-1, 1, -3, 0), "W/m³";
}
impl Quantity {
pub fn from_name(name: &str) -> Option<Quantity> {
Quantity::ALL.iter().copied().find(|q| q.name() == name)
}
/// Whether the unit's zero offset applies (°C and °F as absolute temperatures).
fn absolute(self) -> bool {
self == Quantity::Temperature
}
/// Converts `value` in `unit` to this quantity's SI value, checking the dimension and the
/// result.
pub fn to_si(self, value: f64, unit: &Unit) -> Result<f64, UnitError> {
if !value.is_finite() {
return Err(UnitError::NotFinite(value.to_string()));
}
self.check(unit)?;
let si = unit.to_si(value, self.absolute());
if !si.is_finite() {
return Err(UnitError::OutOfRange(format!("{value} {}", unit.symbol())));
}
if self.absolute() && si < 0.0 {
return Err(UnitError::BelowAbsoluteZero(format!("{value} {}", unit.symbol())));
}
Ok(si)
}
/// Converts an SI value of this quantity to `unit`.
pub fn express(self, si: f64, unit: &Unit) -> Result<f64, UnitError> {
self.check(unit)?;
let v = unit.express(si, self.absolute());
if v.is_finite() { Ok(v) } else { Err(UnitError::NotFinite(v.to_string())) }
}
fn check(self, unit: &Unit) -> Result<(), UnitError> {
if unit.dimension() == self.dimension() {
// Angles and ratios share the empty dimension: only an angle takes rad, deg or °.
if unit.is_angle() == (self == Quantity::Angle) {
Ok(())
} else {
Err(UnitError::WrongKind { unit: unit.symbol().to_string(), quantity: self.name() })
}
} else {
Err(UnitError::WrongDimension {
unit: unit.symbol().to_string(),
found: unit.dimension(),
quantity: self.name(),
expected: self.dimension(),
})
}
}
/// Parses `"210 GPa"`, `"25 mm/s"`, `"-5 °C"` (or a bare `"210"`, meaning `display`'s unit)
/// to the SI value.
pub fn parse(self, text: &str, display: &DisplaySystem) -> Result<f64, UnitError> {
let text = text.trim();
if text.is_empty() {
return Err(UnitError::Empty);
}
if text.len() > MAX_TEXT {
return Err(UnitError::TooLong);
}
let (number, unit) = split_number(text).ok_or_else(|| UnitError::Syntax(text.to_string()))?;
if unit.trim().is_empty() { self.to_si(number, &display.unit(self)?) } else { self.to_si(number, &Unit::parse(unit)?) }
}
/// Formats an SI value in `display`'s unit for this quantity, with the unit: `210000 MPa`.
pub fn format(self, si: f64, display: &DisplaySystem) -> Result<String, UnitError> {
let unit = display.unit(self)?;
let v = self.express(si, &unit)?;
Ok(if unit.symbol() == "1" { format_number(v) } else { format!("{} {}", format_number(v), unit.symbol()) })
}
}
/// The leading number of `text` and the rest. `inf` and `nan` are not numbers here.
fn split_number(text: &str) -> Option<(f64, &str)> {
let end = text.find(|c: char| !(c.is_ascii_digit() || matches!(c, '+' | '-' | '.' | 'e' | 'E'))).unwrap_or(text.len());
// Shrink until the prefix parses: "2e" in "2em" is not an exponent.
(1..=end).rev().find_map(|i| {
let head = text.get(..i)?;
let v: f64 = head.parse().ok()?;
Some((v, text.get(i..)?))
})
}
/// A value with six significant digits and no trailing zeros: `210000`, `7.85e-9`, `0.3`.
pub fn format_number(v: f64) -> String {
if v == 0.0 || !v.is_finite() {
return if v == 0.0 { "0".to_string() } else { v.to_string() };
}
let exp = v.abs().log10().floor();
if (-4.0..7.0).contains(&exp) {
let decimals = (5.0 - exp).max(0.0) as usize;
let s = format!("{v:.decimals$}");
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
} else {
let s = format!("{v:.5e}");
match s.split_once('e') {
Some((mantissa, e)) => {
let m = if mantissa.contains('.') { mantissa.trim_end_matches('0').trim_end_matches('.') } else { mantissa };
format!("{m}e{e}")
}
None => s,
}
}
}
/// A value as commands, scripts and the MCP give it: a bare number (in the display system), a
/// string with its unit (`"210 GPa"`), or `{"value": 210, "unit": "GPa"}`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ValueInput {
Number(f64),
Text(String),
WithUnit(WithUnit),
}
/// `{"value": 210, "unit": "GPa"}`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WithUnit {
pub value: f64,
pub unit: String,
}
impl ValueInput {
/// The SI value of this input as quantity `q`; a bare number is in `display`'s unit.
pub fn to_si(&self, q: Quantity, display: &DisplaySystem) -> Result<f64, UnitError> {
match self {
ValueInput::Number(v) => q.to_si(*v, &display.unit(q)?),
ValueInput::Text(t) => q.parse(t, display),
ValueInput::WithUnit(w) => q.to_si(w.value, &Unit::parse(&w.unit)?),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_si_symbol_parses_to_its_dimension_with_factor_one() {
for &q in Quantity::ALL {
let u = Unit::parse(q.si_symbol()).unwrap_or_else(|e| panic!("{q:?}: {e}"));
assert_eq!(u.dimension(), q.dimension(), "{q:?}");
assert!((u.factor() - 1.0).abs() < 1e-15, "{q:?}");
assert_eq!(Quantity::from_name(q.name()), Some(q));
}
}
#[test]
fn numbers_split_from_units() {
assert_eq!(split_number("210 GPa"), Some((210.0, " GPa")));
assert_eq!(split_number("2.1e11Pa"), Some((2.1e11, "Pa")));
assert_eq!(split_number("-5 °C"), Some((-5.0, " °C")));
assert_eq!(split_number("3e"), Some((3.0, "e")));
assert_eq!(split_number("inf Pa"), None);
assert_eq!(split_number("NaN"), None);
assert_eq!(split_number("GPa"), None);
}
#[test]
fn numbers_format_with_six_significant_digits() {
assert_eq!(format_number(210000.0), "210000");
assert_eq!(format_number(7.85e-9), "7.85e-9");
assert_eq!(format_number(0.3), "0.3");
assert_eq!(format_number(2.1e11), "2.1e11");
assert_eq!(format_number(-20.0), "-20");
assert_eq!(format_number(1.0 / 3.0), "0.333333");
assert_eq!(format_number(0.0), "0");
}
}
+420
View File
@@ -0,0 +1,420 @@
//! Named units and unit expressions (`MPa`, `t/mm³`, `W/(m·K)`), each reduced to a dimension and
//! a factor to SI.
//!
//! This table is the only place in FieldCraft where a conversion factor is written down.
use super::{Dimension, UnitError};
/// Exact definitions of the non-metric units (NIST SP 811).
const INCH: f64 = 0.0254;
const FOOT: f64 = 0.3048;
const POUND_MASS: f64 = 0.453_592_37;
const STANDARD_GRAVITY: f64 = 9.806_65;
const POUND_FORCE: f64 = POUND_MASS * STANDARD_GRAVITY;
/// Celsius zero in kelvin.
const CELSIUS_ZERO: f64 = 273.15;
/// One degree Fahrenheit (or Rankine) in kelvin.
const FAHRENHEIT_STEP: f64 = 5.0 / 9.0;
/// Fahrenheit zero in kelvin: 459.67 °R.
const FAHRENHEIT_ZERO: f64 = 459.67 * FAHRENHEIT_STEP;
const L: Dimension = Dimension::LENGTH;
const M: Dimension = Dimension::MASS;
const T: Dimension = Dimension::TIME;
const TH: Dimension = Dimension::TEMPERATURE;
const ONE: Dimension = Dimension::NONE;
const FORCE: Dimension = Dimension::new(1, 1, -2, 0);
const PRESSURE: Dimension = Dimension::new(-1, 1, -2, 0);
const ENERGY: Dimension = Dimension::new(2, 1, -2, 0);
const POWER: Dimension = Dimension::new(2, 1, -3, 0);
const FREQUENCY: Dimension = Dimension::new(0, 0, -1, 0);
/// A named unit: its spellings (the first is the canonical symbol), dimension, the size of one
/// unit in SI, and the SI value of its zero (non-zero only for °C and °F).
struct Atom {
names: &'static [&'static str],
dimension: Dimension,
factor: f64,
offset: f64,
}
const fn atom(names: &'static [&'static str], dimension: Dimension, factor: f64) -> Atom {
Atom { names, dimension, factor, offset: 0.0 }
}
const ATOMS: &[Atom] = &[
// Length.
atom(&["m"], L, 1.0),
atom(&["km"], L, 1e3),
atom(&["cm"], L, 1e-2),
atom(&["mm"], L, 1e-3),
atom(&["µm", "μm", "um"], L, 1e-6),
atom(&["nm"], L, 1e-9),
atom(&["in", "inch"], L, INCH),
atom(&["ft"], L, FOOT),
// Mass.
atom(&["kg"], M, 1.0),
atom(&["g"], M, 1e-3),
atom(&["mg"], M, 1e-6),
atom(&["t", "tonne"], M, 1e3),
atom(&["lb", "lbm"], M, POUND_MASS),
// Time.
atom(&["s"], T, 1.0),
atom(&["ms"], T, 1e-3),
atom(&["µs", "μs", "us"], T, 1e-6),
atom(&["min"], T, 60.0),
atom(&["h"], T, 3600.0),
atom(&["Hz"], FREQUENCY, 1.0),
atom(&["kHz"], FREQUENCY, 1e3),
// Temperature: as an absolute temperature °C and °F carry their offset; inside a compound unit
// (W/(m·°C)) or as a temperature difference they are only a step size.
atom(&["K"], TH, 1.0),
Atom { names: &["°C", "degC", "℃"], dimension: TH, factor: 1.0, offset: CELSIUS_ZERO },
Atom { names: &["°F", "degF", "℉"], dimension: TH, factor: FAHRENHEIT_STEP, offset: FAHRENHEIT_ZERO },
// Force.
atom(&["N"], FORCE, 1.0),
atom(&["mN"], FORCE, 1e-3),
atom(&["kN"], FORCE, 1e3),
atom(&["MN"], FORCE, 1e6),
atom(&["lbf"], FORCE, POUND_FORCE),
atom(&["kip"], FORCE, 1e3 * POUND_FORCE),
// Pressure and stress.
atom(&["Pa"], PRESSURE, 1.0),
atom(&["kPa"], PRESSURE, 1e3),
atom(&["MPa"], PRESSURE, 1e6),
atom(&["GPa"], PRESSURE, 1e9),
atom(&["bar"], PRESSURE, 1e5),
atom(&["psi"], PRESSURE, POUND_FORCE / (INCH * INCH)),
atom(&["ksi"], PRESSURE, 1e3 * POUND_FORCE / (INCH * INCH)),
// Energy and power.
atom(&["J"], ENERGY, 1.0),
atom(&["mJ"], ENERGY, 1e-3),
atom(&["kJ"], ENERGY, 1e3),
atom(&["W"], POWER, 1.0),
atom(&["mW"], POWER, 1e-3),
atom(&["kW"], POWER, 1e3),
// Dimensionless: angles and ratios.
atom(&["rad"], ONE, 1.0),
atom(&["deg", "°"], ONE, std::f64::consts::PI / 180.0),
atom(&["%"], ONE, 1e-2),
];
/// Longest accepted unit or quantity text; longer input is refused unparsed.
pub(super) const MAX_TEXT: usize = 64;
/// Largest accepted exponent in a unit expression (`m^6` is the largest anything needs).
const MAX_POWER: i8 = 6;
/// Deepest accepted parenthesis nesting.
const MAX_DEPTH: usize = 4;
/// A unit, possibly compound, reduced to SI: `value_si = value * factor + offset`.
#[derive(Debug, Clone, PartialEq)]
pub struct Unit {
symbol: String,
dimension: Dimension,
factor: f64,
/// The SI value of the unit's zero: non-zero only for a lone °C or °F.
offset: f64,
/// Whether the unit names an angle (`rad`, `deg`, `°`): only angles accept it.
angle: bool,
}
impl Unit {
/// Parses a unit expression: named units joined by `*`, `·`, a space or `/`, with integer
/// powers (`^2`, `²`, `^-1`, `⁻¹`, or trailing digits as in `mm2`) and parentheses; a leading
/// `1` is a numerator (`1/K`). `1` alone is the unit of a dimensionless quantity. `.` is not a
/// multiplication sign (it reads as a decimal point).
pub fn parse(text: &str) -> Result<Unit, UnitError> {
let text = text.trim();
if text.is_empty() {
return Err(UnitError::Empty);
}
if text.len() > MAX_TEXT {
return Err(UnitError::TooLong);
}
if text == "1" {
return Ok(Unit { symbol: text.to_string(), dimension: Dimension::NONE, factor: 1.0, offset: 0.0, angle: false });
}
let mut p = Parser { chars: text.chars().collect(), pos: 0, text, angle: false };
let (dimension, factor, atoms, offset) = p.expression(0)?;
if p.pos != p.chars.len() {
return Err(p.syntax());
}
// An offset only means something for a lone temperature unit.
let offset = if atoms == 1 { offset } else { 0.0 };
if !(factor.is_finite() && factor > 0.0) {
return Err(UnitError::OutOfRange(text.to_string()));
}
Ok(Unit { symbol: text.to_string(), dimension, factor, offset, angle: p.angle })
}
pub fn symbol(&self) -> &str {
&self.symbol
}
pub fn dimension(&self) -> Dimension {
self.dimension
}
/// The size of one unit in SI (for °C and °F, the size of one degree).
pub fn factor(&self) -> f64 {
self.factor
}
/// Whether the unit names an angle (`rad`, `deg`, `°`).
pub fn is_angle(&self) -> bool {
self.angle
}
/// Converts a value in this unit to SI. `absolute` applies the zero offset (absolute
/// temperatures); temperature differences and every other quantity pass `false`.
pub(super) fn to_si(&self, value: f64, absolute: bool) -> f64 {
value * self.factor + if absolute { self.offset } else { 0.0 }
}
/// Converts an SI value to this unit; the inverse of [`Unit::to_si`].
pub(super) fn express(&self, si: f64, absolute: bool) -> f64 {
(si - if absolute { self.offset } else { 0.0 }) / self.factor
}
}
struct Parser<'a> {
chars: Vec<char>,
pos: usize,
text: &'a str,
angle: bool,
}
/// Dimension, factor, number of named units, offset of the last named unit.
type Parsed = (Dimension, f64, usize, f64);
impl Parser<'_> {
fn syntax(&self) -> UnitError {
UnitError::Syntax(self.text.to_string())
}
fn peek(&self) -> Option<char> {
self.chars.get(self.pos).copied()
}
fn skip_spaces(&mut self) -> bool {
let start = self.pos;
while self.peek().is_some_and(char::is_whitespace) {
self.pos += 1;
}
self.pos > start
}
fn expression(&mut self, depth: usize) -> Result<Parsed, UnitError> {
self.skip_spaces();
// A leading `1` is a numerator: `1/K`, `1/s`.
let leading_one = self.peek() == Some('1') && !self.chars.get(self.pos + 1).is_some_and(char::is_ascii_digit);
let (mut dim, mut factor, mut atoms, mut offset) = if leading_one {
self.pos += 1;
self.skip_spaces();
if self.peek() != Some('/') {
return Err(self.syntax());
}
(Dimension::NONE, 1.0, 0, 0.0)
} else {
self.term(depth)?
};
loop {
let spaced = self.skip_spaces();
let divide = match self.peek() {
Some('*' | '·') => false,
Some('/') => true,
// A space between two units multiplies: `N mm`, `W/(m K)`.
Some(c) if spaced && (c == '(' || c.is_alphabetic() || matches!(c, '°' | '℃' | '℉' | '%' | 'µ' | 'μ')) => {
self.pos -= 1;
false
}
_ => break,
};
self.pos += 1;
self.skip_spaces();
let (d, f, a, o) = self.term(depth)?;
let overflow = || UnitError::OutOfRange(self.text.to_string());
if divide {
dim = dim.checked_div(d).ok_or_else(overflow)?;
factor /= f;
} else {
dim = dim.checked_mul(d).ok_or_else(overflow)?;
factor *= f;
}
atoms += a;
offset = o;
}
Ok((dim, factor, atoms, offset))
}
fn term(&mut self, depth: usize) -> Result<Parsed, UnitError> {
let (dim, factor, atoms, offset) = if self.peek() == Some('(') {
if depth >= MAX_DEPTH {
return Err(self.syntax());
}
self.pos += 1;
let inner = self.expression(depth + 1)?;
self.skip_spaces();
if self.peek() != Some(')') {
return Err(self.syntax());
}
self.pos += 1;
inner
} else {
self.atom()?
};
let power = self.power()?;
if power == 1 {
return Ok((dim, factor, atoms, offset));
}
let dim = dim.checked_pow(power).ok_or_else(|| UnitError::OutOfRange(self.text.to_string()))?;
// A powered unit is never a lone absolute temperature.
Ok((dim, factor.powi(i32::from(power)), atoms + 1, 0.0))
}
fn atom(&mut self) -> Result<Parsed, UnitError> {
let start = self.pos;
while let Some(c) = self.peek() {
if c.is_alphabetic() || matches!(c, '°' | '℃' | '℉' | '%' | 'µ' | 'μ') {
self.pos += 1;
} else {
break;
}
}
if self.pos == start {
return Err(self.syntax());
}
let name: String = self.chars.get(start..self.pos).unwrap_or_default().iter().collect();
let atom = ATOMS.iter().find(|a| a.names.contains(&name.as_str())).ok_or_else(|| UnitError::UnknownUnit(name.clone()))?;
self.angle |= matches!(atom.names.first(), Some(&("rad" | "deg")));
Ok((atom.dimension, atom.factor, 1, atom.offset))
}
/// An optional integer exponent; 1 when absent.
fn power(&mut self) -> Result<i8, UnitError> {
let mut digits = String::new();
if self.peek() == Some('^') {
self.pos += 1;
if self.peek() == Some('-') {
digits.push('-');
self.pos += 1;
}
while let Some(c) = self.peek().filter(char::is_ascii_digit) {
digits.push(c);
self.pos += 1;
}
} else {
while let Some(c) = self.peek() {
let mapped = match c {
'⁻' => '-',
'¹' => '1',
'²' => '2',
'³' => '3',
'⁴' => '4',
'⁵' => '5',
'⁶' => '6',
d if d.is_ascii_digit() => d,
_ => break,
};
digits.push(mapped);
self.pos += 1;
}
if digits.is_empty() {
return Ok(1);
}
}
match digits.parse::<i8>() {
Ok(p) if p != 0 && p.unsigned_abs() <= MAX_POWER.unsigned_abs() => Ok(p),
_ => Err(self.syntax()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-12 * a.abs().max(b.abs())
}
#[test]
fn compound_units_reduce_to_si() {
let cases = [
("t/mm³", Dimension::new(-3, 1, 0, 0), 1e12),
("t/mm^3", Dimension::new(-3, 1, 0, 0), 1e12),
("kg/m3", Dimension::new(-3, 1, 0, 0), 1.0),
("N/mm²", PRESSURE, 1e6),
("W/(m·K)", Dimension::new(1, 1, -3, -1), 1.0),
("mW/(mm*K)", Dimension::new(1, 1, -3, -1), 1.0),
("W/m/K", Dimension::new(1, 1, -3, -1), 1.0),
("K^-1", Dimension::new(0, 0, 0, -1), 1.0),
("lbf·s²/in", M, POUND_FORCE / INCH),
("mm/s", Dimension::new(1, 0, -1, 0), 1e-3),
("MPa·s", Dimension::new(-1, 1, -1, 0), 1e6),
];
for (text, dim, factor) in cases {
let u = Unit::parse(text).unwrap_or_else(|e| panic!("{text}: {e}"));
assert_eq!(u.dimension(), dim, "{text}");
assert!(close(u.factor(), factor), "{text}: {} vs {factor}", u.factor());
}
}
#[test]
fn numerators_and_spaces() {
let k = Unit::parse("1/K").unwrap();
assert_eq!(k.dimension(), Dimension::new(0, 0, 0, -1));
assert_eq!(Unit::parse("1/s").unwrap().dimension(), FREQUENCY);
assert_eq!(Unit::parse("1 / (m·K)").unwrap().dimension(), Dimension::new(-1, 0, 0, -1));
for (spaced, plain) in
[("N · mm", "N·mm"), ("W/(m K)", "W/(m·K)"), ("kg m^-3", "kg/m^3"), ("W / ( m * K )", "W/(m·K)"), ("lbf s² / in", "lbf·s²/in")]
{
let (a, b) = (Unit::parse(spaced).unwrap(), Unit::parse(plain).unwrap());
assert_eq!(a.dimension(), b.dimension(), "{spaced}");
assert!(close(a.factor(), b.factor()), "{spaced}");
}
assert!(Unit::parse("deg").unwrap().is_angle());
assert!(Unit::parse("°").unwrap().is_angle());
assert!(!Unit::parse("%").unwrap().is_angle());
}
#[test]
fn offsets_only_for_lone_temperatures() {
assert!(close(Unit::parse("°C").unwrap().to_si(20.0, true), 293.15));
assert!(close(Unit::parse("°F").unwrap().to_si(32.0, true), 273.15));
assert!(close(Unit::parse("°F").unwrap().to_si(212.0, true), 373.15));
// In a compound unit the degree is a step.
let k = Unit::parse("W/(m·°C)").unwrap();
assert!(close(k.to_si(1.0, true), 1.0));
}
#[test]
fn hostile_units_are_refused() {
for text in [
"",
" ",
"m^",
"m^0",
"m^99",
"m^-128",
"((((((m))))))",
"(m",
"m)",
"m//s",
"mm2x",
"foo",
"m^7",
"BTU",
"m.s",
"1 K",
"11/K",
"2/K",
"1/",
] {
assert!(Unit::parse(text).is_err(), "{text:?}");
}
assert!(matches!(Unit::parse(&"m".repeat(100)), Err(UnitError::TooLong)));
// Exponent overflow across many factors.
assert!(Unit::parse("(m^6)^6·(m^6)^6·(m^6)^6·(m^6)^6").is_err());
}
}
+157
View File
@@ -0,0 +1,157 @@
//! The unit table against hand-computed values.
use fieldcraft_model::units::{DisplaySystem, Preset, Quantity, Unit, UnitError, ValueInput};
fn close(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-12 * a.abs().max(b.abs()).max(1e-300)
}
fn si(q: Quantity, text: &str) -> f64 {
q.parse(text, &DisplaySystem::new(Preset::Si)).unwrap_or_else(|e| panic!("{text}: {e}"))
}
fn shown(q: Quantity, si_value: f64, preset: Preset) -> f64 {
q.express(si_value, &DisplaySystem::new(preset).unit(q).unwrap()).unwrap()
}
#[test]
fn hand_values() {
use Quantity as Q;
// E = 210 GPa = 210 000 MPa = 2.1e11 Pa.
let e = si(Q::Pressure, "210 GPa");
assert!(close(e, 2.1e11));
assert!(close(si(Q::Pressure, "210000 MPa"), e));
assert!(close(shown(Q::Pressure, e, Preset::MmTonneS), 210_000.0));
// 1 N/mm² = 1 MPa.
assert!(close(si(Q::Pressure, "1 N/mm²"), si(Q::Pressure, "1 MPa")));
// Steel density 7850 kg/m³ = 7.85e-9 t/mm³.
let rho = si(Q::Density, "7850 kg/m³");
assert!(close(shown(Q::Density, rho, Preset::MmTonneS), 7.85e-9));
assert!(close(si(Q::Density, "7.85e-9 t/mm^3"), 7850.0));
// 20 °C = 293.15 K, but a difference of 20 °C is 20 K.
assert!(close(si(Q::Temperature, "20 °C"), 293.15));
assert!(close(si(Q::TemperatureDifference, "20 °C"), 20.0));
assert!(close(si(Q::Temperature, "68 °F"), 293.15));
assert!(close(si(Q::TemperatureDifference, "9 °F"), 5.0));
assert!(close(shown(Q::Temperature, 293.15, Preset::MmTonneS), 20.0));
assert!(close(shown(Q::Temperature, 293.15, Preset::InLbfS), 68.0));
// Expansion 12e-6 /°C = 12e-6 /K; 6.667e-6 /°F.
assert!(close(si(Q::ThermalExpansion, "12e-6 °C⁻¹"), 12e-6));
assert!(close(shown(Q::ThermalExpansion, 12e-6, Preset::InLbfS), 12e-6 * 5.0 / 9.0));
// 25 mm/s = 0.025 m/s; g = 9.81 m/s² = 9810 mm/s² = 386.22 in/s².
assert!(close(si(Q::Velocity, "25 mm/s"), 0.025));
assert!(close(shown(Q::Acceleration, 9.81, Preset::MmTonneS), 9810.0));
assert!(close(shown(Q::Acceleration, 9.81, Preset::InLbfS), 9.81 / 0.0254));
// Steel in in-lbf-s: E = 2.1e11 Pa = 30.458 Msi; ρ = 7850 kg/m³ = 7.345e-4 lbf·s²/in⁴.
let psi = 4.448_221_615_260_5 / (0.0254 * 0.0254);
assert!(close(shown(Q::Pressure, e, Preset::InLbfS), 2.1e11 / psi));
assert!(close(shown(Q::Density, rho, Preset::InLbfS), 7850.0 * 0.0254 / 4.448_221_615_260_5 * 0.0254 * 0.0254 * 0.0254));
// Steel conductivity 50 W/(m·K) is 50 mW/(mm·K); specific heat 460 J/(kg·K) is 4.6e8 mJ/(t·K).
assert!(close(shown(Q::ThermalConductivity, 50.0, Preset::MmTonneS), 50.0));
assert!(close(shown(Q::SpecificHeat, 460.0, Preset::MmTonneS), 4.6e8));
// 10 W/(m²·K) = 0.01 mW/(mm²·K); 1 kW/m² = 1 mW/mm².
assert!(close(shown(Q::HeatTransferCoefficient, 10.0, Preset::MmTonneS), 0.01));
assert!(close(shown(Q::HeatFlux, 1000.0, Preset::MmTonneS), 1.0));
// Water viscosity 1e-3 Pa·s = 1e-9 MPa·s; 1 N·mm = 1e-3 N·m = 1 mJ.
assert!(close(shown(Q::DynamicViscosity, 1e-3, Preset::MmTonneS), 1e-9));
assert!(close(si(Q::Moment, "1 N·mm"), si(Q::Energy, "1 mJ")));
// 90 deg = π/2 rad; 5 % = 0.05.
assert!(close(si(Q::Angle, "90 deg"), std::f64::consts::FRAC_PI_2));
assert!(close(si(Q::Dimensionless, "5 %"), 0.05));
}
#[test]
fn every_preset_unit_has_its_quantitys_dimension() {
for preset in Preset::ALL {
let display = DisplaySystem::new(preset);
for &q in Quantity::ALL {
let unit = display.unit(q).unwrap_or_else(|e| panic!("{preset:?} {q:?}: {e}"));
assert_eq!(unit.dimension(), q.dimension(), "{preset:?} {q:?} `{}`", unit.symbol());
}
assert_eq!(Preset::from_label(preset.label()), Some(preset));
}
}
#[test]
fn mm_tonne_second_is_consistent() {
// A consistent system: force = mass · acceleration and stress = force / area hold with no
// factor, so the deck writers can write its numbers directly.
let d = DisplaySystem::new(Preset::MmTonneS);
let f = |q| d.unit(q).unwrap().factor();
use Quantity as Q;
assert!(close(f(Q::Force), f(Q::Mass) * f(Q::Acceleration)));
assert!(close(f(Q::Pressure), f(Q::Force) / f(Q::Area)));
assert!(close(f(Q::Energy), f(Q::Force) * f(Q::Length)));
assert!(close(f(Q::Density), f(Q::Mass) / f(Q::Volume)));
let i = DisplaySystem::new(Preset::InLbfS);
let g = |q| i.unit(q).unwrap().factor();
assert!(close(g(Q::Force), g(Q::Mass) * g(Q::Acceleration)));
assert!(close(g(Q::Pressure), g(Q::Force) / g(Q::Area)));
}
#[test]
fn round_trips_through_every_preset() {
let values = [(Quantity::Pressure, 2.1e11), (Quantity::Density, 7850.0), (Quantity::Temperature, 293.15), (Quantity::Length, 0.0254)];
for preset in Preset::ALL {
let d = DisplaySystem::new(preset);
for (q, v) in values {
let text = q.format(v, &d).unwrap();
let back = q.parse(&text, &d).unwrap();
assert!((back - v).abs() <= 1e-5 * v.abs(), "{preset:?} {q:?}: {text} -> {back}");
}
}
let mm = DisplaySystem::default();
assert_eq!(Quantity::Pressure.format(2.1e11, &mm).unwrap(), "210000 MPa");
assert_eq!(Quantity::Density.format(7850.0, &mm).unwrap(), "7.85e-9 t/mm³");
assert_eq!(Quantity::Temperature.format(293.15, &mm).unwrap(), "20 °C");
assert_eq!(Quantity::Dimensionless.format(0.3, &mm).unwrap(), "0.3");
}
#[test]
fn inputs_from_commands() {
let mm = DisplaySystem::default();
let parse = |json: &str| serde_json::from_str::<ValueInput>(json).unwrap().to_si(Quantity::Pressure, &mm);
assert!(close(parse("210000").unwrap(), 2.1e11)); // bare number: display system (MPa)
assert!(close(parse(r#""210 GPa""#).unwrap(), 2.1e11));
assert!(close(parse(r#"{"value": 210, "unit": "GPa"}"#).unwrap(), 2.1e11));
assert!(close(Quantity::Pressure.parse("210000", &mm).unwrap(), 2.1e11));
assert!(serde_json::from_str::<ValueInput>(r#"{"value": 1, "unit": "Pa", "extra": 0}"#).is_err());
let over = DisplaySystem::default().with_override(Quantity::Energy, "J").unwrap();
assert_eq!(Quantity::Energy.format(2.0, &over).unwrap(), "2 J");
assert!(DisplaySystem::default().with_override(Quantity::Energy, "MPa").is_err());
}
#[test]
fn hostile_values_are_refused() {
let mm = DisplaySystem::default();
use Quantity as Q;
let wrong = Q::Pressure.parse("210 mm", &mm);
assert!(matches!(wrong, Err(UnitError::WrongDimension { .. })), "{wrong:?}");
assert_eq!(wrong.unwrap_err().to_string(), "`mm` is L, but a pressure is L^-1 M T^-2");
for text in ["", "GPa", "nan GPa", "inf Pa", "1e400 Pa", "1e300 GPa", "210 GPa extra", "210 parsecs", "--1 Pa"] {
assert!(Q::Pressure.parse(text, &mm).is_err(), "{text:?}");
}
assert!(Q::Density.parse("1e300 t/mm³", &mm).is_err());
assert!(matches!(Q::Temperature.parse("-300 °C", &mm), Err(UnitError::BelowAbsoluteZero(_))));
assert!(Q::TemperatureDifference.parse("-300 °C", &mm).is_ok());
assert!(Q::Pressure.to_si(f64::NAN, &Unit::parse("Pa").unwrap()).is_err());
assert!(Q::Pressure.parse(&"9".repeat(500), &mm).is_err());
let input = ValueInput::WithUnit(fieldcraft_model::units::WithUnit { value: f64::INFINITY, unit: "Pa".into() });
assert!(input.to_si(Q::Pressure, &mm).is_err());
}
#[test]
fn angles_and_ratios_are_kept_apart() {
let mm = DisplaySystem::default();
use Quantity as Q;
assert!(matches!(Q::Angle.parse("5 %", &mm), Err(UnitError::WrongKind { .. })));
assert!(matches!(Q::Dimensionless.parse("1 deg", &mm), Err(UnitError::WrongKind { .. })));
assert!(matches!(Q::Angle.parse("90 mm", &mm), Err(UnitError::WrongDimension { .. })));
assert!(Q::Pressure.parse("1 MPa·deg", &mm).is_err());
assert!(close(Q::Angle.parse("90 °", &mm).unwrap(), std::f64::consts::FRAC_PI_2));
assert!(close(Q::Angle.parse("90", &mm).unwrap(), std::f64::consts::FRAC_PI_2)); // display: deg
assert!(close(Q::Dimensionless.parse("0.3", &mm).unwrap(), 0.3));
assert!(close(Q::ThermalExpansion.parse("12e-6 1/K", &mm).unwrap(), 12e-6));
assert!(close(Q::Frequency.parse("0.01 1/s", &mm).unwrap(), 0.01));
assert!(close(Q::ThermalConductivity.parse("50 W/(m K)", &mm).unwrap(), 50.0));
}
+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() {
+114 -11
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 }) => {
@@ -1094,7 +1115,7 @@ impl ModelApp {
/// frame; `None` when no results are shown.
fn plot_key(&self) -> Option<PlotKey> {
let (view, result, current) = results::shown(&self.session)?;
let frame = results::frame(view, self.now);
let frame = results::frame(view, result, self.now);
let pick = results::pick(view, result, frame);
Some(PlotKey { revision: self.revision, result: Arc::as_ptr(result) as usize, current, view: view.clone(), pick, frame })
}
@@ -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()));
+207 -29
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,29 @@ 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) => {
let label = usize::try_from(label).ok()?;
// Past the cells: a face held at a temperature that is the field's extreme.
match label.checked_sub(f.grid.len()) {
Some(k) => self.flow.as_ref()?.wall_positions.get(k).copied().flatten(),
None => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, label))),
}
}
_ => 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.
@@ -114,9 +143,12 @@ pub fn shown(session: &Session) -> Option<(&ResultsView, &Arc<StepResult>, bool)
Some((view, result, current))
}
/// The animation frame shown at time `now` (seconds), when animating.
pub fn frame(view: &ResultsView, now: f64) -> Option<u64> {
(view.animate && view.deformed && now.is_finite()).then(|| ((now.max(0.0) / PERIOD * FRAMES as f64).floor() as u64) % FRAMES)
/// The animation frame shown at time `now` (seconds), when animating. A deformed step swings its
/// shape; a fluid step plays its frames though it does not deform; a step with nothing to animate
/// (Animate on but Deformed off, and not fluid) stays still.
pub fn frame(view: &ResultsView, result: &StepResult, now: f64) -> Option<u64> {
let plays = view.deformed || matches!(result.kind(), Kind::Fluid(_));
(view.animate && plays && now.is_finite()).then(|| ((now.max(0.0) / PERIOD * FRAMES as f64).floor() as u64) % FRAMES)
}
/// The deformation multiplier of an animation frame: a mode swings through ±1, a static step
@@ -139,6 +171,7 @@ pub fn pick(view: &ResultsView, result: &StepResult, frame: Option<u64>) -> Pick
let mut p = view.pick();
let frames = match result.kind() {
Kind::Explicit(e) => e.frames.len(),
Kind::Fluid(f) => f.frames.len(),
Kind::Static(r) => r.nonlinear.as_ref().map_or(0, |h| h.frames.len()),
_ => 0,
};
@@ -156,8 +189,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 +213,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 +224,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 +240,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 +352,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 +428,128 @@ 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. This plate_in_a_tank fixture sets no initial
// velocity, so it starts at rest (unlike the flow-past sample's uniform inlet field).
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)));
}
/// Animating a fluid step plays its frames: the clock maps across the frames once per cycle,
/// and results.animate turns it on without deforming the part (the flow start-up transient).
#[test]
fn a_fluid_step_animates_its_frames() {
let mut s = plate_in_a_tank(false);
let (base, _) = fieldcraft_engine::view::display(&s);
s.execute("results.animate", &json!({})).unwrap();
let view = s.results_view.as_ref().unwrap();
let result = s.latest_result(&view.step).unwrap().0;
assert!(view.animate && !view.deformed, "fluid animates its frames, not a deformation");
// The animation clock runs even though the fluid step does not deform.
assert_eq!(frame(view, result, 0.0), Some(0));
assert_eq!(frame(view, result, PERIOD * 0.5), Some(FRAMES / 2));
// The clock maps across the three frames: the plate_in_a_tank field, which starts at rest
// (the fixture sets no initial velocity, unlike the flow-past sample), then the settled flow.
let n = plot_at(&s, &base, None).unwrap().frame().unwrap().2;
assert_eq!(n, 3);
assert_eq!(plot_at(&s, &base, Some(0)).unwrap().pick.frame, Some(0));
assert_eq!(plot_at(&s, &base, Some(FRAMES - 1)).unwrap().pick.frame, Some(n - 1));
}
#[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();
let at_rest = plot_at(&s, &base, None).unwrap();
let c = at_rest.contour.as_ref().unwrap();
let cells = &at_rest.flow.as_ref().unwrap().contour.as_ref().unwrap().values;
assert!(cells.iter().filter(|t| t.is_finite()).all(|t| *t == 20.0), "the frame selector changes the temperature shown");
// The faces are held at 60 °C from the start: the legend's maximum is a face, at a point on it.
assert_eq!(c.max.1, 60.0);
assert!(at_rest.extreme_position(c.max.0).is_some());
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();
@@ -383,10 +557,14 @@ pub(crate) mod tests {
s.execute("results.animate", &json!({})).unwrap();
let base = DisplayMesh::from_model(&s.model);
let view = s.results_view.clone().unwrap();
assert_eq!(frame(&view, 0.0), Some(0));
assert_eq!(frame(&view, PERIOD * 0.25), Some(FRAMES / 4));
assert_eq!(frame(&view, PERIOD * 1.25), Some(FRAMES / 4));
let (_, result, _) = shown(&s).unwrap();
let result = s.latest_result(&view.step).unwrap().0;
assert_eq!(frame(&view, result, 0.0), Some(0));
assert_eq!(frame(&view, result, PERIOD * 0.25), Some(FRAMES / 4));
assert_eq!(frame(&view, result, PERIOD * 1.25), Some(FRAMES / 4));
// Animate on but Deformed off, and not a fluid step: nothing to animate, so it stays still.
let mut still = view.clone();
still.deformed = false;
assert_eq!(frame(&still, result, PERIOD * 0.25), None);
assert!((swing(result, Some(FRAMES / 4)) - 1.0).abs() < 1e-12);
assert!((swing(result, Some(3 * FRAMES / 4)) + 1.0).abs() < 1e-12);
// At frame 0 a mode is undeformed; a quarter cycle later fully deformed.
+202 -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,21 @@ 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})));
}
let mut animate = plot.view.animate;
if ui.checkbox(&mut animate, "Animate").on_hover_text("Play the frames from the start-up transient to the settled flow").changed() {
run.push(("results.animate", json!({"on": animate})));
}
});
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 +437,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 +943,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"
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more