Compare commits

..
133 Commits
Author SHA1 Message Date
jyvikoandClaude Opus 5.5 3561b0830b engine: geometry.face.split, a face split along a datum plane (#508)
geometry.face.split {name, face, datum} records a face split; it is
applied after the merges whenever the design is opened or cut again,
follows its datum (which it holds), undoes, and is saved with the
model. geometry.face.split.delete removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:27:03 -04:00
jyvikoandClaude Opus 5.5 99c4a2628f model: face splits along datum planes (#508)
A datum plane a split uses is in use, as one a partition cuts by.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:14:54 -04:00
jyvikoandClaude Opus 5.5 c5862b61c9 geometry: a face split along a plane for meshing only (#508)
The face's triangles are cut along the plane, split:<name>:a on its
negative side and :b on the other, both on the face's surface, with the
cut as a new edge between them and the face's edges cut where the plane
crosses them. Applied after the merges; the B-rep is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:14:54 -04:00
jyvikoandClaude Opus 5.5 59a02b0172 docs: the meshing skill's results on the corpus and samples (#482)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:57:51 -04:00
jyvikoandClaude Opus 5.5 a5c71bc5b0 skills: meshing skill fixes from its corpus run (#482)
Plan before geometry.check (its size), mesh.plan.apply's own symmetry and the plan's saved
name, the fallback order, touching bodies, tet aspect ratio, undoing a relaxation that leaves
its region worse, and merging the cells of a split part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:57:51 -04:00
jyvikoandClaude Opus 5.5 cb4141d0f0 testkit: run the meshing skill on the corpus and samples (cargo xtask skill-results)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:57:51 -04:00
jyvikoandClaude Opus 5.5 bb3def1cf5 mesh.plan: decomposition proposals for unmapped features
For a body with features the sweep cannot mesh, mesh.plan replies a
decomposition: the cuts that box those features into one cell, the region,
and the commands that make the cuts and mesh the cells with pyramids. A plate
with a joined dome: 5 cuts, 17 hex cells swept together, the dome cell tet,
valid and conforming, end displacement in tension within 0.4% of all-tet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:57:41 -04:00
jyvikoandClaude Opus 5.5 72ebd5bc7f mesh.cells: tet cells meet hex cells through pyramids
With "transition": "pyramid" (the default; "tie" keeps the old join) a tet
cell is meshed after the hex cells, with a pyramid on every hex face of its
cuts and its tetrahedra on the pyramids' sides: one conforming mesh, no tie.
Where it cannot be built the cell says why (no_pyramids) and is tied. The
apex height is 0.4 of the quad's edge, so pyramids on faces meeting at a
right angle do not cross.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:57:41 -04:00
jyvikoandClaude Opus 5.5 68e9bcde92 mesh: pyramid transition surface and frozen faces in the tet mesher
transition::pyramid_surface puts a pyramid on every hex interface quad and
builds the tet region's surface closed by the pyramid sides, re-stitching the
neighbouring faces to the hex rim nodes. QualityOptions::frozen_faces keeps
those sides exactly (TetComplex refuses changes that remove or add a frozen
face; their vertices are corners), and apex_points checks the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:57:41 -04:00
jyvikoandClaude Opus 5.5 3da332be77 ui: pyramid colour stands out from the selection pinks (fix develop CI)
The pyramid5 colour added in #517 sat within the selection test's distance of
the hover pink (dark and light). Darker violet in both themes; the headless
palette matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:56:10 -04:00
jyvikoandClaude Opus 5.5 56553909f1 mesh: guarded relax keeps the region's largest edge ratio
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:55:59 -04:00
jyvikoandClaude Opus 5.5 cd6c53adbb ui: mesh.node.relax runs as a job (window stays responsive, Cancel)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:55:59 -04:00
jyvikoandClaude Opus 5.5 4908f5679c mesh: strong relaxation morphs round the bad elements (harmonic interpolation within a reach)
The owner's direction: bad nodes move to better places, the nodes within a reach follow by
the graph Laplacian's harmonic extension, edges held, nothing outside the reach moves.
mesh.node.relax strong mode takes below and reach and reports each round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:55:59 -04:00
jyvikoandClaude Opus 5.5 6f94346253 mesh: mesh.node.relax mode strong, below and layers; partitioned bodies count as their cells
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:55:59 -04:00
jyvikoandClaude Opus 5.5 7f2d4cb6e1 mesh: strong relaxation optimises a whole region toward scaled Jacobian 1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:55:59 -04:00
jyvikoandClaude Opus 5.5 b3a7f12472 mesh: a closed edge's start is no curve end of the swept cross-section (#512)
The extrude/revolve cross-section took both ends of every B-rep edge as curve ends. A closed
edge without a vertex on it (a hole's rim after geometry.faces.merge of its halves) starts
wherever its chain was picked up, the old seam vertex, so that point stayed a curve end and a
node column stayed on the merged boundary. Only open edges' ends (and the B-rep vertices) are
curve ends now; the layout and interval assignment place the rim's nodes.

The hex test runs on a square plate and asserts the seams carry exactly as many nodes as the
same lines turned a quarter round the hole (10 and 10; before 5 and 10): the corner layout
puts arc-midpoint nodes there by symmetry, so 0 is not the right target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 13:39:24 -04:00
jyvikoandClaude Opus 5.5 3a5a8213a1 fem: pyramid5 element (solver, quality, decks, VTK)
A five-node pyramid, the conforming transition between a hexahedron's face
and tetrahedra: Bedrosian's rational shape functions with a collapsed (Duffy)
Gauss rule, a certified Jacobian on the base square with exact volume, a
scaled Jacobian, C3D5 / CPYRAM / LS-DYNA degenerate solid, VTK_PYRAMID and
the binary mesh code 13. A distorted hex8-pyramid5-tet4 transition mesh
passes the patch test and uniaxial tension exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 12:48:01 -04:00
jyvikoandClaude Opus 5.5 4e6fd59409 engine: geometry.stitch, imported surfaces stitched for meshing (#507)
geometry.stitch {name, surface, tolerance} records a stitch; the
surfaces keep what was read (TaggedSurface::original) and are stitched
again from it whenever a surface is imported, the model opened, or a
stitch added or deleted (geometry.stitch.delete). Opening a model also
warns about merges that no longer apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 12:07:16 -04:00
jyvikoandClaude Opus 5.5 12b3128f53 model: stitches of imported surfaces, kept in order (#507)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 12:07:16 -04:00
jyvikoandClaude Opus 5.5 8cd7b43c35 geometry: stitch, a surface's gaps under a tolerance closed (#507)
Open points closer than the tolerance become one point and a point on
an open edge near the inside of another splits that edge's triangle;
edges that are not open are never touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 12:07:16 -04:00
jyvikoandClaude Opus 5.5 793fbe3b71 mesh: corner copy keeps the best of several cuts, its corner taken from the whole cell's mesh
The whole cell is meshed with its layout split at each candidate cut and
the sheets there held planar (ProjectOptions::planes); the corner of the
best is added as the low cell's mesh with the whole mesh's face tags,
mirrored, and the middle meshed round it. Accepted only when the corner
and the middle are no worse than the cell meshed whole; else the cell is
meshed whole and corners.skipped says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 12:07:04 -04:00
jyvikoandClaude Opus 5.5 db0f0567a0 mesh: the corner copy takes the corner from the whole cell's mesh, sheets at the cuts held planar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 12:07:04 -04:00
jyvikoandClaude Opus 5.5 fea61fb180 mesh: a symmetric plan copies one corner onto the other (#474, #491)
With symmetry, when the fundamental cell's two ends along another in-plane
axis are alike (layout planes and surface mirror-symmetric), the cell is
cut midway into the next block sheet and at its image; the low corner is
meshed with its blocks, mirrored onto the high one, and the middle meshed
with both cut faces fixed (#490). A plan cell's cut faces another cell's
mesh covers are taken as given. corners: false opts out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 12:07:04 -04:00
jyvikoandClaude Opus 5.5 58174ba168 geometry.face.absorb: tiny and sliver faces into a neighbour (#506)
A face merge may keep one of its faces' names (Merge::into, model
FaceMerge::into): absorbing a tiny face leaves the neighbour by its own
name, so sets on it still select it. Shape::absorbing_neighbour finds
the face a tiny one shares the longest edges with. geometry.face.absorb
absorbs one face (into a named or the best neighbour) or, from_check,
every small and sliver face the last geometry.check marked; check marks
now carry the small face they found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 11:13:23 -04:00
jyvikoandClaude Opus 5.5 ce02d263c7 engine: geometry.faces.merge, faces meshed as one (virtual topology, #505)
geometry.faces.merge {name, faces} records a face merge in the model;
it is applied after the partitions whenever the design is opened or cut
again, undoes through the journal and is saved with the model. Meshers,
mesh.plan (features and rings see merge:<name>; a merged face is
prismatic when its B-rep faces are), checks and the viewport see one
face, drawn in the warm colour. geometry.faces.merge.delete removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 11:09:11 -04:00
jyvikoandClaude Opus 5.5 22d709e604 model: face merges, kept in the order they apply (#505)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 11:09:11 -04:00
jyvikoandClaude Opus 5.5 b126c2293b geometry: virtual faces, adjacent faces merged for meshing only (#505)
A Merge makes adjacent faces of a body one virtual face in what meshers
take from it (tessellation tags, faces, surfaces, edge and vertex names):
the edges inside it are dropped and the pieces of an edge it leaves are
joined; the B-rep is unchanged. Design::merged applies merges after the
partitions and lists the ones that fail as feature errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 11:09:11 -04:00
dependabot[bot] f38a3be917 deps: bump signal-hook from 0.3.18 to 0.4.5
Bumps [signal-hook](https://github.com/vorner/signal-hook) from 0.3.18 to 0.4.5.
- [Changelog](https://github.com/vorner/signal-hook/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vorner/signal-hook/compare/v0.3.18...v0.4.5)

---
updated-dependencies:
- dependency-name: signal-hook
  dependency-version: 0.4.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 10:58:37 -04:00
dependabot[bot] cbac4c7e27 deps: bump pollster from 0.4.0 to 1.0.1
Bumps [pollster](https://github.com/zesterer/pollster) from 0.4.0 to 1.0.1.
- [Changelog](https://github.com/zesterer/pollster/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zesterer/pollster/commits)

---
updated-dependencies:
- dependency-name: pollster
  dependency-version: 1.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 10:58:27 -04:00
dependabot[bot] 38678253fa deps: bump minisign from 0.9.1 to 0.10.0
Bumps [minisign](https://github.com/jedisct1/rust-minisign) from 0.9.1 to 0.10.0.
- [Commits](https://github.com/jedisct1/rust-minisign/compare/0.9.1...0.10.0)

---
updated-dependencies:
- dependency-name: minisign
  dependency-version: 0.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 10:58:16 -04:00
dependabot[bot] b88a8b7093 deps: bump getrandom from 0.3.4 to 0.4.3
Bumps [getrandom](https://github.com/rust-random/getrandom) from 0.3.4 to 0.4.3.
- [Changelog](https://github.com/rust-random/getrandom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/getrandom/compare/v0.3.4...v0.4.3)

---
updated-dependencies:
- dependency-name: getrandom
  dependency-version: 0.4.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 10:58:06 -04:00
jyvikoandClaude Opus 5.5 e4233ff794 mesh: format
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:55:27 -04:00
jyvikoandClaude Opus 5.5 7b7d38fabd mesh: fixed patch vertex matching takes the map entry once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:55:27 -04:00
jyvikoandClaude Opus 5.5 6f07e4c09c mesh: the projection mesher takes fixed boundary patches as given (#490)
A patch of quads already meshed on a face of the body (a neighbour's mesh)
is matched to the block faces on it: block vertices to its nodes (its
singular nodes first, then along its grid lines), block edges along its
grid lines, faces filled quad by quad. Those faces take the patch's
divisions and nodes, held through smoothing. mesh.hex.project takes
"fixed": [face names] and builds the patch from other bodies' meshes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:55:27 -04:00
dependabot[bot] e887e3a8e5 deps: bump base64 from 0.22.1 to 0.23.1
Bumps [base64](https://github.com/marshallpierce/rust-base64) from 0.22.1 to 0.23.1.
- [Changelog](https://github.com/marshallpierce/rust-base64/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/marshallpierce/rust-base64/compare/v0.22.1...v0.23.1)

---
updated-dependencies:
- dependency-name: base64
  dependency-version: 0.23.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 10:55:16 -04:00
jyvikoandClaude Opus 5.5 d5fb100182 mesh.plan.apply: name the faces of the meshed bodies left untagged
The reply's untagged_faces lists every face of the plan's bodies no
element face carries; the acceptance checks require it empty, and a
symmetric filleted base checks a set by every face of every cell (#494).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:31 -04:00
jyvikoandClaude Opus 5.5 937e557e5a mesh.mirror: carry each face tag to the image face its reflection covers
The image's face names came from one sample per source face (the first
element face's centre), so a source face whose sampled element face was
off it, or a sliver, mapped wrong and the true image face went untagged.
Each source face now maps to the image faces its own reflection covers,
and each element face to the nearest of those.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:31 -04:00
jyvikoandClaude Opus 5.5 18dbc29357 mesh: tag each projected quad with the face it lies on, slivers included
project_trimmed accepted the exact surface's point up to a fifth of the
nearest triangle's median edge off the face: on a tall fillet piece
(triangles 30 mm long) that let a point on a sibling piece of the same
cylinder count as on it, so every quad round the corner took the first
piece's name. The slack is now the triangle's own chordal gap to the
surface, doubled.

A face narrower than the elements (the sliver a corner boss leaves of the
inner fillet) gets one row of the quads it runs through, from the
neighbour with the most quads left. Tessellation::nearest_face finds the
face nearest a point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:31 -04:00
jyvikoandClaude Opus 5.5 fcc068bce9 docs: the ring-and-pillow layout's papers and the meshing skill's plan step (#455)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:26 -04:00
jyvikoandClaude Opus 5.5 7c3107dec0 mesh: a crank arm's plan takes rings round its bosses and a pillow from a carved grid (#455, #190)
When the grid layout does not fit a swept body (sides at an angle to the axes, root arcs where
an arm meets its bosses), mesh.plan tries the ring-and-pillow layout (cmd/plan/pillow.rs): each
boss is an annulus from its bore or pedal hole to its circle through every level, the arm a grid
carved a margin inside the outline and the bosses, with a pillow layer of blocks to the wall each
boundary face faces; a grid corner facing a boss and the outline gets a corner block reaching the
kink where the outline leaves the boss's circle. The old crank test (frame cuts) becomes the
heavy acceptance test at sizes 2.5, 3 and 4; the moved frame cut keeps a unit test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:26 -04:00
jyvikoandClaude Opus 5.5 992bc71b6b engine, render: symmetry plane command on any datum plane (#242)
model.boundary_condition.symmetry takes a datum plane of any orientation,
finds the faces on it and reports the plane's unit normal when it is
inclined; the glyph draws a support along that normal. Verified: half a plate
cut on its diagonal with the plane deflects as the half mirrored across it
(6.7e-13), the mirror conforming on the plane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:20 -04:00
jyvikoandClaude Opus 5.5 a226b7dcac explicit: symmetry about inclined planes (#242)
At an inclined plane's nodes the velocity along its normal is removed every
increment, from the components no prescribed velocity holds (planes meeting
at a node orthonormalized, so one pass is exact); a prescribed motion off the
plane is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:20 -04:00
jyvikoandClaude Opus 5.5 ac1037e1af solver, decks: symmetry about an inclined plane (#242)
The symmetry condition takes any plane normal ({"normal": [nx, ny, nz]}).
An inclined one holds n·u = 0 and, at shell, beam and reference point
nodes, the rotations about the two in-plane axes: constraint rows the
implicit elimination already handles, its support forces reported as
reactions. Decks: Abaqus *EQUATION, Nastran MPC sets per subcase, LS-DYNA
*BOUNDARY_SPC in a *DEFINE_COORDINATE_SYSTEM along the normal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:20 -04:00
jyvikoandClaude Opus 5.5 a079e51f9d repo: move to github.com/tangency-labs/fieldcraft (URLs, updater, Pages)
The updater's release endpoint, docs, README, release notes and the Pages
URL point at the tangency-labs org. App IDs (io.github.jyviko.fieldcraft)
stay unchanged so installed copies keep their identity and settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:13 -04:00
jyvikoandClaude Opus 5.5 b74d15e44d mesh: relax by face names the listed faces with no mesh (#471)
Faces no element is tagged with are left out and named in the reply
(faces_without_mesh); none tagged is the error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 09:21:21 -04:00
jyvikoandClaude Opus 5.5 7d269c58f9 ui: Relax Nodes takes picked faces (#471)
Use selection puts the selected design faces in the form as faces, in
place of a sphere; Shift/Ctrl-click picks faces while the form is open,
and every click does once the form lists faces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 09:21:21 -04:00
jyvikoandClaude Opus 5.5 3ee622f62a mesh: relax nodes by CAD face; params docs match their forms (#471)
mesh.node.relax takes faces: [{body, face}] (every node on those B-rep
faces and the nodes of the elements touching them, sliding on the faces)
and elements in its form; the undocumented-by-form set is dropped.
A schema test checks every top-level key a params doc names is accepted,
which also found mesh.hex.extrude normal, mesh.hex.project max_arc_angle
and mesh.plan.apply name missing from their forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 09:21:21 -04:00
jyvikoandClaude Opus 5.5 4bb2134b47 mesh: lift from the best sweep grid only for a real gain in the smallest scaled Jacobian (#489)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:55:09 -04:00
jyvikoandClaude Opus 5.5 655a60c138 mesh: test that more smoothing iterations never lower the smallest scaled Jacobian (#489)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:55:09 -04:00
jyvikoandClaude Opus 5.5 3ee542453e mesh: projection smoother keeps the best sweep grid and lifts from the better start (#489)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:55:09 -04:00
jyvikoandClaude Opus 5.5 78b8fc8d6c engine: one scene builder for the window and headless pictures
engine::view::build (Style, Show, Palette) is the viewport's builder,
moved from ui-egui; ui-egui maps theme::Tokens onto a Palette and calls
it. Palette::LIGHT now equals the light theme's tokens. Test: the app's
scene and engine::view::frame's are identical for the same session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:29:38 -04:00
jyvikoandClaude Opus 5.5 6136770c1c ui: ui.render draws on the window's GPU
The app keeps a capturer on its own device; ui.render draws with the
viewport's pipelines there, on the CPU with the reason otherwise, and
the reply names the renderer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:27:21 -04:00
jyvikoandClaude Opus 5.5 b655af305a gpu: Capturer::on_device and Renderer::report
A capturer on a device already open (the app's), and the renderer's
JSON shared by every reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:27:21 -04:00
jyvikoandClaude Opus 5.5 7f0af28e9c ui: test that the window draws byte-identically to a capture
The window's paint callback (slots, prepare, paint) draws the sample,
its symbols and an overlay into the capture's offscreen pass; the
pixels equal Capturer::rgba's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:27:17 -04:00
jyvikoandClaude Opus 5.5 7714afbced gpu: Capturer::offscreen draws one pass that a caller records
Capturer::rgba becomes offscreen() (sizes, error scopes, MSAA, depth,
read-back) plus finish() (background and labels), so another painter,
such as the window's paint callback, can draw into the same pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:27:17 -04:00
jyvikoandClaude Opus 5.5 eae709b3c0 mesh: a body the user's partitions cut is not meshed by symmetry (#473)
With a user's partition on a symmetric body, applying the whole body's plan cut it again by its
symmetry plane and mirrored one cell, leaving the other half unmeshed ("the mirror images do not
reach cell Base/b/a"). Such a body is its cells: the symmetry is not used and the plan is refused
with the cells named, as the replay of the saved Pi 5 model shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 07:56:10 -04:00
jyvikoandClaude Opus 5.5 80da2d968b mesh: applying a whole body's plan to a body partitions cut says to plan a cell (#473)
After geometry.partition cuts a body, the body is its cells: a projection plan of the whole body
could not open it and failed with "no body `Base` (open a design first)". It now names the cells
and says to plan and apply a cell (mesh.plan {"body": "Base/a"}) or delete the partitions. Tests
cover a design opened directly and a saved model opened again, partitioned after opening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 07:56:10 -04:00
jyvikoandClaude Opus 5.5 c6bc4d2ba0 ci: prove the lavapipe GPU path draws (FIELDCRAFT_REQUIRE_GPU=1)
The drawing tests skipped silently without an adapter. Under
FIELDCRAFT_REQUIRE_GPU=1 they fail instead, and the snapshot and MCP
screenshot tests require a GPU renderer. CI runs them with
FIELDCRAFT_GPU=software on lavapipe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 07:52:10 -04:00
jyvikoandClaude Opus 5.5 b905f1bc89 mesh: a projection plan meshes a symmetric body by its symmetry; partitioned bodies and cells plan (#473)
mesh.plan.apply finds the planes a projection plan's body is its own mirror image across
(through the middle of its extent, square to the sweep's in-plane axes, through no ring: every
triangle middle of its tessellation, a little inside and outside, has its mirror image inside and
outside alike). It cuts the body by them, plans and meshes the cell on their negative side, and
mirrors that mesh across each plane in turn (mesh.mirror, joined node to node on the planes), so
mirror-image regions mesh exactly alike. `symmetry: false` meshes the whole body.

A cell of a partitioned body is planned from the features that built its body, with the rings it
holds; a partitioned body is planned whole as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 07:26:28 -04:00
jyvikoandClaude Opus 5.5 4ad793ca5e skills: relax around marked elements (relax takes no set)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 07:19:24 -04:00
jyvikoandClaude Opus 5.5 c160712343 skills: meshing skill follows today's loop (look, symmetry, plan, mark, relax)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 07:19:24 -04:00
jyvikoandClaude Opus 5.5 19c0538dd8 mcp, cli, ui: camera target, fit and fov in screenshots, snapshot --camera and ui.camera; screenshot section (#467)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 06:28:57 -04:00
jyvikoandClaude Opus 5.5 1f183e04ea engine: aim a picture's camera at a target (#467)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 06:28:57 -04:00
jyvikoandClaude Opus 5.5 4fa6292749 render: fit a camera's distance about its target
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 06:28:57 -04:00
jyvikoandClaude Opus 5.5 927accce45 mesh: smoothing straightens the face grid lines the compass search leaves zigzagging (#402)
The compass search after the sweeps moves the nodes of the worst elements one at a time, each to
its own best place. Since it lifts every element below 0.7 (#462) it moves many surface nodes,
and a column of them on a face ends at scattered places: the enclosure base's inner walls showed
zigzag element lines in a column next to each corner, full height. A last pass now moves each
surface node back toward the mean of its neighbours on the surface, as far as no element round
it falls below the lower of its own value and the mesh's smallest after the search, so the lines
straighten and the mesh's worst element is no worse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 06:05:54 -04:00
jyvikoandClaude Opus 5.5 4a8d34d619 ui: XY plot in the results panel, drawn by the energy plot's code (#94)
XY Plot opens a section in the results panel: a quantity picker, the
series from results.xy plotted against the step's own axis with the
frame or mode shown marked (a click shows the nearest), nodal fields
read at the probed node, and Export CSV through results.xy. The energy
plot and the XY plot share one line-plot function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 05:31:33 -04:00
jyvikoandClaude Opus 5.5 9a8469cf4b mesh: a projection query searches the face's own triangles (#402)
Projecting a point onto one B-rep face searched one box tree over every triangle of the
boundary, skipping the other faces' triangles: when other faces lie nearer the point than the
target face, it walked most of the tree before reaching a triangle it could use. Smoothing a
projected mesh projects every boundary node many times, onto every face of its target group,
so the Pi 5 enclosure base spent 95 % of its 11 minutes there. Each face (and each face pair's
curve) now has a tree of its own: the same nearest triangle, found among its own boxes only.

Ties between triangles at the same distance went to whichever the tree's walk met first; they
now go to the lowest triangle, so the result no longer depends on how a tree is built. With
that rule the per-face trees and the whole-boundary tree give bitwise the same mesh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 04:54:14 -04:00
jyvikoandClaude Opus 5.5 cbd60a7985 results: results.xy, a quantity against the load factor, time or mode (#94)
A nodal field at a node per frame (nonlinear static displacements from
load factor 0, explicit frames, heat temperatures) or per mode, the
explicit energies and histories, history nodes, and the frequencies or
buckling load factors; capped at 100000 points with `every`; stale
results refused; `path` writes a two-column CSV. XY Plot is ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 04:33:06 -04:00
jyvikoandClaude Opus 5.5 47e92eebb1 mesh: a plan's frame cut on an opening's end face moves one element out of it (#455)
The crank arm's back pocket ends on the planes x = 48 and x = 130, its frame sides. The plan
put its frame cuts there; the kernel cannot cut along a face (#427), so both were skipped and
the cell between the bosses swept the pocket's rounded ends into fans. A frame side on a face
of the body now moves one element out of the opening (away from the rectangle's middle),
leaving a strip one element wide; both cuts are made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 03:59:29 -04:00
jyvikoandClaude Opus 5.5 bf61ae8406 mesh: smoothing visits nodes in their own order again (#402)
#462 visited nodes by a mirror-invariant key (distance from the centroid). On the cup with a
handle that order sweeps ring by ring round the axis and converges worse in its 100 sweeps: the
refined cup fell to a scaled Jacobian of 0.481 (needs 0.5, #440). In the lattice's own order
it holds, and the order gained the enclosure base little (its corners differ by 0.02 either
way), so it goes; the compass search's lift to 0.7 stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 03:42:47 -04:00
jyvikoandClaude Opus 5.5 4d6d69ee6d mesh: smoothing lifts elements up to 0.7 and visits mirror twins alike (#402)
The compass search after the sweeps only lifted elements below 0.5 (the quality floor), so a
mesh whose worst elements sat just above it stayed there: every corner of an enclosure base
stopped at a scaled Jacobian of 0.50-0.52 where its walls meet the corner bosses. It now lifts
every element below 0.7, never lowering another below the lower of its own value and 0.5.

The sweeps and the search are Gauss-Seidel, so the node order shaped the result and mirror
image corners smoothed differently. Nodes are now visited by a key that a mirror or a quarter
turn about the nodes' centroid keeps (distance from it, then the sorted offsets along the
axes). What spread is left between mirrored corners (0.686 vs 0.702 on the test base) is most likely the projection onto the body's
tessellation, which the kernel does not make mirror symmetric (not yet proven).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 03:05:43 -04:00
jyvikoandClaude Opus 5.5 e79ba866a1 engine: draw geometry.check findings as viewport markers
geometry.check keeps its findings on the session (not an edit: not journalled,
saved or undone) while the checked design stays open; the picture and the app's
viewport draw the faces of small, sliver and near faces in the marker colour,
short edges as thick lines and a dot at each finding over everything. `mark:
false` or a clean check clears them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 02:47:14 -04:00
jyvikoandClaude Opus 5.5 f8b92fcd89 ui: element faces and edges as element:side lists
A Sides field kind: element faces or edges typed as element:side with
ranges (1-20:3, 25:1), no row cap, and Use selection for picked faces.
Set and mesh-tag forms use it instead of a JSON box (#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 02:30:24 -04:00
jyvikoandClaude Opus 5.5 9b705f5334 ui: fluid boundary face and obstacle faces as typed fields
The boundary's 'at' is a box face or an obstacle body and face
(NameOr); the domain's obstacle faces are rows of a face and its wall
condition (#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 02:14:58 -04:00
jyvikoandClaude Opus 5.5 98e96921e6 ui: mirror plane and images as typed fields
Two field kinds: NameOr (a name as a bare string, or an object of fields)
and RefText (a text per item of a collection). The mirror's plane is a
datum plane or a point and normal; its images a body name per body
(#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:51:04 -04:00
jyvikoandClaude Opus 5.5 d17cf414f7 ui: block divisions and bias as two or three typed fields
A Tuple field kind: min_len to labels.len() positive numbers, whole when
asked, trailing blanks left out. The block's divisions and bias use it
instead of a JSON box (#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:42:51 -04:00
jyvikoandClaude Opus 5.5 e4d1f6c7da ui: one number or one per axis for conductivity, expansion and point mass
A PerAxis field kind: one value for every direction, or Per axis ticked
for one per axis. Thermal conductivity and expansion and the point mass
use it instead of a JSON box (#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:38:26 -04:00
jyvikoandClaude Opus 5.5 b18195ff89 ui: table rows on one line under column headers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:34:38 -04:00
jyvikoandClaude Opus 5.5 61f0d42ec4 ui: nonlinear load path as a table of fraction and factor
Kind::Points gains its columns, so a list of number rows other than
[x, y, z] gets the same typed editor; the load path uses it (#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:34:38 -04:00
jyvikoandClaude Opus 5.5 1c18a463e7 app: the windows-sys features WriteFile and MiniDumpWriteDump need (#299)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:11:58 -04:00
jyvikoandClaude Opus 5.5 af9b4bb222 app: startup steps in the crash journal, and a Windows exception filter (#299)
egui-wgpu's startup hook and the app's own steps (window, app setup, viewport
pipelines, first frame, running) go into the journal. On Windows,
native_crash.rs (adapted from SolidCraft, the only module allowed unsafe code;
the app crate now denies instead of forbids it) writes the exception code,
faulting module and offset and a minidump. A nightly/on-demand CI job runs the
app's tests on Windows, including a child that faults on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:11:58 -04:00
jyvikoandClaude Opus 5.5 ed25de6521 engine: crash reports name the startup step, the renderer and a native crash (#299)
The journal keeps the startup step the app is in (phase) and the renderer
(set_environment) across rebases; an unclean exit before the first frame is
reported "during startup (<step>, N s after launch)". native_paths() is where
a native exception filter writes what it caught and a minidump; the next
start puts the exception code, module+offset and thread into the report and
moves the minidump into its folder. Adapted from SolidCraft's crash.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:11:58 -04:00
jyvikoandClaude Opus 5.5 ead489592c ui: arc-length displacement limit as typed fields
The step form's displacement limit was a raw JSON box; it is now a group
of node, direction and displacement fields (#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 00:50:12 -04:00
jyvikoandClaude Opus 5.5 7a5363e07b app: leave SIGHUP alone so nohup keeps the app running (#248 follow-up)
#445 also closed the window on SIGHUP, which overrides the SIG_IGN that
`nohup fieldcraft &` sets so the app outlives its terminal. A safe API cannot
ask whether a signal was ignored at launch, so SIGHUP keeps its default.
SIGTERM and SIGINT still close the window the normal way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 00:47:35 -04:00
jyvikoandClaude Opus 5.5 9a30c7d9ed mesh: the one-to-one sweep names a body that ends at several levels (#444)
The crank ends at 8, 16, 20 and 22 mm, so no one-to-one sweep takes it.
The sweep met one of those caps as a linking face and reported "linking
face has no top side". A plane parallel to the source that only part of a
ring reaches is now named as what it is, with extrude pointed to. The arm's
profile alone sweeps as well as extrude does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 00:33:55 -04:00
jyvikoandClaude Opus 5.5 0b7dda95f7 mesh: an enclosure's corner bosses are ringed where its walls join them (#402)
A boss the body's walls run into had its circle crossed by the walls' planes, so the projection
layout refused it and the plan fell back to swept cells, fanning round the boss and its hole.
Such a boss is now framed by the square round its whole circle, subdivided by the planes that
cross it; every square inside (the hole's, the core) is divided alike, so the ring's blocks meet
the walls node to node. On the walls' side the frame stops at their inner plane and its corner
projects onto the middle of the inner fillet, which the ring takes in (a boss just touching the
fillet included: no sliver). Cylinders a fillet's history names but that are a ring's circle are
the ring's faces. Rings are built per patch of grid cells, the one-cell O-grid being the
simplest patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 00:29:24 -04:00
jyvikoandClaude Opus 5.5 02b408c499 app: a termination signal closes the window the normal way, not as a crash (#248)
SIGTERM, SIGINT and SIGHUP ended the process on the spot: no last autosave,
and the crash journal left behind became an unclean_exit report at the next
start. The report on #248's machine ("ended without a clean exit between
commands (last: control ui.capture)") has that exact signature and is
reproduced by kill -TERM after a capture; 500 captures of the solved
crank-mirror (50 app runs), 30 CLI snapshots and 250 MCP headless
screenshots exited cleanly. The first signal now asks the window to close;
a second ends the process with the signal's default action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 00:24:26 -04:00
jyvikoandClaude Opus 5.5 3d38753cad geometry: a partition the kernel can only nudge off its plane is refused (#427)
The kernel's last-resort boolean retry moves the tool 1.3 µm and turns it
2 µrad. For a partition that puts the cut beside the face it was meant to
run along and leaves a cell bounded by slivers (the open boundary at x = 130
on the crank half). halves() now refuses a result-altering retry and names it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 00:20:02 -04:00
jyvikoandClaude Opus 5.5 1af87f1f98 mesh: a disk's O-grid follows the region's elongation (#198)
A smooth loop (straight sides running tangent into arcs, as a slot or a
rounded pocket) is meshed as a disk. Its middle block was a square halfway
to the loop's quarter points, which on the crank's 6:1 pocket floor
squashed elements to a scaled Jacobian of 0.16. The block is now a
rectangle on the region's principal axes, inset by the same distance all
round (the old square on a circle), its corners paired with the loop nodes
nearest their diagonals, its sides spaced as the nodes they pair with, and
the O-grid is smoothed (untangled first) before the validity check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 23:55:02 -04:00
jyvikoandClaude Opus 5.5 e9801e4528 ui: draw the marked elements in the error colour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 23:32:47 -04:00
jyvikoandClaude Opus 5.5 75b8c7e26e engine: mesh.quality.mark marks the elements below quality limits as a set
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 23:32:47 -04:00
jyvikoandClaude Opus 5.5 33d69fc53d mesh: list the elements that fail quality limits, worst first
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 23:32:47 -04:00
jyvikoandClaude Opus 5.5 44de124d9d engine: geometry.check reports what is small against the element size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 23:17:39 -04:00
jyvikoandClaude Opus 5.5 5cee94f947 geometry: check bodies for short edges, small and sliver faces, near faces
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 23:17:39 -04:00
jyvikoandClaude Opus 5.5 06c4a8d73d mesh: projection divisions from the mean edge length of each class, thin-layer tests (#418)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 23:09:50 -04:00
jyvikoandClaude Opus 5.5 f9a0282acc engine: results.export.vtu writes a solved step for ParaView
Static, frequency (per mode), nonlinear and explicit (per frame, with a
.pvd collection) and heat results; fluid steps are refused. Wedges keep
the model's node order: VTK 9's wedge has the same orientation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 22:55:12 -04:00
jyvikoandClaude Opus 5.5 439dfc678f io: write VTK XML unstructured grids and PVD collections
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 22:55:12 -04:00
jyvikoandClaude Opus 5.5 68016f69f7 engine: the crank half y > 0 divides where its mirror does (#283)
The kernel failure is reported as such since #381; the failure itself came
from split_by_plane's oversized boxes, which #384 replaced with body-sized
ones. The regression test now requires the cut (x = 110, 148, reversed
normal, the diagonal plane) from commands, with the cells' volumes adding up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 22:45:27 -04:00
jyvikoandClaude Opus 5.5 92d49debec mesh: a screw hole's ring takes in the fillet of its rounded corner; frames centred on the hole (#402)
The projection layout gave every fillet a blend layer along the sides it runs between, so a
screw hole close to a rounded corner sat inside the layer and the plan fell back to frame cuts
and swept cells, which fan into the hole. A corner fillet that lies inside the square from the
hole to the two sides is now taken in by the hole's O-grid ring: the frame reaches both sides,
its outer corner projects onto the middle of the fillet, and no layer is needed. An O-grid's
frame is centred on its hole (a near side is the frame, the far side as far), and the plan
reports the layout's frames and drops the frame-cut notes when it does not cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 22:43:02 -04:00
jyvikoandClaude Opus 5.5 1d71a9f8af engine: mesh.export.stl writes the outer surface or a set's faces
Binary by default, ASCII on request; in the Mesh tab's Verify panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 22:34:02 -04:00
jyvikoandClaude Opus 5.5 87710a83ab io: write element faces as binary and ASCII STL
Model::skin_faces gives the mesh's outer skin: the exterior faces of
the solids and the top side of every shell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 22:34:02 -04:00
jyvikoandClaude Opus 5.5 eecdcb5b96 mesh: projection divisions follow the curvature of curved targets, max_arc_angle (#418, #402)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 21:53:38 -04:00
jyvikoandClaude Opus 5.5 464417d84d mesh: test that a plane is trimmed to its face (#418)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 21:53:38 -04:00
jyvikoandClaude Opus 5.5 c107ea8105 mesh: a point of a plane past its face's triangles is off the face (#418)
project_trimmed accepted the analytic projection within a fifth of the nearest
triangle's size; a plane face's triangles can span the whole face, so nodes in a
3 mm fillet landed on the untrimmed box planes (mesh volume = the unfilleted box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 21:53:38 -04:00
jyvikoandClaude Opus 5.5 390ddcd6cc cli, mcp: snapshot --section and --field; headless MCP screenshots on the GPU
--section x|y|z[=VALUE] hides the elements on the camera's side of a plane
(at the mesh's middle by default) so the element faces inside show;
--field contours a nodal field of the solved step (results.contour). The
MCP server's headless screenshot draws with the viewport's pipeline on the
shared device and its reply, like snapshot's, names the renderer.

Refs #422

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 20:50:40 -04:00
jyvikoandClaude Opus 5.5 c9163e98ad gpu: captures draw name labels; a shared capturer and a PNG with the CPU as last tier
render::draw_labels draws a scene's labels over finished pixels in the CPU
rasterizer's font. capture::shared opens the process's device once by the
FIELDCRAFT_GPU policy; capture::png draws on it or on the CPU and reports
which (Renderer).

Refs #422

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 20:50:40 -04:00
jyvikoandClaude Opus 5.5 fee978c7e4 cli: snapshot draws a script's model to a PNG with no window
fieldcraft-cli snapshot script.json --out shot.png [--view] [--size WxH]
draws the engine's framed scene (engine::view::frame, shared with the CPU
render_png) with the viewport's GPU pipelines offscreen: hardware GPU, then
a software one, then the CPU rasterizer (FIELDCRAFT_GPU=auto|hardware|
software|cpu). CI installs lavapipe so the GPU path runs there.

Refs #422

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 20:19:00 -04:00
jyvikoandClaude Opus 5.5 248e74ca9e gpu: the viewport's pipelines move to a crate of their own (fieldcraft-gpu)
ui-egui keeps only the egui_wgpu paint callback; the shaders, batching and
pipelines are fieldcraft-gpu's view module, so an offscreen picture can draw
with the window's pipelines.

Refs #422

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 20:19:00 -04:00
jyvikoandClaude Opus 5.5 575ae10dbf mesh: tests pin projection smoothing's edge ratio and regular grids (#418)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 19:39:01 -04:00
jyvikoandClaude Opus 5.5 58d3b351ff mesh: projection smoothing keeps a result that holds the grid's quality (#418)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 19:39:01 -04:00
jyvikoandClaude Opus 5.5 5306450413 mesh: projection smoothing guards edge ratio and never leaves the grid worse (#418)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 19:39:01 -04:00
jyvikoandClaude Opus 5.5 d29334a7a7 mesh: mesh.plan.apply builds rings and fillet/chamfer blend layers as one projected block layout (#402)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 18:59:02 -04:00
jyvikoandClaude Opus 5.5 361ac2e7b3 ui: an icon for Extrude to Surface (mesh.hex.extrude)
develop CI failed every_catalogue_entry_has_an_icon_and_every_icon_an_entry since #414.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 17:39:22 -04:00
jyvikoandClaude Opus 5.5 febd40fadc mesh: split a projection block through its whole sheet; name twisted elements' block junction (#399)
Topology::split cuts a block at given fractions and every block chained to it
through parallel edges, orienting the fractions through the chain, so the
topology stays conforming. A certified-invalid element is reported with its
block, cell, centre and the block vertex and blocks meeting there; the mesh is
never emitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:57:05 -04:00
jyvikoandClaude Opus 5.5 4dadaa50aa ci: docs/releasing.md: release jobs run in parallel; only the draft waits for verify
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:52:48 -04:00
jyvikoandClaude Opus 5.5 e468133f9a ci: package releases in parallel with the verify jobs; only the draft job waits for both
The release job (the only one that signs SHA256SUMS.txt or touches a GitHub Release) now needs
every verify and packaging job; packaging no longer waits for verify. Verify jobs restore
develop's CI caches through shared keys (same CARGO_* env as ci.yml); saving stays main-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:52:48 -04:00
jyvikoandClaude Opus 5.5 c82c13019a docs: regenerate features.md after the rebase
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:44:40 -04:00
jyvikoandClaude Opus 5.5 d703cdde0b docs: extrude up to a surface in the literature, MCP and meshing skill (#400)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:44:40 -04:00
jyvikoandClaude Opus 5.5 c077131285 ui: Extrude to Surface form picks the source face, then the target surface (#400)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:44:40 -04:00
jyvikoandClaude Opus 5.5 4ac31d3988 engine: mesh.hex.extrude, hex layers up to a target surface (#400)
The section comes from a set (a 2D mesh or element faces), the mesh already
on the source faces (tagged, or a neighbouring body's boundary lying on
them, whose nodes are shared so the meshes conform), or a fresh quad mesh
of one planar face. Target and side faces project through the tet mesher's
B-rep projection. Replaces the body's mesh like the other meshers, tags the
body's own source, target and side faces, and is on the MCP mesh tool as
hex_extrude.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:44:40 -04:00
jyvikoandClaude Opus 5.5 600b2ff633 mesh: hex extrusion of a quad section up to a target surface (#400)
Each source node follows its own path (a direction or the section normal)
to the target, found by Newton on the target's tangent plane; layers are
graded by a bias; side faces, when given, hold the boundary nodes of every
layer (alternating projections at the target), and interior nodes take the
boundary-residual correction. Source nodes are kept, so a section taken from
a neighbouring mesh conforms with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:44:40 -04:00
jyvikoandClaude Opus 5.5 d6b336538a docs: start hex meshing from the provenance graph (#402)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:38:51 -04:00
jyvikoandClaude Opus 5.5 efe90ec50e mesh: geometry.features, mesh.plan and mesh.plan.apply from the feature graph (#402)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:38:51 -04:00
jyvikoandClaude Opus 5.5 8dd7446569 model: mesh plans, saved definitions with templates, rings, cuts and copies (#402)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:38:51 -04:00
jyvikoandClaude Opus 5.5 4cfb6dd3de geometry: the replayed feature graph per body (#402); a partition may list a cut body for its cells
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:38:51 -04:00
jyvikoandClaude Opus 5.5 d3ac6395bb engine: Projection Blocking form takes levels and merge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:37:07 -04:00
jyvikoandClaude Opus 5.5 62fc00755a engine: a form for Projection Blocking (mesh.hex.project)
every_command_has_a_form failed on develop since #399.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:37:07 -04:00
jyvikoandClaude Opus 5.5 d781d6e427 engine: mesh.hex.project command, MCP, docs and meshing skill (#399)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:05:38 -04:00
jyvikoandClaude Opus 5.5 6efad8feeb mesh: projection blocking — block topology, projection onto the B-rep, elliptic smoothing (#399)
A block topology (carved grid, butterfly and seven-block sphere templates,
O-grid, extrude, connect) projected onto a body: outer block faces onto their
smooth face groups, edges onto the curve of two targets and vertices onto the
point of three by tangent-plane iteration, nodes by transfinite interpolation,
then Winslow smoothing with the boundary on the geometry and a compass search
on the worst elements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:05:38 -04:00
jyvikoandClaude Opus 5.5 8092c39892 mesh.cells: a tie inside one design body says why (#396)
Every tied interface between cells of one design body carries `why`: the tet cell (and why
auto fell back), the reason the body's cells did not sweep together, or the cell meshed alone
by its method; so an agent knows which cut or method to change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 15:59:01 -04:00
jyvikoandClaude Opus 5.5 4610876384 mesh: square corners round rectangular openings; no sliver or unassignable blocks (#397)
The swept cross-section's decomposition:
- An opening with concave corners gets a layout that continues its sides to the outer loop
  (side strips and corner blocks, every corner near 90°). The thin air ring round the Pi
  enclosure's square pedestal was left to the free mesher, which put a node 1e-5 from the
  opening's corner: edge ratio 341,515.
- A piece's score is capped by its quadrilaterals' aspect against their longest edge or the
  target size, so thin strips and pieces much smaller than the elements no longer score as
  square blocks; the free mesh is scored alike for "do no harm".
- A disk is not cut into wedges round its centre (its O-grid is the scheme).
- Cuts whose pieces cannot be given division counts together (mapped pieces round a hole that
  chain back to a longer side) are refused, instead of failing the whole cross-section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 15:59:01 -04:00
Preetinder Kalsi 2be6aff2f9 Bundle the macOS CLI inside FieldCraft.app 2026-10-15 15:43:32 -04:00
Preetinder Kalsi 991dd354cb solver: share Cholesky and LDLT triangular solves 2026-10-15 15:43:15 -04:00
245 changed files with 27809 additions and 1565 deletions

No files matched your search

+5 -4
View File
@@ -1,13 +1,14 @@
FieldCraft is open-source CAE pre-processing in Rust: it turns geometry into analysis-ready
finite element models, writes Abaqus, Nastran and LS-DYNA decks, and solves linear static and
modal problems with its own solver. AI agents can drive it over MCP (`fieldcraft-cli mcp`).
[README](https://github.com/jyviko/fieldcraft#readme) ·
[roadmap](https://github.com/jyviko/fieldcraft/blob/main/ROADMAP.md) ·
[issues](https://github.com/jyviko/fieldcraft/issues)
[README](https://github.com/tangency-labs/fieldcraft#readme) ·
[roadmap](https://github.com/tangency-labs/fieldcraft/blob/main/ROADMAP.md) ·
[issues](https://github.com/tangency-labs/fieldcraft/issues)
**Which file?**
- **macOS 11+** (Apple silicon and Intel): open `fieldcraft-{version}-macos-universal.dmg` and
drag `FieldCraft.app` onto Applications. The command-line tool is in
drag `FieldCraft.app` onto Applications. The command-line tool is included at
`/Applications/FieldCraft.app/Contents/MacOS/fieldcraft-cli` and also available separately in
`fieldcraft-cli-{version}-macos-universal.zip`.
- **Linux** (x86_64, glibc 2.35+: Ubuntu 22.04, Debian 12, Fedora 36 or newer):
`fieldcraft-{version}-linux-x86_64.AppImage` (`chmod +x`, then run it), or unpack
+27 -2
View File
@@ -129,10 +129,17 @@ jobs:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
# Shared keys: release.yml's verify jobs restore these caches (they save only on main).
shared-key: ci
save-if: ${{ github.ref == 'refs/heads/develop' }}
- name: Software GPU (lavapipe), so headless snapshots run the GPU pipeline
run: sudo apt-get update -q && sudo apt-get install -y -q --no-install-recommends mesa-vulkan-drivers libvulkan1
- name: cargo xtask ci (the affected crates, or everything)
env:
BASE: ${{ needs.plan.outputs.base }}
# The drawing tests run on lavapipe and fail, instead of skipping, without it.
FIELDCRAFT_GPU: software
FIELDCRAFT_REQUIRE_GPU: "1"
run: |
if [ -n "$BASE" ]; then cargo xtask ci --affected "$BASE"; else cargo xtask ci; fi
@@ -149,6 +156,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: heavy
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo xtask test --heavy
@@ -167,7 +175,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle
shared-key: oracle
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo xtask oracle
- name: docs/oracle.md is current
@@ -185,12 +193,29 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle-macos
shared-key: oracle-macos
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo xtask oracle
- name: docs/oracle.md is current
run: git diff --exit-code docs/oracle.md
windows:
# The desktop app's tests on Windows, nightly and on demand: the Windows-only code
# (apps/fieldcraft/src/native_crash.rs, the exception filter) builds and a child process that
# faults on purpose leaves its native crash report (#299). Releases build Windows too.
name: Windows app tests
if: github.event.schedule == '23 3 * * *' || github.event_name == 'workflow_dispatch'
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: windows
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo test --locked --profile ci -p fieldcraft
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]')
+1 -1
View File
@@ -1,4 +1,4 @@
# The web app (apps/fieldcraft-web, docs/web.md) on GitHub Pages: https://jyviko.github.io/fieldcraft/
# The web app (apps/fieldcraft-web, docs/web.md) on GitHub Pages: https://tangency-labs.github.io/fieldcraft/
# After SolidCraft's pages.yml (MIT OR Apache-2.0; see ATTRIBUTION.md).
#
# A push to `main` (a release; main holds released code only) builds the app with Trunk (release,
+22 -13
View File
@@ -17,9 +17,12 @@
# macOS: signed with a Developer ID and notarized when the APPLE_* secrets exist, ad-hoc
# otherwise (the release notes say which). Linux and Windows builds are not signed.
#
# Before anything is packaged, the verify jobs run everything CI can: the full `cargo xtask ci`,
# `cargo xtask test --heavy` and `cargo xtask oracle` on Linux, and the oracle on macOS. Develop's
# CI tests only what each change affects (ci.yml), so this is where a release proves all of it.
# The verify jobs run everything CI can: the full `cargo xtask ci`, `cargo xtask test --heavy` and
# `cargo xtask oracle` on Linux, and the oracle on macOS. Develop's CI tests only what each change
# affects (ci.yml), so this is where a release proves all of it. The packaging jobs build in
# parallel with them and only upload workflow artifacts; the `release` job, the only one that
# signs SHA256SUMS.txt or touches a GitHub Release, needs every verify and packaging job, so a
# failing verify means no draft and no signature.
#
# Dry run: `gh workflow run release.yml --ref <branch>` verifies and builds everything and uploads the
# `release-assets` artifact without creating a release. `-f draft=true` on `main` retries
@@ -45,8 +48,10 @@ concurrency:
cancel-in-progress: false
env:
# The same CARGO_* environment as ci.yml: rust-cache hashes it into the cache key, so a
# difference would stop the verify jobs restoring develop's caches. (rust-cache already sets
# CARGO_INCREMENTAL=0 for every job.)
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
version:
@@ -75,8 +80,9 @@ jobs:
{ echo "version=$VERSION"; echo "prerelease=$pre"; echo "draft=$REQUEST_DRAFT"; } >>"$GITHUB_OUTPUT"
echo "### FieldCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
# The full gate, the heavy tests and the oracle on Linux and macOS: every packaging job needs
# them, so a release (or a dry run) never builds from a commit that fails any of them.
# The full gate, the heavy tests and the oracle on Linux and macOS: the `release` job needs them,
# so a release never drafts or signs a commit that fails any of them. Each restores the cache of
# its ci.yml counterpart on develop (same shared-key), so it starts warm.
verify-ci:
name: Verify (cargo xtask ci)
runs-on: ubuntu-24.04
@@ -89,6 +95,7 @@ jobs:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: ci
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo xtask ci
@@ -102,6 +109,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: heavy
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo xtask test --heavy
@@ -112,9 +120,9 @@ jobs:
matrix:
include:
- os: ubuntu-24.04
cache: release-oracle-linux
cache: oracle
- os: macos-14
cache: release-oracle-macos
cache: oracle-macos
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
@@ -122,7 +130,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: ${{ matrix.cache }}
shared-key: ${{ matrix.cache }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo xtask oracle
- name: docs/oracle.md is current
@@ -130,7 +138,7 @@ jobs:
macos:
name: macOS universal
needs: [version, verify-ci, verify-heavy, verify-oracle]
needs: version
# Signing, notarization and analytics secrets live in the `release` environment, which only
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
@@ -179,7 +187,7 @@ jobs:
linux:
name: Linux x86_64
needs: [version, verify-ci, verify-heavy, verify-oracle]
needs: version
# The oldest GitHub-hosted image, so the binaries need only glibc >= 2.35.
# Signing, notarization and analytics secrets live in the `release` environment, which only
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
@@ -268,7 +276,7 @@ jobs:
windows:
name: Windows x64
needs: [version, verify-ci, verify-heavy, verify-oracle]
needs: version
# Signing, notarization and analytics secrets live in the `release` environment, which only
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
@@ -302,7 +310,8 @@ jobs:
release:
name: Draft release (or dry-run artifacts)
needs: [version, macos, linux, windows]
# The only job that publishes anything: it waits for every verify and packaging job.
needs: [version, verify-ci, verify-heavy, verify-oracle, macos, linux, windows]
# Signing, notarization and analytics secrets live in the `release` environment, which only
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
+4 -2
View File
@@ -24,7 +24,9 @@ FieldCraft everywhere: crates `fieldcraft-*`, binaries `fieldcraft` and `fieldcr
## Never crash
People trust FieldCraft with their models; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The one exception, in the app crate
(which denies it instead), has no safe API: the Windows-only `apps/fieldcraft/src/native_crash.rs`
(the exception filter that records crashes inside native code such as graphics drivers).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, model files, solver decks,
@@ -137,7 +139,7 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
heavy tests, nightly, before every release and on demand (`gh workflow run CI --ref <branch>`). Hostile-input tests run commands on sessions with small
work caps (`Session::limits`) so refusals are instant; never lower the production caps for a
test.
- **Publishing**: `origin` is github.com/jyviko/fieldcraft. Changes land through pull requests
- **Publishing**: `origin` is github.com/tangency-labs/fieldcraft. Changes land through pull requests
as the workflow above describes; the coordinator reads every merged diff and sends findings
back as fix-forward work. Never force-push `develop` or `main`; `main` holds released code and moves only by
fast-forward at a release (docs/releasing.md). Never commit `plan/`, `.agent-files/`,
+13 -2
View File
@@ -24,6 +24,7 @@ is committed and listed here with its exports.
| docs/media/examples/bracket-hex.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved bracket-hex sample (docs/examples.md) | MIT OR Apache-2.0 |
| 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/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 |
@@ -56,6 +57,7 @@ is committed and listed here with its exports.
| assets/icons/file.save_as.svg, assets/icons/file.save_as.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.save_as.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/file.save.svg, assets/icons/file.save.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.save.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.bodies.svg, assets/icons/geometry.bodies.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.bodies.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.check.svg, assets/icons/geometry.check.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.check.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.datum.svg, assets/icons/geometry.datum.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.datum.axis.svg, assets/icons/geometry.datum.axis.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.axis.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.datum.point.svg, assets/icons/geometry.datum.point.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.point.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
@@ -63,6 +65,10 @@ is committed and listed here with its exports.
| assets/icons/geometry.midsurface.svg, assets/icons/geometry.midsurface.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.midsurface.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.open.svg, assets/icons/geometry.open.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.open.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.partition.svg, assets/icons/geometry.partition.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.partition.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.faces.merge.svg, assets/icons/geometry.faces.merge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.faces.merge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.face.absorb.svg, assets/icons/geometry.face.absorb.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.face.absorb.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.face.split.svg, assets/icons/geometry.face.split.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.face.split.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.stitch.svg, assets/icons/geometry.stitch.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.stitch.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.tessellate.svg, assets/icons/geometry.tessellate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.tessellate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/interaction.bolt.svg, assets/icons/interaction.bolt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.bolt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/interaction.connector.svg, assets/icons/interaction.connector.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.connector.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
@@ -92,6 +98,9 @@ is committed and listed here with its exports.
| 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 |
| assets/icons/mesh.quality.mark.svg, assets/icons/mesh.quality.mark.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.mark.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/results.export.vtu.svg, assets/icons/results.export.vtu.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/results.export.vtu.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.export.stl.svg, assets/icons/mesh.export.stl.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.export.stl.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.region.remesh.svg, assets/icons/mesh.region.remesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.region.remesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.size.adapt.svg, assets/icons/mesh.size.adapt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.adapt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.size.global.svg, assets/icons/mesh.size.global.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.global.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
@@ -100,6 +109,8 @@ is committed and listed here with its exports.
| assets/icons/mesh.surface.check.svg, assets/icons/mesh.surface.check.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.surface.check.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.tag.svg, assets/icons/mesh.tag.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.tag.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.tet.generate.svg, assets/icons/mesh.tet.generate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.tet.generate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.hex.extrude.svg, assets/icons/mesh.hex.extrude.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.extrude.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.plan.svg, assets/icons/mesh.plan.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.plan.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.planar.svg, assets/icons/mesh.planar.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.planar.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.transition.svg, assets/icons/mesh.transition.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.transition.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.untangle.svg, assets/icons/mesh.untangle.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.untangle.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
@@ -197,12 +208,12 @@ is committed and listed here with its exports.
| clippy.toml, rustfmt.toml | SolidCraft | MIT OR Apache-2.0 |
| crates/geometry/src/tagged_mesh.rs (package and part reading, transforms, units, caps) | SolidCraft `crates/io/src/threemf.rs` | MIT OR Apache-2.0 |
| crates/render/src/camera.rs, crates/render/src/raster.rs | SolidCraft `crates/render/src/camera.rs`, `raster.rs` | MIT OR Apache-2.0 |
| crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs (tokens and their values), crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots), crates/ui-egui/src/toolbar.rs (workspace switcher, tabs, panels with drop-downs, fitting), crates/ui-egui/src/appbar.rs (application bar; the mark is drawn from assets/app-icon/icon-small.svg's coordinates), crates/ui-egui/src/titlebar.rs (caption buttons, drag and resize zones; SolidCraft took it from VectorCraft), crates/ui-egui/src/frame.rs (windows, heading bands), crates/ui-egui/src/browser.rs (row drawing), crates/ui-egui/src/palette.rs (command line and search), crates/ui-egui/src/timeline.rs (timeline strip, transport), crates/ui-egui/src/viewport.rs (navigation bar, orientation gizmo), crates/ui-egui/src/files.rs (background opening, file pickers as host services) | SolidCraft `crates/ui-egui/src/` (titlebar.rs also VectorCraft `crates/ui-egui/src/titlebar.rs`) | MIT OR Apache-2.0 |
| crates/gpu/src/view.rs (viewport pipelines and shaders), crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs (tokens and their values), crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots), crates/ui-egui/src/toolbar.rs (workspace switcher, tabs, panels with drop-downs, fitting), crates/ui-egui/src/appbar.rs (application bar; the mark is drawn from assets/app-icon/icon-small.svg's coordinates), crates/ui-egui/src/titlebar.rs (caption buttons, drag and resize zones; SolidCraft took it from VectorCraft), crates/ui-egui/src/frame.rs (windows, heading bands), crates/ui-egui/src/browser.rs (row drawing), crates/ui-egui/src/palette.rs (command line and search), crates/ui-egui/src/timeline.rs (timeline strip, transport), crates/ui-egui/src/viewport.rs (navigation bar, orientation gizmo), crates/ui-egui/src/files.rs (background opening, file pickers as host services) | SolidCraft `crates/ui-egui/src/` (titlebar.rs also VectorCraft `crates/ui-egui/src/titlebar.rs`) | MIT OR Apache-2.0 |
| crates/ui-egui/src/navigation.rs (navigation presets and bindings), crates/ui-egui/src/viewport.rs (orbit about the point under the cursor, or the drawn centre from empty space; Ctrl + left drag orbits) | SolidCraft `crates/ui-egui/src/prefs.rs` (`NavPreset`, `NavBinding`, `NAV_PRESETS`, `orbit`) | MIT OR Apache-2.0 |
| crates/ui-egui/src/fields.rs (form rows: a label column of fixed width, then the field) | SolidCraft `crates/ui-egui/src/dialogs.rs` | MIT OR Apache-2.0 |
| crates/io/src/fs.rs (atomic writes) | SolidCraft `crates/io/src/safe_write.rs` | MIT OR Apache-2.0 |
| crates/io/src/recovery.rs (recovery folder, entries, locks) | SolidCraft `crates/engine/src/recovery.rs` | MIT OR Apache-2.0 |
| crates/engine/src/crash.rs, crates/engine/src/crash_tests.rs (crash reports: panic hook, locked command journal, unclean-exit detection, report folders and limits, replay), apps/fieldcraft-cli/src/crashes.rs, docs/crash-reports.md (structure) | SolidCraft `crates/engine/src/crash.rs`, `crash_tests.rs`, `apps/solidcraft-cli/src/crashes.rs`, `docs/crash-reports.md` | MIT OR Apache-2.0 |
| crates/engine/src/crash.rs, crates/engine/src/crash_tests.rs (crash reports: panic hook, locked command journal, unclean-exit detection, startup steps, native crash reports, report folders and limits, replay), apps/fieldcraft/src/native_crash.rs (Windows exception filter and minidump), apps/fieldcraft-cli/src/crashes.rs, docs/crash-reports.md (structure) | SolidCraft `crates/engine/src/crash.rs`, `crash_tests.rs`, `apps/solidcraft/src/native_crash.rs`, `apps/solidcraft-cli/src/crashes.rs`, `docs/crash-reports.md` | MIT OR Apache-2.0 |
| apps/fieldcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 |
| crates/mcp/src/ (JSON-RPC framing, MCP lifecycle, tool schema checks, the control-channel backend, base64) | SolidCraft `crates/mcp/src/` | MIT OR Apache-2.0 |
| crates/io/src/roots.rs (the rules of read and write roots) | VectorCraft's automation roots (docs/mcp.md there) | MIT OR Apache-2.0 |
+17 -17
View File
@@ -15,7 +15,7 @@ needed most, and how to claim work. The rules every change follows, by a person
You need stable Rust 1.95 or newer.
```sh
git clone https://github.com/jyviko/fieldcraft && cd fieldcraft
git clone https://github.com/tangency-labs/fieldcraft && cd fieldcraft
cargo run --release -p fieldcraft -- --sample cantilever # the desktop app on a sample model
cargo run -p fieldcraft-cli -- commands # every command and its parameters
cargo test -p fieldcraft-solver # the tests of one crate
@@ -28,32 +28,32 @@ cargo xtask oracle --quick # the verification ca
## Where help is wanted most
See the [`help wanted`](https://github.com/jyviko/fieldcraft/labels/help%20wanted) and
[`good first issue`](https://github.com/jyviko/fieldcraft/labels/good%20first%20issue) labels, and
See the [`help wanted`](https://github.com/tangency-labs/fieldcraft/labels/help%20wanted) and
[`good first issue`](https://github.com/tangency-labs/fieldcraft/labels/good%20first%20issue) labels, and
the `area:` labels for your field. In order of how much they would help users:
1. **Reproducible meshes** ([#115](https://github.com/jyviko/fieldcraft/issues/115)). The same part
1. **Reproducible meshes** ([#115](https://github.com/tangency-labs/fieldcraft/issues/115)). The same part
must give the same tet mesh on every platform and every run, as the solvers already do.
2. **All-hex meshing of thin and curved parts** ([#85](https://github.com/jyviko/fieldcraft/issues/85)).
2. **All-hex meshing of thin and curved parts** ([#85](https://github.com/tangency-labs/fieldcraft/issues/85)).
The octree mesher over-refines thin walls; concave transition templates and an up-front depth
are next. Hex meshing is the centre of gravity of the project.
3. **Getting results in and out**: ParaView export ([#90](https://github.com/jyviko/fieldcraft/issues/90)),
Gmsh import ([#91](https://github.com/jyviko/fieldcraft/issues/91)), Abaqus deck reading
([#98](https://github.com/jyviko/fieldcraft/issues/98)), CSV and STL
([#92](https://github.com/jyviko/fieldcraft/issues/92), [#93](https://github.com/jyviko/fieldcraft/issues/93)).
4. **Verification benchmarks** ([#97](https://github.com/jyviko/fieldcraft/issues/97),
[#106](https://github.com/jyviko/fieldcraft/issues/106)). An engineer with a published benchmark
3. **Getting results in and out**: ParaView export ([#90](https://github.com/tangency-labs/fieldcraft/issues/90)),
Gmsh import ([#91](https://github.com/tangency-labs/fieldcraft/issues/91)), Abaqus deck reading
([#98](https://github.com/tangency-labs/fieldcraft/issues/98)), CSV and STL
([#92](https://github.com/tangency-labs/fieldcraft/issues/92), [#93](https://github.com/tangency-labs/fieldcraft/issues/93)).
4. **Verification benchmarks** ([#97](https://github.com/tangency-labs/fieldcraft/issues/97),
[#106](https://github.com/tangency-labs/fieldcraft/issues/106)). An engineer with a published benchmark
can add it as an oracle case without touching solver code.
5. **Nonlinear elements and materials**: incompatible modes at finite strain
([#82](https://github.com/jyviko/fieldcraft/issues/82)), kinematic hardening
([#81](https://github.com/jyviko/fieldcraft/issues/81)), and the material library
([#99](https://github.com/jyviko/fieldcraft/issues/99)).
6. **The app**: typed command forms ([#112](https://github.com/jyviko/fieldcraft/issues/112)), XY and
path plots ([#94](https://github.com/jyviko/fieldcraft/issues/94), [#95](https://github.com/jyviko/fieldcraft/issues/95)).
([#82](https://github.com/tangency-labs/fieldcraft/issues/82)), kinematic hardening
([#81](https://github.com/tangency-labs/fieldcraft/issues/81)), and the material library
([#99](https://github.com/tangency-labs/fieldcraft/issues/99)).
6. **The app**: typed command forms ([#112](https://github.com/tangency-labs/fieldcraft/issues/112)), XY and
path plots ([#94](https://github.com/tangency-labs/fieldcraft/issues/94), [#95](https://github.com/tangency-labs/fieldcraft/issues/95)).
Bigger areas on the [roadmap](ROADMAP.md) — contact, dynamics, composites, coupled fields, wave
propagation, turbulence — are open to anyone who wants to own one: start a thread in
[Discussions](https://github.com/jyviko/fieldcraft/discussions).
[Discussions](https://github.com/tangency-labs/fieldcraft/discussions).
## Before you start: claim an issue
Generated
+36 -12
View File
@@ -425,12 +425,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
@@ -1279,12 +1273,14 @@ dependencies = [
"fieldcraft-io",
"fieldcraft-ui-egui",
"fieldcraft-update",
"getrandom 0.3.4",
"getrandom 0.4.3",
"log",
"rfd",
"rustls",
"serde_json",
"signal-hook",
"ureq",
"windows-sys 0.61.2",
]
[[package]]
@@ -1292,9 +1288,12 @@ name = "fieldcraft-cli"
version = "0.6.0"
dependencies = [
"fieldcraft-engine",
"fieldcraft-gpu",
"fieldcraft-io",
"fieldcraft-mcp",
"fieldcraft-render",
"fieldcraft-update",
"png",
"serde_json",
]
@@ -1356,6 +1355,7 @@ version = "0.6.0"
dependencies = [
"fieldcraft-model",
"quick-xml 0.42.0",
"serde",
"serde_json",
"solidcraft-doc",
"solidcraft-engine",
@@ -1366,6 +1366,18 @@ dependencies = [
"zip 9.0.3",
]
[[package]]
name = "fieldcraft-gpu"
version = "0.6.0"
dependencies = [
"fieldcraft-render",
"log",
"pollster 1.0.1",
"serde_json",
"thiserror 2.0.21",
"wgpu",
]
[[package]]
name = "fieldcraft-io"
version = "0.6.0"
@@ -1394,6 +1406,7 @@ name = "fieldcraft-mcp"
version = "0.6.0"
dependencies = [
"fieldcraft-engine",
"fieldcraft-gpu",
"fieldcraft-io",
"fieldcraft-render",
"fieldcraft-testkit",
@@ -1472,6 +1485,7 @@ dependencies = [
"fieldcraft-engine",
"fieldcraft-explicit",
"fieldcraft-geometry",
"fieldcraft-gpu",
"fieldcraft-model",
"fieldcraft-render",
"fieldcraft-update",
@@ -1503,7 +1517,7 @@ dependencies = [
name = "fieldcraft-web"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"base64",
"eframe",
"egui",
"fieldcraft-engine",
@@ -2241,9 +2255,9 @@ dependencies = [
[[package]]
name = "minisign"
version = "0.9.1"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aefd422a7a8b5efced01cd7cdf3771e62ebecbc90e3d27695002d3af6af94586"
checksum = "3062dbbf4d8f57899e2252804f7512a4fd89c8211c58fa6c786d38794a0c809d"
dependencies = [
"ct-codecs",
"getrandom 0.4.3",
@@ -3660,6 +3674,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -4414,7 +4438,7 @@ version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64 0.23.1",
"base64",
"log",
"percent-encoding",
"rustls",
@@ -4430,7 +4454,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
dependencies = [
"base64 0.23.1",
"base64",
"http",
"httparse",
"log",
+5 -1
View File
@@ -8,7 +8,7 @@ edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
authors = ["FieldCraft contributors"]
repository = "https://github.com/jyviko/fieldcraft"
repository = "https://github.com/tangency-labs/fieldcraft"
[workspace.dependencies]
# internal
@@ -28,6 +28,7 @@ fieldcraft-render = { path = "crates/render" }
fieldcraft-ui-egui = { path = "crates/ui-egui" }
fieldcraft-mcp = { path = "crates/mcp" }
fieldcraft-update = { path = "crates/update" }
fieldcraft-gpu = { path = "crates/gpu" }
# foundation
thiserror = "2"
@@ -51,6 +52,9 @@ resvg = { version = "0.48", default-features = false }
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui-wgpu = "0.36"
# the wgpu egui-wgpu uses, for the offscreen viewport (crates/gpu); backends come per target there
wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] }
pollster = "1.0"
# Shared Craft-family egui components (icon buttons, tabs, widget palette); the same rev as
# VectorCraft. MIT OR Apache-2.0.
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675" }
+8 -7
View File
@@ -10,8 +10,8 @@ see the results — with a nonlinear implicit solver (large deformation, plastic
in place. Hex meshing is the centre of gravity. Next: buckling and arc-length, contact, then
the wider material and multiphysics library. See [ROADMAP.md](ROADMAP.md).
**Want to help?** Pick an issue labelled
[good first issue](https://github.com/jyviko/fieldcraft/labels/good%20first%20issue) or
[help wanted](https://github.com/jyviko/fieldcraft/labels/help%20wanted) and read
[good first issue](https://github.com/tangency-labs/fieldcraft/labels/good%20first%20issue) or
[help wanted](https://github.com/tangency-labs/fieldcraft/labels/help%20wanted) and read
[CONTRIBUTING.md](CONTRIBUTING.md).
![Claude analyses an I-beam cantilever in FieldCraft over MCP: it opens the part, hex meshes it, sets steel, the clamp and a 1 kN tip load, solves, shows von Mises stress and the deformed shape, and checks the tip deflection against beam theory](docs/media/claude-mcp.gif)
@@ -28,15 +28,15 @@ theory with shear 0.177 mm).
## Download
[![Download for macOS](https://img.shields.io/badge/macOS-Download-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/jyviko/fieldcraft/releases/latest)
[![Download for Windows](https://img.shields.io/badge/Windows-Download-0078D6?style=for-the-badge)](https://github.com/jyviko/fieldcraft/releases/latest)
[![Download for Linux](https://img.shields.io/badge/Linux-Download-FCC624?style=for-the-badge&logo=linux&logoColor=black)](https://github.com/jyviko/fieldcraft/releases/latest)
[![Try it in your browser](https://img.shields.io/badge/Browser-Try%20it-2ea44f?style=for-the-badge&logo=webassembly&logoColor=white)](https://jyviko.github.io/fieldcraft/)
[![Download for macOS](https://img.shields.io/badge/macOS-Download-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/tangency-labs/fieldcraft/releases/latest)
[![Download for Windows](https://img.shields.io/badge/Windows-Download-0078D6?style=for-the-badge)](https://github.com/tangency-labs/fieldcraft/releases/latest)
[![Download for Linux](https://img.shields.io/badge/Linux-Download-FCC624?style=for-the-badge&logo=linux&logoColor=black)](https://github.com/tangency-labs/fieldcraft/releases/latest)
[![Try it in your browser](https://img.shields.io/badge/Browser-Try%20it-2ea44f?style=for-the-badge&logo=webassembly&logoColor=white)](https://tangency-labs.github.io/fieldcraft/)
- **macOS:** open the `.dmg` and drag FieldCraft into Applications (Apple silicon and Intel).
- **Windows:** unzip and run `fieldcraft.exe`.
- **Linux:** the `.AppImage` (allow it to run as a program), or the `.tar.gz`.
- **Browser:** [try it at jyviko.github.io/fieldcraft](https://jyviko.github.io/fieldcraft/), no install.
- **Browser:** [try it at tangency-labs.github.io/fieldcraft](https://tangency-labs.github.io/fieldcraft/), no install.
- **Command line:** `fieldcraft-cli` ships in every package; AI agents connect to it over MCP
([docs/mcp.md](docs/mcp.md)).
@@ -118,6 +118,7 @@ cargo xtask ci # fmt, assets, layers, clippy, t
cargo xtask test --heavy # the heavy (ignored) tests and the doc tests
cargo run -p fieldcraft-cli -- commands # every command and its parameters
cargo run -p fieldcraft-cli -- run script.json # run a command script
cargo run -p fieldcraft-cli -- snapshot script.json --out shot.png # draw it to a PNG, no window
cargo run -p fieldcraft-cli -- mcp # MCP server on stdio for AI agents (docs/mcp.md)
```
+1 -1
View File
@@ -3,7 +3,7 @@
FieldCraft is young; only `main` and the latest release get fixes.
Please **don't open a public issue** for a security problem. Report it privately through the
repository's [Security tab](https://github.com/jyviko/fieldcraft/security/advisories/new) with
repository's [Security tab](https://github.com/tangency-labs/fieldcraft/security/advisories/new) with
what you found, how to reproduce it (a model, deck or script), the version and platform, and the
impact you expect.
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "fieldcraft-cli"
description = "FieldCraft from the command line: list commands, run scripts, serve MCP"
description = "FieldCraft from the command line: list commands, run scripts, draw snapshots, serve MCP"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -10,10 +10,13 @@ workspace = true
[dependencies]
fieldcraft-engine = { workspace = true }
fieldcraft-gpu = { workspace = true }
fieldcraft-render = { workspace = true }
fieldcraft-io = { workspace = true }
fieldcraft-mcp = { workspace = true }
fieldcraft-update = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
png = { workspace = true }
fieldcraft-update = { workspace = true, features = ["mock"] }
+29 -1
View File
@@ -1,5 +1,6 @@
//! fieldcraft-cli commands
//! fieldcraft-cli run <script.json>
//! fieldcraft-cli snapshot <script.json> --out shot.png [--view V | --camera JSON] [--size WxH] [--section x|y|z[=V]] [--field F]
//! fieldcraft-cli features
//! fieldcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! fieldcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
@@ -9,6 +10,7 @@
#![forbid(unsafe_code)]
mod crashes;
mod snapshot;
mod update;
use std::path::Path;
@@ -23,6 +25,19 @@ usage:
fieldcraft-cli commands --json the same as JSON, each command's parameters as JSON Schema
fieldcraft-cli run <script.json> run a script (a JSON array of {\"command\", \"params\"})
and print the model summary
fieldcraft-cli snapshot <script.json> --out FILE.png [--view V | --camera JSON]
[--size WxH] [--section x|y|z[=VALUE]] [--field FIELD]
run a script and draw the model to a PNG with no
window: the viewport's GPU pipeline on a hardware or
software GPU, else the CPU rasterizer (FIELDCRAFT_GPU=
auto|hardware|software|cpu); V: iso (default), x, y, z,
front, back, left, right, top, bottom; size 1280x800;
--section hides the elements beyond a plane (at the
mesh's middle, or VALUE); --field contours a nodal
field of the solved step (results.contour);
--camera '{\"yaw\":30,\"pitch\":20,\"target\":...,
\"fit\":true,\"fov\":35}' aims as MCP screenshot's camera
(default target: the model's centre of mass)
fieldcraft-cli features print docs/features.md (every capability, ready or
coming soon) from the capability catalogue
fieldcraft-cli mcp [options] serve the Model Context Protocol on stdin/stdout
@@ -67,6 +82,7 @@ fn main() -> ExitCode {
Some("mcp") => mcp(args.get(1..).unwrap_or(&[])),
Some("crashes") => crashes::run(args.get(1..).unwrap_or(&[])),
Some("update") => update::run(args.get(1..).unwrap_or(&[])),
Some("snapshot") => snapshot::run(args.get(1..).unwrap_or(&[])),
Some("run") => args.get(1).ok_or_else(|| format!("run needs a script\n\n{USAGE}")).and_then(|p| run(p)),
Some("-V" | "--version") => {
println!("fieldcraft-cli {}", env!("CARGO_PKG_VERSION"));
@@ -135,7 +151,14 @@ fn mcp(args: &[String]) -> Result<(), String> {
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| format!("mcp: {e}"))
}
fn run(path: &str) -> Result<(), String> {
/// The JSON report of a picture: what drew it and what it shows (the MCP `screenshot` reply's
/// fields).
pub fn picture_report(r: &fieldcraft_gpu::Renderer, shown: &fieldcraft_engine::view::Shown) -> serde_json::Value {
fieldcraft_mcp::picture_report(r, shown)
}
/// The session after running the script at `path`.
fn session_of(path: &str) -> Result<Session, String> {
let size = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
if size > MAX_SCRIPT_BYTES {
return Err(format!("{path}: larger than {} MB", MAX_SCRIPT_BYTES / (1024 * 1024)));
@@ -144,6 +167,11 @@ fn run(path: &str) -> Result<(), String> {
let script: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("{path}: {e}"))?;
let mut session = Session::default();
session.run_script(&script).map_err(|e| e.to_string())?;
Ok(session)
}
fn run(path: &str) -> Result<(), String> {
let mut session = session_of(path)?;
let info = session.execute("model.info", &json!({})).map_err(|e| e.to_string())?;
println!("{}", serde_json::to_string_pretty(&info).map_err(|e| e.to_string())?);
Ok(())
+261
View File
@@ -0,0 +1,261 @@
//! `fieldcraft-cli snapshot`: a script in, a PNG out, with no window and no display. The picture
//! is the viewport's: the engine's scene ([`fieldcraft_engine::view::frame`]) drawn with the
//! window's GPU pipelines ([`fieldcraft_gpu::capture`]) on a hardware GPU or a software one
//! (lavapipe, llvmpipe, WARP), and by the CPU rasterizer as the last tier (`FIELDCRAFT_GPU`,
//! [`fieldcraft_gpu::select`]).
use fieldcraft_engine::view::{self, Aim, Palette, Section, Target};
use fieldcraft_gpu::Renderer;
use fieldcraft_gpu::capture::{self, Capturer};
use fieldcraft_gpu::select::Policy;
use fieldcraft_render::{Orientation, StandardView};
/// The picture size when `--size` is not given.
const DEFAULT_SIZE: (u32, u32) = (1280, 800);
#[derive(Clone, Debug, PartialEq)]
pub struct Options {
pub script: String,
pub out: String,
pub view: StandardView,
/// A camera aimed as the MCP `screenshot`'s (`--camera JSON`; not with `--view`).
pub camera: Option<Aim>,
pub size: (u32, u32),
/// A section through the mesh (`--section x|y|z[=VALUE]`).
pub section: Option<Section>,
/// A nodal field contoured on the last solved step (`--field von_mises`).
pub field: Option<String>,
}
/// A view name: a standard view, or the axis looked along (`x`: from the right, `y`: from the
/// front, `z`: from the top).
fn view_of(s: &str) -> Option<StandardView> {
match s.to_ascii_lowercase().as_str() {
"x" => Some(StandardView::Right),
"y" => Some(StandardView::Front),
"z" => Some(StandardView::Top),
other => StandardView::parse(other),
}
}
/// `WxH`, each side 1..=the renderer's limit.
fn size_of(s: &str) -> Option<(u32, u32)> {
let (w, h) = s.split_once(['x', 'X'])?;
let (w, h) = (w.trim().parse::<u32>().ok()?, h.trim().parse::<u32>().ok()?);
let max = fieldcraft_gpu::capture::MAX_SIDE;
((1..=max).contains(&w) && (1..=max).contains(&h) && u64::from(w) * u64::from(h) <= fieldcraft_gpu::capture::MAX_PIXELS).then_some((w, h))
}
/// The options: the script, then each flag once, as `--flag value` or `--flag=value`.
pub fn options(args: &[String]) -> Result<Options, String> {
let (mut script, mut out, mut view, mut size, mut section, mut field, mut camera) = (None, None, None, None, None, None, None);
let mut it = args.iter();
while let Some(arg) = it.next() {
if !arg.starts_with("--") {
if script.replace(arg.clone()).is_some() {
return Err(format!("snapshot takes one script, not `{arg}` too"));
}
continue;
}
let (flag, inline) = match arg.split_once('=') {
Some((f, v)) => (f, Some(v.to_string())),
None => (arg.as_str(), None),
};
let value = inline.or_else(|| it.next().cloned()).filter(|v| !v.is_empty()).ok_or_else(|| format!("{flag} needs a value"))?;
let twice = match flag {
"--out" => out.replace(value).is_some(),
"--view" => view
.replace(
view_of(&value).ok_or_else(|| format!("--view: unknown view `{value}` (iso, x, y, z, front, back, left, right, top, bottom)"))?,
)
.is_some(),
"--size" => size
.replace(size_of(&value).ok_or_else(|| {
format!(
"--size: `{value}` is not WxH with each side 1..={} and at most {} pixels",
fieldcraft_gpu::capture::MAX_SIDE,
fieldcraft_gpu::capture::MAX_PIXELS
)
})?)
.is_some(),
"--section" => section
.replace(Section::parse(&value).ok_or_else(|| format!("--section: `{value}` is not x, y, z or AXIS=VALUE (x=12.5)"))?)
.is_some(),
"--field" => field.replace(value).is_some(),
"--camera" => {
let v: serde_json::Value = serde_json::from_str(&value).map_err(|e| format!("--camera: not JSON: {e}"))?;
camera.replace(view::camera(&v).map_err(|e| format!("--camera: {e}"))?).is_some()
}
other => return Err(format!("snapshot: unknown option `{other}`")),
};
if twice {
return Err(format!("{flag} is given twice"));
}
}
if view.is_some() && camera.is_some() {
return Err("give --view or --camera, not both".into());
}
Ok(Options {
script: script.ok_or("snapshot needs a script")?,
out: out.ok_or("snapshot needs --out FILE.png")?,
view: view.unwrap_or(StandardView::Iso),
camera,
size: size.unwrap_or(DEFAULT_SIZE),
section,
field,
})
}
/// The PNG of the session `o` describes, drawn as `policy` says ([`capture::png`]).
pub fn picture(o: &Options, policy: Policy) -> Result<(Vec<u8>, Renderer, view::Shown), String> {
let mut session = crate::session_of(&o.script)?;
if let Some(field) = &o.field {
session.execute("results.contour", &serde_json::json!({"field": field})).map_err(|e| format!("--field: {e}"))?;
}
let (w, h) = o.size;
let f = match &o.camera {
Some(aim) => view::frame_at(&session, aim, w as usize, h as usize, &Palette::LIGHT, o.section).map_err(|e| e.to_string())?,
None => view::frame(&session, &Orientation::of(o.view), w as usize, h as usize, &Palette::LIGHT, o.section),
};
let capturer = Capturer::open(policy);
let (png, renderer) = capture::png(&capturer, policy, &f.scene, &[&f.symbols], &f.cam, w, h).map_err(|e| e.to_string())?;
Ok((png, renderer, f.shown))
}
pub fn run(args: &[String]) -> Result<(), String> {
let o = options(args)?;
let policy = Policy::from_env().map_err(|e| e.to_string())?;
let (png, renderer, shown) = picture(&o, policy)?;
std::fs::write(&o.out, &png).map_err(|e| format!("{}: {e}", o.out))?;
let mut report = crate::picture_report(&renderer, &shown);
if let Some(r) = report.as_object_mut() {
r.insert("out".into(), o.out.clone().into());
r.insert("width".into(), o.size.0.into());
r.insert("height".into(), o.size.1.into());
match &o.camera {
Some(aim) => r.insert("camera".into(), Aim { target: Some(aim.target.clone().unwrap_or(Target::MODEL)), ..aim.clone() }.json()),
None => r.insert("view".into(), o.view.label().into()),
};
}
println!("{report}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(args: &[&str]) -> Result<Options, String> {
options(&args.iter().map(|s| s.to_string()).collect::<Vec<_>>())
}
#[test]
fn options_are_checked() {
let o = opts(&["s.json", "--out", "a.png"]).unwrap();
assert_eq!(
o,
Options {
script: "s.json".into(),
out: "a.png".into(),
view: StandardView::Iso,
camera: None,
size: DEFAULT_SIZE,
section: None,
field: None
}
);
let o = opts(&["s", "--out", "o", "--section", "y=2.5", "--field", "displacement"]).unwrap();
assert_eq!((o.section, o.field.as_deref()), (Some(Section { axis: 1, at: Some(2.5) }), Some("displacement")));
assert_eq!(opts(&["s", "--out", "o", "--section=Z"]).unwrap().section, Some(Section { axis: 2, at: None }));
let o = opts(&["--view=z", "s.json", "--size", "640x480", "--out=b.png"]).unwrap();
assert_eq!((o.view, o.size), (StandardView::Top, (640, 480)));
assert_eq!(opts(&["s", "--out", "o", "--view", "X"]).unwrap().view, StandardView::Right);
assert_eq!(opts(&["s", "--out", "o", "--view", "front"]).unwrap().view, StandardView::Front);
let o = opts(&["s", "--out", "o", "--camera", r#"{"yaw": 30, "pitch": 10, "target": [1, 2, 3], "fit": true}"#]).unwrap();
assert_eq!(o.camera.and_then(|a| a.target), Some(Target::Point(fieldcraft_render::Vec3::new(1.0, 2.0, 3.0))));
for bad in [
vec![],
vec!["s.json"],
vec!["--out", "a.png"],
vec!["s", "t", "--out", "o"],
vec!["s", "--out"],
vec!["s", "--out", "o", "--out", "p"],
vec!["s", "--out", "o", "--view", "diagonal"],
vec!["s", "--out", "o", "--size", "0x10"],
vec!["s", "--out", "o", "--size", "10"],
vec!["s", "--out", "o", "--size", "99999x10"],
vec!["s", "--out", "o", "--size", "8192x8192"],
vec!["s", "--out", "o", "--size", "-5x10"],
vec!["s", "--out", "o", "--nope", "1"],
vec!["s", "--out", "o", "--section", "w"],
vec!["s", "--out", "o", "--section", "x=inf"],
vec!["s", "--out", "o", "--section", "x=", "--field", "f"],
vec!["s", "--out", "o", "--camera", "{yaw: 1}"],
vec!["s", "--out", "o", "--camera", r#"{"yaw": 0}"#],
vec!["s", "--out", "o", "--camera", r#"{"yaw": 0, "pitch": 0}"#, "--view", "x"],
] {
assert!(opts(&bad).is_err(), "{bad:?}");
}
}
/// PNG width, height and pixels (RGBA).
fn decode(png: &[u8]) -> (u32, u32, Vec<u8>) {
let mut r = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
let mut buf = vec![0; r.output_buffer_size().unwrap()];
let info = r.next_frame(&mut buf).unwrap();
buf.truncate(info.buffer_size());
(info.width, info.height, buf)
}
fn script(dir: &std::path::Path, name: &str, solve: bool) -> String {
let mut steps = fieldcraft_engine::sample::cantilever();
if solve && let Some(a) = steps.as_array_mut() {
a.push(serde_json::json!({"command": "solve.static", "params": {}}));
}
let path = dir.join(name);
std::fs::write(&path, steps.to_string()).unwrap();
path.to_string_lossy().into()
}
fn opts_for(script: String) -> Options {
Options { script, out: String::new(), view: StandardView::Iso, camera: None, size: (320, 200), section: None, field: None }
}
/// The cantilever drawn headless: a PNG of the size asked for, with the model in it (pixels
/// of the hex colour) and its element edges (dark pixels). `auto` draws on the GPU when the
/// machine has one and on the CPU otherwise (it must be the GPU under `FIELDCRAFT_REQUIRE_GPU=1`); `cpu` always
/// on the CPU, and both pictures show the same model. A section and a field change it.
#[test]
fn snapshots_the_model_and_its_mesh_headless() {
let dir = std::env::temp_dir().join(format!("fieldcraft-snapshot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let o = opts_for(script(&dir, "cantilever.json", false));
for policy in [Policy::Auto, Policy::Cpu] {
let (png, drawn, _) = picture(&o, policy).unwrap();
let (w, h, px) = decode(&png);
assert_eq!((w, h), (320, 200), "{drawn:?}");
let hex = Palette::LIGHT.hex;
let near = |p: &[u8], c: fieldcraft_render::Rgb, d: u8| p[0].abs_diff(c.0) <= d && p[1].abs_diff(c.1) <= d && p[2].abs_diff(c.2) <= d;
// Shaded faces vary in brightness around the hex colour; the edges are near black.
let faces = px.chunks(4).filter(|p| near(p, hex, 60) && p[2] > p[0]).count();
let edges = px.chunks(4).filter(|p| p[0] < 90 && p[1] < 90 && p[2] < 90).count();
assert!(faces > 500, "{drawn:?}: {faces} face pixels");
assert!(edges > 100, "{drawn:?}: {edges} edge pixels");
if policy == Policy::Cpu {
assert_eq!(drawn, Renderer::Cpu { why: None });
} else if fieldcraft_gpu::select::gpu_required() {
assert!(matches!(drawn, Renderer::Gpu { .. }), "FIELDCRAFT_REQUIRE_GPU=1 but drawn by {drawn:?}");
}
}
// A section along the beam draws half of it.
let whole = picture(&o, Policy::Cpu).unwrap().0;
let cut = picture(&Options { section: Some(Section { axis: 0, at: None }), ..o.clone() }, Policy::Cpu).unwrap().0;
assert_ne!(whole, cut);
// A field needs a solved step; on a solved one it is the contour reported.
assert!(picture(&Options { field: Some("von_mises".into()), ..o.clone() }, Policy::Cpu).is_err());
let solved = Options { field: Some("displacement".into()), ..opts_for(script(&dir, "solved.json", true)) };
let (_, _, shown) = picture(&solved, Policy::Auto).unwrap();
assert_eq!(shown.contour.map(|c| c.0).as_deref(), Some("displacement"));
std::fs::remove_dir_all(&dir).ok();
}
}
+4 -1
View File
@@ -12,7 +12,10 @@ fn cli(host: &str, args: &[&str]) -> std::process::Output {
#[test]
fn update_check_reports_the_latest_release() {
let server = Server::start().unwrap();
server.release("latest", &json!({"tag_name": "v99.0.0", "html_url": "https://github.com/jyviko/fieldcraft/releases/tag/v99.0.0", "assets": []}));
server.release(
"latest",
&json!({"tag_name": "v99.0.0", "html_url": "https://github.com/tangency-labs/fieldcraft/releases/tag/v99.0.0", "assets": []}),
);
let out = cli(&server.url(), &["update", "--check"]);
assert!(out.status.success(), "{out:?}");
let report: Value = serde_json::from_slice(&out.stdout).unwrap();
+1 -1
View File
@@ -31,4 +31,4 @@ wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "DomTokenList", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "Storage", "Crypto", "Headers", "Request", "RequestInit", "RequestCache", "Response"] }
# Autosaves kept in the browser's local storage, which holds text.
base64 = "0.22"
base64 = "0.23"
+3 -3
View File
@@ -9,15 +9,15 @@
<meta property="og:site_name" content="FieldCraft">
<meta property="og:title" content="FieldCraft">
<meta property="og:description" content="Open-source CAE built in Rust: structures, heat, fluids, and MCP for AI agents.">
<meta property="og:url" content="https://jyviko.github.io/fieldcraft/">
<meta property="og:image" content="https://jyviko.github.io/fieldcraft/og.png">
<meta property="og:url" content="https://tangency-labs.github.io/fieldcraft/">
<meta property="og:image" content="https://tangency-labs.github.io/fieldcraft/og.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
<meta property="og:image:alt" content="FieldCraft: Open-source CAE built in Rust">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="FieldCraft">
<meta name="twitter:description" content="Open-source CAE built in Rust: structures, heat, fluids, and MCP for AI agents.">
<meta name="twitter:image" content="https://jyviko.github.io/fieldcraft/og.png">
<meta name="twitter:image" content="https://tangency-labs.github.io/fieldcraft/og.png">
<link data-trunk rel="rust" data-bin="fieldcraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="icon" type="image/svg+xml" href="../../assets/app-icon/icon-small.svg" />
<link data-trunk rel="copy-file" href="../../assets/app-icon/icon.iconset/icon_128x128@2x.png" data-target-path="icons" />
+34 -4
View File
@@ -6,8 +6,18 @@ edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/native_crash.rs
# (the Windows exception filter, which has no safe API) can allow it. Keep the rest in sync with
# [workspace.lints].
[lints.rust]
unsafe_code = "deny"
[lints.clippy]
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
neg_cmp_op_on_partial_ord = "allow"
[dependencies]
fieldcraft-engine = { workspace = true }
@@ -23,7 +33,27 @@ log = { workspace = true }
# operating system's certificates (no bundled root list), and a random install id.
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
getrandom = "0.3"
getrandom = "0.4"
# A termination signal (SIGTERM, SIGINT) closes the window the normal way (terminate.rs).
[target.'cfg(unix)'.dependencies]
signal-hook = "0.4"
# The exception filter and minidump of a native crash (src/native_crash.rs).
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Diagnostics_Debug",
# WriteFile's OVERLAPPED.
"Win32_System_IO",
"Win32_System_Kernel",
"Win32_System_LibraryLoader",
# MiniDumpWriteDump.
"Win32_System_Memory",
"Win32_System_Threading",
] }
[build-dependencies]
# The Windows executable's icon resource (build.rs).
@@ -71,7 +101,7 @@ assets = [
[package.metadata.generate-rpm]
name = "fieldcraft"
license = "MIT OR Apache-2.0"
url = "https://github.com/jyviko/fieldcraft"
url = "https://github.com/tangency-labs/fieldcraft"
assets = [
{ source = "target/release/fieldcraft", dest = "/usr/bin/fieldcraft", mode = "755" },
{ source = "target/release/fieldcraft-cli", dest = "/usr/bin/fieldcraft-cli", mode = "755" },
+45 -8
View File
@@ -9,9 +9,12 @@
//! `fieldcraft_ui_egui::control` and docs/control-protocol.md.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
// Unsafe code is denied (Cargo.toml): only the Windows exception filter allows it (native_crash.rs).
mod control_server;
#[cfg(windows)]
mod native_crash;
mod terminate;
mod update;
mod usage;
@@ -49,8 +52,8 @@ struct App {
autosaver: Option<Autosaver>,
/// App time of the last autosave check.
autosaved_at: f64,
/// The first frame was drawn (the crash journal says the app is up).
running: bool,
/// Frames drawn so far, up to 2 (the second means the first was presented: the app is up).
frames: u8,
/// Sends the anonymous usage events, when this run may (docs/privacy.md).
usage: Option<usage::Sender>,
}
@@ -67,9 +70,13 @@ impl App {
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if !self.running {
self.running = true;
crash::activity("running");
if self.frames < 2 {
self.frames += 1;
// A second frame right away, which means the first was presented: the app is up.
ctx.request_repaint();
if self.frames == 2 {
crash::phase(crash::RUNNING);
}
}
self.app.logic(ctx);
if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS {
@@ -162,10 +169,18 @@ fn start_reporting() -> Vec<crash::Summary> {
b.version_line(),
crash::default_dir().map(|d| d.display().to_string()).unwrap_or_default()
);
crash::activity("startup");
crashed
}
/// egui-wgpu is about to start a step of getting the graphics device and the window's surface
/// up: the crash journal keeps it, and the adapter once it is picked (#299).
fn startup_step(step: &'static str, detail: &str) {
if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN {
crash::set_environment(&format!("Renderer: {detail}"));
}
crash::phase(step);
}
/// Command-line options.
#[derive(Debug, Default, PartialEq)]
struct Args {
@@ -223,6 +238,12 @@ fn main() -> eframe::Result {
fieldcraft_io::recovery::use_automation_dir();
}
let crashed = start_reporting();
// A crash in native code (a graphics driver) says where it happened: the exception and the
// module on Windows, and on every system the startup step the app was in.
#[cfg(windows)]
native_crash::install();
eframe::egui_wgpu::set_startup_hook(startup_step);
crash::phase("window");
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FieldCraft")
@@ -245,6 +266,7 @@ fn main() -> eframe::Result {
"FieldCraft",
options,
Box::new(move |cc| {
crash::phase("app setup");
let mut session = Session::default();
// The window's model: its commands go into the crash journal.
session.crash_journal = crash::enabled();
@@ -265,7 +287,21 @@ fn main() -> eframe::Result {
if let Some(rs) = &cc.wgpu_render_state {
let info = rs.adapter.get_info();
log::info!("graphics {:?} {} ({}), MSAA {}x (requested {MSAA}x)", info.backend, info.name, info.driver, rs.msaa_samples);
crash::set_environment(&format!("Renderer: {:?} {} ({}), MSAA {}x", info.backend, info.name, info.driver, rs.msaa_samples));
crash::phase("viewport pipelines (shader compile)");
app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx);
// The graphics driver is loaded now; it may have put its own exception filter
// in front of ours.
#[cfg(windows)]
native_crash::reinstall();
}
// `kill` or Ctrl-C closes the window as Close does: autosaved, no crash (#248).
let ctx = cc.egui_ctx.clone();
if let Err(e) = terminate::watch(move || {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ctx.request_repaint();
}) {
log::warn!("termination signals: {e}");
}
if let Some(port) = args.control {
app = app.with_control(control_server::start(port, cc.egui_ctx.clone()));
@@ -285,7 +321,8 @@ fn main() -> eframe::Result {
Autosaver::new(&dir).map_err(|e| log::warn!("autosave unavailable: {e}")).ok()
});
app.announce_crashes(&crashed);
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, running: false, usage }))
crash::phase("first frame");
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, frames: 0, usage }))
}),
)
}
+290
View File
@@ -0,0 +1,290 @@
//! Windows: what a crash in native code leaves behind (docs/crash-reports.md, #299).
//!
//! A crash inside a graphics driver or another DLL (an access violation, a stack overflow) is no
//! Rust panic: the panic hook never runs and the next start only found "an unclean exit". An
//! unhandled-exception filter now writes, before the process goes, a small JSON file with the
//! exception code, the faulting module and the offset into it, and a minidump, next to the run's
//! crash journal (`fieldcraft_engine::crash::native_paths`). The next start puts both into the
//! unclean exit's report, with the startup step the journal says the app was in. Everything that
//! can be tested without Windows (reading the file, the report, the startup steps) lives in
//! `fieldcraft_engine::crash` and is tested there.
//!
//! Installing an exception filter and asking the system which module holds an address have no
//! safe API, hence the `unsafe_code` allowance on this module, the only one in FieldCraft (the
//! app crate denies unsafe code instead of forbidding it for this). The filter runs on a thread
//! that may have a broken heap or almost no stack left: it doesn't allocate, formats into a buffer
//! on the stack, and skips the minidump after a stack overflow.
//!
//! Adapted from SolidCraft's `apps/solidcraft/src/native_crash.rs` (MIT OR Apache-2.0), see
//! ATTRIBUTION.md.
#![allow(unsafe_code)]
use std::fmt::Write as _;
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_WRITE, HANDLE, HMODULE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::{CREATE_ALWAYS, CreateFileW, FILE_ATTRIBUTE_NORMAL, WriteFile};
use windows_sys::Win32::System::Diagnostics::Debug::{
EXCEPTION_CONTINUE_SEARCH, EXCEPTION_POINTERS, MINIDUMP_EXCEPTION_INFORMATION, MiniDumpNormal, MiniDumpWithThreadInfo,
MiniDumpWithUnloadedModules, MiniDumpWriteDump, SetUnhandledExceptionFilter,
};
use windows_sys::Win32::System::LibraryLoader::{
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, GetModuleFileNameW, GetModuleHandleExW,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId};
/// Where the filter writes (NUL-terminated UTF-16), set once by [`install`].
struct Paths {
info: Vec<u16>,
dump: Vec<u16>,
}
static PATHS: OnceLock<Paths> = OnceLock::new();
/// The filter that was installed before ours (called after ours), as an address.
static PREVIOUS: AtomicUsize = AtomicUsize::new(0);
/// The filter has started writing (a second exception while it does is left alone).
static BUSY: AtomicBool = AtomicBool::new(false);
const STATUS_ACCESS_VIOLATION: u32 = 0xC000_0005;
const STATUS_IN_PAGE_ERROR: u32 = 0xC000_0006;
const STATUS_STACK_OVERFLOW: u32 = 0xC000_00FD;
/// Catch native crashes from now on (after `fieldcraft_engine::crash::install`; nothing without it).
pub fn install() {
let Some((info, dump)) = fieldcraft_engine::crash::native_paths() else { return };
let wide = |p: &Path| p.as_os_str().encode_wide().chain([0]).collect::<Vec<u16>>();
if PATHS.set(Paths { info: wide(&info), dump: wide(&dump) }).is_ok() {
set_filter();
}
}
/// Put the filter back on top: a DLL loaded since (a graphics driver) may have set its own,
/// which is then called after ours.
pub fn reinstall() {
if PATHS.get().is_some() {
set_filter();
}
}
fn set_filter() {
// SAFETY: `filter` has the signature the system calls it with and stays valid for the
// process's life.
let previous = unsafe { SetUnhandledExceptionFilter(Some(filter)) };
if let Some(p) = previous
&& p as usize != filter as *const () as usize
{
PREVIOUS.store(p as usize, Ordering::SeqCst);
}
}
/// The unhandled-exception filter: record, then let the previous filter (or the system) go on.
unsafe extern "system" fn filter(pointers: *const EXCEPTION_POINTERS) -> i32 {
if !BUSY.swap(true, Ordering::SeqCst) {
// SAFETY: the system passes valid exception pointers for the duration of the call.
unsafe { record(pointers) };
}
let previous = PREVIOUS.load(Ordering::SeqCst);
if previous != 0 && previous != filter as *const () as usize {
// SAFETY: the address came from `SetUnhandledExceptionFilter` as a filter of this type.
let previous: unsafe extern "system" fn(*const EXCEPTION_POINTERS) -> i32 = unsafe { std::mem::transmute(previous) };
// SAFETY: as for this filter.
return unsafe { previous(pointers) };
}
EXCEPTION_CONTINUE_SEARCH
}
/// A fixed buffer to format into without allocating (what doesn't fit is cut off).
struct Buf {
bytes: [u8; 2048],
len: usize,
}
impl std::fmt::Write for Buf {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
let room = self.bytes.len().saturating_sub(self.len);
let n = s.len().min(room);
if let (Some(to), Some(from)) = (self.bytes.get_mut(self.len..self.len + n), s.as_bytes().get(..n)) {
to.copy_from_slice(from);
self.len += n;
}
Ok(())
}
}
/// The usual names of the exception codes a crash shows.
fn code_name(code: u32) -> &'static str {
match code {
STATUS_ACCESS_VIOLATION => "EXCEPTION_ACCESS_VIOLATION",
STATUS_IN_PAGE_ERROR => "EXCEPTION_IN_PAGE_ERROR",
STATUS_STACK_OVERFLOW => "EXCEPTION_STACK_OVERFLOW",
0xC000_001D => "EXCEPTION_ILLEGAL_INSTRUCTION",
0xC000_0094 => "EXCEPTION_INT_DIVIDE_BY_ZERO",
0xC000_0096 => "EXCEPTION_PRIV_INSTRUCTION",
0xC000_008C => "EXCEPTION_ARRAY_BOUNDS_EXCEEDED",
0xC000_0374 => "STATUS_HEAP_CORRUPTION",
0xC000_0409 => "STATUS_STACK_BUFFER_OVERRUN",
0xC000_0602 => "STATUS_FAIL_FAST_EXCEPTION",
0x8000_0003 => "EXCEPTION_BREAKPOINT",
0xE06D_7363 => "C++ exception",
_ => "exception",
}
}
/// Write what `pointers` says into the info file, then the minidump.
unsafe fn record(pointers: *const EXCEPTION_POINTERS) {
let Some(paths) = PATHS.get() else { return };
if pointers.is_null() {
return;
}
// SAFETY: checked non-null; the system keeps it valid during the filter.
let rec = unsafe { (*pointers).ExceptionRecord };
if rec.is_null() {
return;
}
// SAFETY: as above.
let rec = unsafe { &*rec };
let code = rec.ExceptionCode as u32;
let address = rec.ExceptionAddress as usize;
let mut module: HMODULE = std::ptr::null_mut();
let mut name = [0u16; 512];
// SAFETY: FROM_ADDRESS takes the address in place of a name; UNCHANGED_REFCOUNT means no
// handle to release; `name` is as long as we say.
let len = unsafe {
if GetModuleHandleExW(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
address as *const u16,
&mut module,
) != 0
{
GetModuleFileNameW(module, name.as_mut_ptr(), name.len() as u32) as usize
} else {
0
}
};
// SAFETY: no preconditions.
let thread = unsafe { GetCurrentThreadId() };
let mut b = Buf { bytes: [0; 2048], len: 0 };
let _ = write!(b, "{{\"code\":\"0x{code:08X}\",\"name\":\"{}\",\"address\":\"0x{address:016X}\",\"thread\":{thread}", code_name(code));
if len > 0 {
let _ = write!(b, ",\"offset\":\"0x{:X}\",\"module\":\"", address.wrapping_sub(module as usize));
for c in char::decode_utf16(name.iter().take(len).copied()) {
let _ = match c.unwrap_or(char::REPLACEMENT_CHARACTER) {
'"' => b.write_str("\\\""),
'\\' => b.write_str("\\\\"),
c if (c as u32) < 0x20 => b.write_str("?"),
c => b.write_char(c),
};
}
let _ = b.write_str("\"");
}
if matches!(code, STATUS_ACCESS_VIOLATION | STATUS_IN_PAGE_ERROR) && rec.NumberParameters >= 2 {
let what = match rec.ExceptionInformation[0] {
0 => "read of",
1 => "write to",
8 => "execution at",
_ => "access to",
};
let _ = write!(b, ",\"detail\":\"{what} 0x{:016X}\"", rec.ExceptionInformation[1]);
}
let _ = b.write_str("}");
let bytes = b.bytes.get(..b.len).unwrap_or_default();
// SAFETY: NUL-terminated path; the handle is closed below.
unsafe {
let file = create(&paths.info);
if !file.is_null() {
let mut written = 0u32;
WriteFile(file, bytes.as_ptr(), bytes.len() as u32, &mut written, std::ptr::null_mut());
CloseHandle(file);
}
}
// After a stack overflow there is no stack left for writing a minidump.
if code == STATUS_STACK_OVERFLOW {
return;
}
// SAFETY: as above; the exception information points at this thread's exception.
unsafe {
let file = create(&paths.dump);
if !file.is_null() {
let exception = MINIDUMP_EXCEPTION_INFORMATION { ThreadId: thread, ExceptionPointers: pointers as *mut _, ClientPointers: 0 };
MiniDumpWriteDump(
GetCurrentProcess(),
GetCurrentProcessId(),
file,
MiniDumpNormal | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules,
&exception,
std::ptr::null(),
std::ptr::null(),
);
CloseHandle(file);
}
}
}
/// A new file at `path` (NUL-terminated) for writing, or null.
unsafe fn create(path: &[u16]) -> HANDLE {
// SAFETY: the caller passes a NUL-terminated path.
let h = unsafe { CreateFileW(path.as_ptr(), GENERIC_WRITE, 0, std::ptr::null(), CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, std::ptr::null_mut()) };
if h == INVALID_HANDLE_VALUE { std::ptr::null_mut() } else { h }
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
/// The child half: install crash reporting and the filter, then fault in native code.
#[test]
fn child() {
let Some(dir) = std::env::var_os("FIELDCRAFT_NATIVE_CRASH_CHILD") else { return };
let build = fieldcraft_engine::crash::Build { version: "9.9.9", commit: "abc1234" };
let fresh = fieldcraft_engine::crash::install(PathBuf::from(dir), build);
println!("FRESH {}", serde_json::to_string(&fresh).unwrap_or_default());
if std::env::var_os("FIELDCRAFT_NATIVE_CRASH_FAULT").is_none() {
fieldcraft_engine::crash::end_session();
return;
}
super::install();
fieldcraft_engine::crash::phase("device creation");
// SAFETY: none; this is the crash under test (a read of address 16, as a null struct
// pointer's field would be).
let v = unsafe { std::ptr::read_volatile(16 as *const u64) };
println!("unreachable {v}");
}
fn run(dir: &std::path::Path, fault: bool) -> std::process::Output {
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
c.args(["--exact", "native_crash::tests::child", "--test-threads=1", "--nocapture"]).env("FIELDCRAFT_NATIVE_CRASH_CHILD", dir);
if fault {
c.env("FIELDCRAFT_NATIVE_CRASH_FAULT", "1");
}
c.output().unwrap()
}
#[test]
fn an_access_violation_is_reported_with_its_module_step_and_minidump() {
let dir = std::env::temp_dir().join(format!("fieldcraft-native-crash-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = run(&dir, true);
assert_eq!(out.status.code().map(|c| c as u32), Some(super::STATUS_ACCESS_VIOLATION), "{out:?}");
let sessions: Vec<PathBuf> = std::fs::read_dir(dir.join("sessions")).unwrap().flatten().map(|e| e.path()).collect();
let info = sessions.iter().find(|p| p.to_string_lossy().ends_with(".native.json")).expect("the filter wrote what it caught");
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(info).unwrap()).unwrap();
assert_eq!(v["code"], "0xC0000005", "{v}");
assert_eq!(v["detail"], "read of 0x0000000000000010", "{v}");
assert!(v["module"].as_str().unwrap().to_lowercase().contains("fieldcraft"), "the fault is in the test binary: {v}");
let dump = sessions.iter().find(|p| p.extension().is_some_and(|e| e == "dmp")).expect("a minidump");
assert_eq!(std::fs::read(dump).unwrap().get(..4), Some(&b"MDMP"[..]));
// The next start reports it.
let out = run(&dir, false);
assert!(out.status.success(), "{out:?}");
let reports = fieldcraft_engine::crash::list(&dir);
assert_eq!(reports.len(), 1, "{reports:?}");
let r = fieldcraft_engine::crash::read(&dir, &reports[0].id).unwrap();
assert!(r["message"].as_str().unwrap().contains("EXCEPTION_ACCESS_VIOLATION (0xC0000005) in fieldcraft"), "{r}");
assert!(r["message"].as_str().unwrap().contains("during startup (device creation"), "{r}");
assert!(r.get("minidump_path").is_some(), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+143
View File
@@ -0,0 +1,143 @@
//! A request to terminate (SIGTERM, SIGINT on Unix) closes the window the normal way.
//!
//! Without this, `kill PID`, Ctrl-C in the terminal, a system shutdown or a test harness's timeout
//! ended the process on the spot: no `on_exit`, so no last autosave and no clean end of the crash
//! journal, and the next start filed an `unclean_exit` crash report for what was no crash (#248:
//! "ended without a clean exit between commands (last: control ui.capture)" was an automation run
//! stopped with SIGTERM after its capture). Now the first signal asks the window to close, which
//! autosaves, ends the journal and exits as a click on Close does; the journal notes the signal,
//! so a run that still dies while closing says why. A second signal, for a window that does not
//! close, ends the process at once with the signal's default action.
//!
//! SIGHUP is left alone: `nohup fieldcraft &` ignores it on purpose so the app outlives its
//! terminal, and a handler would override that (a safe API cannot ask whether a signal was
//! ignored at launch).
//!
//! Windows has no such signals: a window there is closed through its messages, which eframe
//! already turns into a normal close.
use std::io;
/// The signals that ask the app to terminate.
#[cfg(unix)]
const SIGNALS: [i32; 2] = [signal_hook::consts::SIGTERM, signal_hook::consts::SIGINT];
/// The signal's usual name, for the log and the crash journal.
#[cfg(unix)]
fn name(signal: i32) -> &'static str {
match signal {
signal_hook::consts::SIGTERM => "SIGTERM",
signal_hook::consts::SIGINT => "SIGINT",
_ => "signal",
}
}
/// Call `close` (on a thread of its own) on the first termination signal; a second one ends the
/// process with the signal's default action.
#[cfg(unix)]
pub fn watch(close: impl Fn() + Send + 'static) -> io::Result<()> {
let mut signals = signal_hook::iterator::Signals::new(SIGNALS)?;
std::thread::Builder::new().name("terminate".into()).spawn(move || {
let mut asked = false;
for signal in signals.forever() {
if asked {
log::warn!("{}: the window did not close; ending now", name(signal));
if let Err(e) = signal_hook::low_level::emulate_default_handler(signal) {
log::error!("{}: {e}", name(signal));
std::process::exit(128 + signal);
}
continue;
}
asked = true;
log::info!("{}: closing", name(signal));
fieldcraft_engine::crash::activity(&format!("terminating ({})", name(signal)));
close();
}
})?;
Ok(())
}
/// Nothing to watch without Unix signals.
#[cfg(not(unix))]
pub fn watch(_close: impl Fn() + Send + 'static) -> io::Result<()> {
Ok(())
}
#[cfg(all(test, unix))]
mod tests {
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use fieldcraft_engine::crash;
/// The child half: start crash reporting as the app does, then get SIGTERM. With the watcher
/// the "window" (here the main thread) closes the normal way; without it the process dies.
#[test]
fn child() {
let Some(dir) = std::env::var_os("FIELDCRAFT_TERMINATE_CHILD") else { return };
let build = crash::Build { version: "9.9.9", commit: "abc1234" };
let fresh = crash::install(PathBuf::from(dir), build);
if std::env::var_os("FIELDCRAFT_TERMINATE_NEXT").is_some() {
// The next start: what it reports of the run before.
println!("FRESH {}", fresh.iter().map(|s| s.message.as_str()).collect::<Vec<_>>().join(" | "));
crash::end_session();
return;
}
crash::activity("control ui.capture");
if std::env::var_os("FIELDCRAFT_TERMINATE_WATCH").is_some() {
let (tx, rx) = mpsc::channel();
super::watch(move || {
let _ = tx.send(());
})
.unwrap();
signal_hook::low_level::raise(signal_hook::consts::SIGTERM).unwrap();
// What `on_exit` does once the window has closed.
rx.recv().unwrap();
crash::end_session();
println!("CLOSED");
return;
}
signal_hook::low_level::raise(signal_hook::consts::SIGTERM).unwrap();
// Not reached: SIGTERM's default action ends the process.
std::thread::sleep(std::time::Duration::from_secs(10));
}
#[test]
fn sigterm_closes_cleanly_and_leaves_no_crash_report() {
let root = std::env::temp_dir().join(format!("fieldcraft-terminate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let run = |dir: &Path, mode: &str| {
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
c.args(["--exact", "terminate::tests::child", "--test-threads=1", "--nocapture"]).env("FIELDCRAFT_TERMINATE_CHILD", dir);
match mode {
"watch" => c.env("FIELDCRAFT_TERMINATE_WATCH", "1"),
"next" => c.env("FIELDCRAFT_TERMINATE_NEXT", "1"),
_ => &mut c,
};
c.output().unwrap()
};
// What the next start in `dir` reports of the run before.
let reported_at_next_start = |dir: &Path| {
let out = run(dir, "next");
assert!(out.status.success(), "{out:?}");
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
stdout.lines().find_map(|l| l.split_once("FRESH ").map(|(_, r)| r.trim().to_string())).unwrap_or_else(|| panic!("{stdout}"))
};
// Without the watcher (the bug): killed, and the journal it leaves reads as a crash.
let unwatched = root.join("unwatched");
let out = run(&unwatched, "kill");
use std::os::unix::process::ExitStatusExt;
assert_eq!(out.status.signal(), Some(signal_hook::consts::SIGTERM), "{out:?}");
let reported = reported_at_next_start(&unwatched);
assert!(reported.contains("ended without a clean exit") && reported.contains("control ui.capture"), "{reported}");
// With it: the close runs, the journal ends, and nothing is reported as a crash.
let watched = root.join("watched");
let out = run(&watched, "watch");
assert!(out.status.success(), "{out:?}");
assert!(String::from_utf8_lossy(&out.stdout).contains("CLOSED"), "{out:?}");
assert_eq!(reported_at_next_start(&watched), "");
let _ = std::fs::remove_dir_all(&root);
}
}
+11
View File
@@ -0,0 +1,11 @@
<?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>geometry.check</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M9 3 L16 6.5 L16 13 L9 16.5 L2 13 L2 6.5 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 6.5 L9 10 L16 6.5 M9 10 L9 16.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 6.5 L2 9.5" fill="none" stroke="#f0b44a" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.5 17 C13.5 15.067 15.067 13.5 17 13.5 C18.933 13.5 20.5 15.067 20.5 17 C20.5 18.933 18.933 20.5 17 20.5 C15.067 20.5 13.5 18.933 13.5 17 Z" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.5 19.5 L22 22" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.
+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>geometry.face.absorb</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.5 5.1 L17 6.2 L8 10.3 L5.5 9.2 Z" fill="#f0b44a" stroke="#f0b44a" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 823 B

Binary file not shown.
+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>geometry.face.split</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 10 L16.5 6" fill="none" stroke="#f0b44a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 4.5 L9 2.5 M15 9.5 L19 7.5" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 1.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.
+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>geometry.faces.merge</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 6 L16.5 10" fill="none" stroke="#f0b44a" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 2"/>
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 844 B

Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
<?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>geometry.stitch</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 6 L10.5 6 L10.5 18 L3 18 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.5 6 L21 6 L21 18 L13.5 18 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 8.5 L15 8.5" fill="none" stroke="#f0b44a" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 12 L15 12" fill="none" stroke="#f0b44a" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 15.5 L15 15.5" fill="none" stroke="#f0b44a" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.
+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.export.stl</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 9 L9 5 L15 9 L9 13 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 9 L3 17 L9 21 L9 13 M9 21 L15 17 L15 9" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 9 L9 21 M9 13 L15 17" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.
+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.hex.extrude</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M6 20 L18 20 L18 11.6 C14 9.467 10 9.467 6 11.6 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 17.2 L18 17.2 M6 14.4 C10 12.8 14 12.8 18 14.4 M10 20 L10 9.9 M14 20 L14 9.9" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 9.2 C9 5.333 15 5.333 21 9.2" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 6 L12 3 M10.5 4.5 L12 3 L13.5 4.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
<?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.plan</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M12 3 L20 7 L20 16 L12 20 L4 16 L4 7 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 7 L12 11 L20 7 M12 11 L12 20" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 11.5 L12 15.5 L20 11.5 M8 9 L8 18 M16 9 L16 18" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 3 L12 5.4 M20 7 L15.2 7 M12 11 L12 8.6 M4 7 L8.8 7" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.8 7 C8.8 7.884 10.233 8.6 12 8.6 C13.767 8.6 15.2 7.884 15.2 7 C15.2 6.116 13.767 5.4 12 5.4 C10.233 5.4 8.8 6.116 8.8 7 Z" fill="none" stroke="#f0b44a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.
+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.quality.mark</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 3 L11 3 L11 11 L3 11 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 3 L21 3 L21 11 L13 11 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 13 L11 13 L11 21 L3 21 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 14 L21 13 L20 21 L14 19 Z" fill="#f0b44a" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.
+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>results.export.vtu</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M4 3 L12 3 L16 7 L16 21 L4 21 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 3 L12 7 L16 7" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 17 L7 14 M10 17 L10 11 M13 17 L13 13" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 837 B

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M9 3 L16 6.5 L16 13 L9 16.5 L2 13 L2 6.5 Z", "fill": "soft", "stroke": "line"}, {"d": "M2 6.5 L9 10 L16 6.5 M9 10 L9 16.5", "stroke": "line"}, {"line": [2, 6.5, 2, 9.5], "stroke": "warm", "width": 2.25}, {"circle": [17, 17, 3.5], "stroke": "accent", "width": 1.75}, {"line": [19.5, 19.5, 22, 22], "stroke": "accent", "width": 2}]}
@@ -0,0 +1 @@
{"shapes": [{"d": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"d": "M14.5 5.1 L17 6.2 L8 10.3 L5.5 9.2 Z", "fill": "warm", "stroke": "warm", "width": 1}, {"d": "M3 8 L12 4 L21 8 L12 12 Z", "stroke": "accent", "width": 1.75}]}
@@ -0,0 +1 @@
{"shapes": [{"d": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"line": [7.5, 10, 16.5, 6], "stroke": "warm", "width": 2}, {"d": "M5 4.5 L9 2.5 M15 9.5 L19 7.5", "stroke": "accent", "width": 1.5, "dash": [1.5, 1.5]}]}
@@ -0,0 +1 @@
{"shapes": [{"d": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"line": [7.5, 6, 16.5, 10], "stroke": "warm", "width": 1.25, "dash": [1.5, 2]}, {"d": "M3 8 L12 4 L21 8 L12 12 Z", "stroke": "accent", "width": 1.75}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M3 6 L10.5 6 L10.5 18 L3 18 Z", "fill": "soft", "stroke": "line"}, {"d": "M13.5 6 L21 6 L21 18 L13.5 18 Z", "fill": "soft", "stroke": "line"}, {"line": [9, 8.5, 15, 8.5], "stroke": "warm", "width": 1.75}, {"line": [9, 12, 15, 12], "stroke": "warm", "width": 1.75}, {"line": [9, 15.5, 15, 15.5], "stroke": "warm", "width": 1.75}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M3 9 L9 5 L15 9 L9 13 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 9 L3 17 L9 21 L9 13 M9 21 L15 17 L15 9", "stroke": "line"}, {"d": "M3 9 L9 21 M9 13 L15 17", "stroke": "line", "width": 1}, {"d": "M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47", "stroke": "accent", "width": 1.5}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M6 20 L18 20 L18 11.6 Q12 8.4 6 11.6 Z", "fill": "soft", "stroke": "line"}, {"d": "M6 17.2 L18 17.2 M6 14.4 Q12 12 18 14.4 M10 20 L10 9.9 M14 20 L14 9.9", "stroke": "accent", "width": 1}, {"d": "M3 9.2 Q12 3.4 21 9.2", "stroke": "accent", "width": 2}, {"d": "M12 6 L12 3 M10.5 4.5 L12 3 L13.5 4.5", "stroke": "line"}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M12 3 L20 7 L20 16 L12 20 L4 16 L4 7 Z", "fill": "soft", "stroke": "line"}, {"d": "M4 7 L12 11 L20 7 M12 11 L12 20", "stroke": "line"}, {"d": "M4 11.5 L12 15.5 L20 11.5 M8 9 L8 18 M16 9 L16 18", "stroke": "accent", "width": 1}, {"d": "M12 3 L12 5.4 M20 7 L15.2 7 M12 11 L12 8.6 M4 7 L8.8 7", "stroke": "accent", "width": 1}, {"d": "M8.8 7 A3.2 1.6 0 1 0 15.2 7 A3.2 1.6 0 1 0 8.8 7 Z", "stroke": "warm", "width": 1.5}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"rect": [3, 3, 8, 8], "fill": "soft", "stroke": "line"}, {"rect": [13, 3, 8, 8], "fill": "soft", "stroke": "line"}, {"rect": [3, 13, 8, 8], "fill": "soft", "stroke": "line"}, {"d": "M13 14 L21 13 L20 21 L14 19 Z", "fill": "warm", "stroke": "line"}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M4 3 L12 3 L16 7 L16 21 L4 21 Z", "fill": "soft", "stroke": "line"}, {"d": "M12 3 L12 7 L16 7", "stroke": "line"}, {"d": "M7 17 L7 14 M10 17 L10 11 M13 17 L13 13", "stroke": "accent", "width": 2}, {"d": "M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47", "stroke": "accent", "width": 1.5}]}
+106 -7
View File
@@ -85,6 +85,7 @@
//! | tet4 | C3D4 (H) | | | |
//! | tet10 | C3D10 (H) | | | |
//! | wedge6 | C3D6 (H) | | | |
//! | pyramid5 | C3D5 (H) | | | |
//! | hex8 | C3D8, C3D8R, C3D8I (H) | | | |
//! | hex20 | C3D20, C3D20R (H) | | | |
//! | tri3, quad4 | | S3 / S3R, S4 / S4R | | |
@@ -148,7 +149,7 @@ use std::fmt::Write as _;
use fieldcraft_model::{
BeamProfile, BoundaryCondition, ContactSurfaces, CouplingKind, Elastic, Element, ElementKind, Hardening, Hyperelastic, InitialPenetration,
Integration, Load, Model, OutputVariable, Plastic, Procedure, Resolved, Section, SectionKind, Step, Symmetry, Target,
Integration, Load, Model, OutputVariable, Plastic, Procedure, Resolved, Section, SectionKind, Step, Target,
};
use crate::{Error, Result, unsupported};
@@ -193,6 +194,7 @@ pub fn write_resolved(model: &Model, r: &Resolved) -> Result<String> {
d.couplings(model, r);
d.contacts(model, r)?;
d.ties(model)?;
d.equations(model, r)?;
d.steps(model, r)?;
Ok(d.out)
}
@@ -230,11 +232,12 @@ pub fn element_type(kind: ElementKind, section: &Section) -> std::result::Result
(S::Solid {}, _, true, true) => return Err("incompatible modes with reduced integration".into()),
(S::Solid {}, _, _, true) => return Err(format!("incompatible modes on {} elements (hex8 only)", kind.name())),
(S::Solid {}, K::Tet4, ..) => "C3D4",
(S::Solid {}, K::Tet10 | K::Wedge6, true, _) => {
(S::Solid {}, K::Tet10 | K::Wedge6 | K::Pyramid5, true, _) => {
return Err(format!("reduced integration of {} elements (Abaqus has no reduced-integration variant)", kind.name()));
}
(S::Solid {}, K::Tet10, ..) => "C3D10",
(S::Solid {}, K::Wedge6, ..) => "C3D6",
(S::Solid {}, K::Pyramid5, ..) => "C3D5",
(S::Solid {}, K::Hex8, r, _) => ["C3D8", "C3D8R"][usize::from(r)],
(S::Solid {}, K::Hex20, r, _) => ["C3D20", "C3D20R"][usize::from(r)],
(S::Shell { .. }, K::Tri3, r, _) => ["S3", "S3R"][usize::from(r)],
@@ -370,9 +373,36 @@ fn check_expressible(model: &Model) -> Result<()> {
bad.insert(format!("coupling `{name}`: an Abaqus distributing coupling always couples translations 1-3"));
}
}
for name in inclined_symmetries(model) {
if !model.steps().iter().all(|s| s.boundary_conditions.contains(name)) {
bad.insert(format!(
"boundary condition `{name}`: a symmetry plane not normal to X, Y or Z active in some steps only (it is written as `*EQUATION`, which is model data and holds in every step)"
));
}
}
unsupported(FORMAT, bad)
}
/// The symmetry conditions on inclined planes some step uses: linear equations rather than held
/// degrees of freedom.
fn inclined_symmetries(model: &Model) -> BTreeSet<&String> {
model
.boundary_conditions()
.iter()
.filter(|(n, bc)| {
matches!(bc, BoundaryCondition::Symmetry { plane, .. } if plane.axis().is_none()) && !model.boundary_condition_users(n).is_empty()
})
.map(|(n, _)| n)
.collect()
}
/// The boundary conditions of `names` that `*BOUNDARY` writes (inclined symmetry planes are
/// `*EQUATION`s).
fn held_conditions(model: &Model, names: &BTreeSet<String>) -> BTreeSet<String> {
let inclined = inclined_symmetries(model);
names.iter().filter(|n| !inclined.contains(n)).cloned().collect()
}
/// The property of a group of discrete elements.
enum DiscreteProperty {
Mass(f64),
@@ -1076,10 +1106,12 @@ impl Deck {
match bc {
BoundaryCondition::Fixed { .. } => self.line(&format!("{target}, ENCASTRE")),
BoundaryCondition::Symmetry { plane, .. } => {
let s = match plane {
Symmetry::X => "XSYMM",
Symmetry::Y => "YSYMM",
Symmetry::Z => "ZSYMM",
let s = match plane.axis() {
Some(0) => "XSYMM",
Some(1) => "YSYMM",
Some(2) => "ZSYMM",
// An inclined plane is an `*EQUATION` (model data), never here.
_ => continue,
};
self.line(&format!("{target}, {s}"));
}
@@ -1097,8 +1129,74 @@ impl Deck {
Ok(())
}
/// The inclined symmetry planes: one `*EQUATION` per node and held direction (n · u = 0, and
/// the rotations about the plane's axes at shell and beam nodes), whose first term Abaqus
/// eliminates; it is chosen among the degrees of freedom no boundary condition, coupling
/// or tie constrains ([`crate::symmetry_equations`]).
fn equations(&mut self, model: &Model, r: &Resolved) -> Result<()> {
let inclined = inclined_symmetries(model);
if inclined.is_empty() {
return Ok(());
}
let steps = model.steps();
// Per node and degree of freedom: the value it is held at in each step that holds it.
let mut held: BTreeMap<(u32, u8), Vec<f64>> = BTreeMap::new();
for step in steps {
for name in held_conditions(model, &step.boundary_conditions) {
let Some(bc) = model.boundary_condition(&name) else { continue };
for &node in r.target_nodes(bc.target()) {
for dof in bc.dofs().list() {
let v = if matches!(step.procedure, Procedure::Frequency { .. } | Procedure::Buckle { .. }) {
0.0
} else {
bc.value(dof).unwrap_or(0.0)
};
held.entry((node, dof)).or_default().push(v);
}
}
}
}
let mut dependent: BTreeSet<(u32, u8)> = BTreeSet::new();
for c in model.couplings().values().filter(|c| c.kind == CouplingKind::Kinematic) {
for &node in r.sets.get(&c.set).map(|s| s.nodes.as_slice()).unwrap_or_default() {
dependent.extend(c.dofs.list().into_iter().map(|d| (node, d)));
}
}
for t in model.ties().values() {
for &node in r.sets.get(&t.secondary).map(|s| s.nodes.as_slice()).unwrap_or_default() {
dependent.extend((1..=6).map(|d| (node, d)));
}
}
let state = |node: u32, dof: u8| {
if dependent.contains(&(node, dof)) {
return crate::Held::Sometimes;
}
match held.get(&(node, dof)) {
None => crate::Held::Free,
Some(v) if v.len() == steps.len() && v.iter().all(|x| *x == v[0]) => crate::Held::Always(v[0]),
Some(_) => crate::Held::Sometimes,
}
};
let mut problems = BTreeSet::new();
let equations = crate::symmetry_equations(model, r, inclined, &model.rotating_nodes(), state, &mut problems);
unsupported(FORMAT, problems)?;
self.comment("Symmetry planes not normal to X, Y or Z: per node, no motion along the normal (and no rotation about the plane's axes)");
for e in &equations {
self.line("*EQUATION");
self.line(&e.terms.len().to_string());
let mut fields = Vec::new();
for (dof, c) in &e.terms {
fields.push(format!("{}, {dof}", e.node));
fields.push(number(*c)?);
}
self.line(&fields.join(", "));
}
Ok(())
}
/// Boundary conditions of a step against the state it starts from.
fn step_boundaries(&mut self, model: &Model, r: &Resolved, base: &BTreeSet<String>, active: &BTreeSet<String>) -> Result<()> {
let (base, active) = (&held_conditions(model, base), &held_conditions(model, active));
if active == base {
self.comment("boundary conditions: as in the base state");
Ok(())
@@ -1188,7 +1286,8 @@ impl Deck {
BTreeSet::new()
} else {
let in_every_step = |n: &String| steps.iter().all(|s| s.boundary_conditions.contains(n));
model.boundary_conditions().iter().filter(|(n, bc)| holds_at_zero(bc) && in_every_step(n)).map(|(n, _)| n.clone()).collect()
let all = model.boundary_conditions().iter().filter(|(n, bc)| holds_at_zero(bc) && in_every_step(n)).map(|(n, _)| n.clone()).collect();
held_conditions(model, &all)
};
if !initial.is_empty() {
self.comment("Initial state: boundary conditions active in every step");
+115 -1
View File
@@ -16,7 +16,7 @@ pub mod nastran;
use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_model::nodal::edge_pressure;
use fieldcraft_model::{Diagnostic, Load, Model, Resolved, ResolvedDirection};
use fieldcraft_model::{BoundaryCondition, Diagnostic, Load, Model, Resolved, ResolvedDirection};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum Error {
@@ -176,6 +176,120 @@ pub fn edge_pressure_loads<'a>(model: &Model, r: &Resolved, active: impl IntoIte
Ok(out)
}
/// How a degree of freedom is constrained apart from the inclined symmetry planes, for choosing
/// the dependent one of a plane's equation (formats eliminate it, so it may carry no other
/// constraint).
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Held {
Free,
/// Held at this value wherever the equation applies: it cannot depend on the others, and an
/// equation of held ones alone is redundant when the values satisfy it.
Always(f64),
/// Held in some steps only, or made dependent by a coupling or tie: it cannot be dependent,
/// nor can the equation be dropped.
Sometimes,
}
/// A linear equation Σ cⱼ u(node, dofⱼ) = 0 of an inclined symmetry plane, dependent degree of
/// freedom first.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Equation {
pub node: u32,
pub terms: Vec<(u8, f64)>,
}
/// A coefficient below this fraction of its row's largest is round-off of the elimination.
const NEGLIGIBLE: f64 = 1e-12;
/// The equations of the inclined symmetry conditions `names` (others are skipped), node by node
/// in label order: n · u = 0 and, at a node in `rotating`, t₁ · θ = 0 and t₂ · θ = 0 for the
/// plane's in-plane axes ([`fieldcraft_model::Symmetry`]). The rows of one node (several planes
/// meet at an edge) are reduced against each other, so each has a dependent degree of freedom
/// of its own: the largest coefficient among those `held` says are free. A row of held degrees
/// of freedom only is left out when their values satisfy it; otherwise it is a problem, as is a
/// row with none free that cannot be left out.
pub(crate) fn symmetry_equations<'a>(
model: &Model,
r: &Resolved,
names: impl IntoIterator<Item = &'a String>,
rotating: &BTreeSet<u32>,
held: impl Fn(u32, u8) -> Held,
problems: &mut BTreeSet<String>,
) -> Vec<Equation> {
let mut rows: BTreeMap<u32, Vec<(&str, [f64; 6])>> = BTreeMap::new();
let mut seen = BTreeSet::new();
for name in names {
if !seen.insert(name.as_str()) {
continue;
}
let Some(BoundaryCondition::Symmetry { target, plane }) = model.boundary_condition(name) else { continue };
let (Some(n), Some([t1, t2])) = (plane.inclined(), plane.in_plane()) else { continue };
for &node in r.target_nodes(target) {
let at = rows.entry(node).or_default();
at.push((name, [n[0], n[1], n[2], 0.0, 0.0, 0.0]));
if rotating.contains(&node) || target.reference_point().is_some() {
at.push((name, [0.0, 0.0, 0.0, t1[0], t1[1], t1[2]]));
at.push((name, [0.0, 0.0, 0.0, t2[0], t2[1], t2[2]]));
}
}
}
let mut out = Vec::new();
for (node, rows) in rows {
// The equations of this node so far: (dependent index, coefficients).
let mut done: Vec<(usize, [f64; 6])> = Vec::new();
for (name, mut c) in rows {
let scale = c.iter().fold(0.0f64, |m, v| m.max(v.abs()));
for (d, e) in &done {
let f = c[*d] / e[*d];
for k in 0..6 {
c[k] -= f * e[k];
}
c[*d] = 0.0;
}
for v in &mut c {
if v.abs() <= NEGLIGIBLE * scale {
*v = 0.0;
}
}
if c.iter().all(|v| *v == 0.0) {
continue;
}
let dof = |k: usize| k as u8 + 1;
let pivot = (0..6).filter(|&k| c[k] != 0.0 && held(node, dof(k)) == Held::Free).fold(None, |best: Option<usize>, k| match best {
Some(b) if c[b].abs() >= c[k].abs() => Some(b),
_ => Some(k),
});
let Some(pivot) = pivot else {
let mut residual = 0.0;
let mut size = 0.0;
let mut always = true;
for k in (0..6).filter(|&k| c[k] != 0.0) {
match held(node, dof(k)) {
Held::Always(v) => {
residual += c[k] * v;
size += (c[k] * v).abs();
}
_ => always = false,
}
}
if !always {
problems.insert(format!(
"boundary condition `{name}`: at node {node} every degree of freedom the inclined symmetry plane relates is held in some steps only or follows a coupling or tie, so none can be eliminated by its equation"
));
} else if residual.abs() > 1e-9 * size {
problems.insert(format!("boundary condition `{name}`: node {node} is held off the inclined symmetry plane"));
}
continue;
};
let mut terms = vec![(dof(pivot), c[pivot])];
terms.extend((0..6).filter(|&k| k != pivot && c[k] != 0.0).map(|k| (dof(k), c[k])));
out.push(Equation { node, terms });
done.push((pivot, c));
}
}
out
}
/// The global degree of freedom (1–6) a spring or dashpot direction acts along, when it is one
/// (a vector or line along an axis counts: the sign does not matter to a spring).
pub(crate) fn axis_dof(d: ResolvedDirection) -> Option<u8> {
+6 -2
View File
@@ -36,7 +36,7 @@ impl Keyword {
pub(crate) fn of(kind: ElementKind) -> Keyword {
match kind {
ElementKind::Hex20 => Keyword::SolidH20,
ElementKind::Tet4 | ElementKind::Tet10 | ElementKind::Wedge6 | ElementKind::Hex8 => Keyword::Solid,
ElementKind::Tet4 | ElementKind::Tet10 | ElementKind::Wedge6 | ElementKind::Pyramid5 | ElementKind::Hex8 => Keyword::Solid,
ElementKind::Tri3 | ElementKind::Tri6 | ElementKind::Quad4 | ElementKind::Quad8 => Keyword::Shell,
ElementKind::Rod2 | ElementKind::Rod3 | ElementKind::Beam2 => Keyword::Beam,
}
@@ -64,6 +64,8 @@ pub(crate) fn order(kind: ElementKind) -> &'static [usize] {
ElementKind::Tet4 => &[0, 1, 2, 3, 3, 3, 3, 3],
ElementKind::Tet10 => &[0, 1, 2, 3, 4, 5, 6, 8, 9, 7],
ElementKind::Wedge6 => &[1, 0, 3, 4, 2, 2, 5, 5],
// The degenerate hexahedron: the base, then the apex four times.
ElementKind::Pyramid5 => &[0, 1, 2, 3, 4, 4, 4, 4],
ElementKind::Hex8 => &[0, 1, 2, 3, 4, 5, 6, 7],
ElementKind::Hex20 => &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19],
}
@@ -95,7 +97,7 @@ pub(crate) struct Elform {
///
/// Solids: hex8 2 (eight-point), reduced 1 (one-point) with hourglass control, incompatible
/// modes −18 (eight-point enhanced strain with incompatible modes); wedges 2 (as degenerate
/// hexahedra), reduced 15 (two-point pentahedron); tet4 10 (one point, exact for a linear
/// hexahedra), reduced 15 (two-point pentahedron); pyramids 2 (as degenerate hexahedra); tet4 10 (one point, exact for a linear
/// tetrahedron), hybrid 13 (nodal pressure); tet10 16; hex20 23. Shells: quad4 16 (fully
/// integrated), reduced 2 (one-point) with hourglass control; tri3 4 (C0 triangle); quad8 23;
/// tri6 24. Rods: 3 (truss). 2D (in the XY plane): plane stress 12, plane strain 13, both one-point
@@ -122,6 +124,8 @@ pub(crate) fn elform(kind: ElementKind, section: &SectionKind, f: Formulation, s
ElementKind::Hex8 => plain(2),
ElementKind::Wedge6 if reduced => plain(15),
ElementKind::Wedge6 => plain(2),
ElementKind::Pyramid5 if reduced => no("there is no reduced-integration form of"),
ElementKind::Pyramid5 => plain(2),
ElementKind::Tet4 => plain(10),
_ if reduced && kind.is_quadratic() => no("there is no reduced-integration form of"),
ElementKind::Tet10 => plain(16),
+20 -1
View File
@@ -40,7 +40,8 @@
//! LS-DYNA's defaults; a step without nonlinear geometry and hyperelastic materials in nonlinear
//! steps are refused); boundary conditions
//! as `*BOUNDARY_SPC_*_ID` (named) and `*BOUNDARY_PRESCRIBED_MOTION_*_ID` for non-zero
//! displacements; forces, moments and tractions as `*LOAD_NODE_SET`/`_POINT` from the
//! displacements, a symmetry plane not normal to X, Y or Z as `*BOUNDARY_SPC_*_ID` in a
//! `*DEFINE_COORDINATE_SYSTEM` whose x axis is the plane's normal; forces, moments and tractions as `*LOAD_NODE_SET`/`_POINT` from the
//! resolved nodal values; pressure as `*LOAD_SEGMENT_SET`; gravity as `*LOAD_BODY_X/Y/Z`
//! (with `*LOAD_BODY_PARTS` when it acts on a set); output as `*DATABASE_BINARY_D3PLOT`,
//! `*DATABASE_SPCFORC` and `*DATABASE_EXTENT_BINARY`.
@@ -1048,6 +1049,24 @@ fn boundary_condition(
(r.reference_point_nodes.get(p).copied().ok_or_else(|| broken(format!("boundary condition `{name}`: reference point `{p}`")))?, "NODE")
}
};
// A symmetry plane not normal to an axis: held in a coordinate system whose x axis is its
// normal and whose xy plane holds its first in-plane axis (translation x, rotations y and z).
if let BoundaryCondition::Symmetry { plane, .. } = bc
&& let (Some(n), Some([t1, _])) = (plane.inclined(), plane.in_plane())
{
*constraint_id = constraint_id.saturating_add(1);
let cid = *constraint_id;
d.line("*DEFINE_COORDINATE_SYSTEM");
d.header(&["cid", "xo", "yo", "zo", "xl", "yl", "zl", "cidl"]);
d.card([Field::from(cid), Field::from(0.0), Field::from(0.0), Field::from(0.0), Field::from(n[0]), Field::from(n[1]), Field::from(n[2])]);
d.header(&["xp", "yp", "zp"]);
d.card([Field::from(t1[0]), Field::from(t1[1]), Field::from(t1[2])]);
d.line(&format!("*BOUNDARY_SPC_{suffix}_ID"));
id_card(d, constraint_id, name);
d.header(&["nsid", "cid", "dofx", "dofy", "dofz", "dofrx", "dofry", "dofrz"]);
d.card([Field::from(entity), Field::from(cid)].into_iter().chain([1, 0, 0, 0, 1, 1].map(|v: i32| Field::from(v))));
return Ok(());
}
let dofs = bc.dofs();
// Held degrees of freedom (in a frequency step every constrained one is held).
let held: Vec<u8> = dofs.list().into_iter().filter(|dof| !is_static || bc.value(*dof).is_none_or(|v| v == 0.0)).collect();
+48 -3
View File
@@ -6,7 +6,7 @@
//! data (the entries). What the model holds becomes:
//!
//! - nodes and reference points: `GRID`;
//! - solids: `CTETRA` (tet4, tet10), `CPENTA` (wedge6), `CHEXA` (hex8, hex20) with `PSOLID`;
//! - solids: `CTETRA` (tet4, tet10), `CPENTA` (wedge6), `CPYRAM` (pyramid5), `CHEXA` (hex8, hex20) with `PSOLID`;
//! shells: `CTRIA3`, `CQUAD4`, `CTRIA6`, `CQUAD8` with `PSHELL`; beams: `CBEAM` with `PBEAML`;
//! rods: `CROD` with `PROD`. Nastran numbers a 20-node hexahedron's mid-side nodes bottom
//! edges, vertical edges, top edges, where the model has bottom, top, vertical
@@ -22,6 +22,9 @@
//! the basic X axis through a `CORD2R`;
//! - boundary conditions: per step, one constraint set of `SPC1` (held degrees of freedom) and
//! `SPC` (prescribed values); in a modes step every constrained degree of freedom is held;
//! symmetry about a plane not normal to X, Y or Z: one `MPC` set per step, an equation per node
//! and held direction (n · u = 0, and t · θ = 0 about the plane's two axes at shell and beam
//! grids), its dependent component one no `SPC` or rigid element constrains;
//! - forces, moments and tractions: `FORCE` and `MOMENT` at the nodal values the model resolves,
//! so every writer and the solver share one distribution; pressure: `PLOAD4` per element face;
//! gravity: `GRAV`; a step with several loads combines them with `LOAD`;
@@ -110,6 +113,7 @@ pub fn element_card(kind: ElementKind) -> &'static str {
ElementKind::Quad8 => "CQUAD8",
ElementKind::Tet4 | ElementKind::Tet10 => "CTETRA",
ElementKind::Wedge6 => "CPENTA",
ElementKind::Pyramid5 => "CPYRAM",
ElementKind::Hex8 | ElementKind::Hex20 => "CHEXA",
}
}
@@ -241,6 +245,8 @@ type Constraints = BTreeMap<u32, [Option<f64>; 6]>;
struct Subcase<'m> {
step: &'m Step,
spc: Option<u32>,
/// The equations of its inclined symmetry planes.
mpc: Option<u32>,
load: Option<u32>,
method: Option<u32>,
}
@@ -269,6 +275,8 @@ struct Deck<'m> {
dependent: BTreeMap<u32, (Dofs, &'m str)>,
/// Constraint sets by id.
spc_sets: Vec<(u32, Constraints)>,
/// Multipoint constraint sets (inclined symmetry planes) by id.
mpc_sets: Vec<(u32, Vec<crate::Equation>)>,
/// Load name → load set id.
load_sids: BTreeMap<&'m str, u32>,
/// Combinations: (id, load set ids).
@@ -310,7 +318,7 @@ impl<'m> Deck<'m> {
model,
r,
sol,
subcases: steps.into_iter().map(|step| Subcase { step, spc: None, load: None, method: None }).collect(),
subcases: steps.into_iter().map(|step| Subcase { step, spc: None, mpc: None, load: None, method: None }).collect(),
problems,
next_sid: 1,
materials: BTreeMap::new(),
@@ -321,6 +329,7 @@ impl<'m> Deck<'m> {
discrete: Vec::new(),
dependent: BTreeMap::new(),
spc_sets: Vec::new(),
mpc_sets: Vec::new(),
load_sids: BTreeMap::new(),
combinations: Vec::new(),
}
@@ -587,6 +596,7 @@ impl<'m> Deck<'m> {
/// One constraint set per distinct step constraint, checked against coupled nodes.
fn plan_constraints(&mut self) -> Result<()> {
let (model, r) = (self.model, self.r);
let rotating = model.rotating_nodes();
// Conflicts, one problem per pair of definitions: the nodes they meet on.
let mut clashes: BTreeMap<(&str, &str, &str), BTreeSet<u32>> = BTreeMap::new();
let mut on_dependent: BTreeMap<(&str, &str), BTreeSet<u32>> = BTreeMap::new();
@@ -598,6 +608,10 @@ impl<'m> Deck<'m> {
for name in &step.boundary_conditions {
let Some(bc) = model.boundary_condition(name) else { continue };
let dofs = bc.dofs();
// Inclined symmetry planes hold no global degree of freedom: they are equations.
if dofs.is_empty() {
continue;
}
// In a modes step every constrained degree of freedom is held.
let values: Vec<(u8, f64)> = dofs.list().into_iter().filter_map(|d| Some((d, if modes { 0.0 } else { bc.value(d)? }))).collect();
for &node in r.target_nodes(bc.target()) {
@@ -622,6 +636,19 @@ impl<'m> Deck<'m> {
}
}
}
let dependent = &self.dependent;
let held = |node: u32, dof: u8| match (
dependent.get(&node),
set.get(&node).and_then(|v| v.get(usize::from(dof).checked_sub(1)?).copied().flatten()),
) {
(Some((d, _)), _) if d.contains(dof) => crate::Held::Sometimes,
(_, Some(v)) => crate::Held::Always(v),
_ => crate::Held::Free,
};
let equations = crate::symmetry_equations(model, r, &step.boundary_conditions, &rotating, held, &mut self.problems);
if !equations.is_empty() {
sub.mpc = Some(intern(&mut self.mpc_sets, &mut self.next_sid, equations)?);
}
if !set.is_empty() {
sub.spc = Some(intern(&mut self.spc_sets, &mut self.next_sid, set)?);
}
@@ -707,7 +734,7 @@ impl<'m> Deck<'m> {
let _ = writeln!(out, "SUBCASE {}", i + 1);
let title: String = format!(" SUBTITLE = {}", sub.step.name).chars().take(72).collect();
let _ = writeln!(out, "{title}");
for (key, sid) in [("SPC", sub.spc), ("LOAD", sub.load), ("METHOD", sub.method)] {
for (key, sid) in [("SPC", sub.spc), ("MPC", sub.mpc), ("LOAD", sub.load), ("METHOD", sub.method)] {
if let Some(sid) = sid {
let _ = writeln!(out, " {key} = {sid}");
}
@@ -715,6 +742,7 @@ impl<'m> Deck<'m> {
for v in &sub.step.output {
let request = match v {
OutputVariable::Displacement => "DISPLACEMENT",
OutputVariable::ReactionForce if sub.mpc.is_some() => "SPCFORCES = ALL\n MPCFORCES",
OutputVariable::ReactionForce => "SPCFORCES",
OutputVariable::Stress => "STRESS",
OutputVariable::Strain => "STRAIN",
@@ -892,6 +920,23 @@ impl<'m> Deck<'m> {
Card::new("SPC").int(*sid).int(node).int(component(Dofs::from_bits(mask))).real(value).write(out)?;
}
}
for (sid, equations) in &self.mpc_sets {
for sub in self.subcases.iter().filter(|s| s.mpc == Some(*sid)) {
comment(out, &format!("step {}: inclined symmetry planes, MPC {sid} (the first grid component is dependent)", sub.step.name));
}
for e in equations {
// SID, then (G, C, A) triples: two on the first line (its last field blank), two
// per continuation after a blank first field.
let mut card = Card::new("MPC").int(*sid);
for (i, (dof, c)) in e.terms.iter().enumerate() {
if i % 2 == 0 && i > 0 {
card = card.blank().blank();
}
card = card.int(e.node).int(i64::from(*dof)).real(*c);
}
card.write(out)?;
}
}
Ok(())
}
+2 -1
View File
@@ -461,10 +461,11 @@ fn written_connectivity_has_a_positive_jacobian_in_abaqus_conventions() {
#[test]
fn model_faces_are_abaqus_faces() {
// Each face's node positions (1-based, Abaqus element library, our transcription).
let table: [(ElementKind, &[&[usize]]); 3] = [
let table: [(ElementKind, &[&[usize]]); 4] = [
(ElementKind::Hex8, &[&[1, 2, 3, 4], &[5, 8, 7, 6], &[1, 5, 6, 2], &[2, 6, 7, 3], &[3, 7, 8, 4], &[4, 8, 5, 1]]),
(ElementKind::Tet4, &[&[1, 2, 3], &[1, 4, 2], &[2, 4, 3], &[3, 4, 1]]),
(ElementKind::Wedge6, &[&[1, 2, 3], &[4, 6, 5], &[1, 4, 5, 2], &[2, 5, 6, 3], &[3, 6, 4, 1]]),
(ElementKind::Pyramid5, &[&[1, 2, 3, 4], &[1, 5, 2], &[2, 5, 3], &[3, 5, 4], &[4, 5, 1]]),
];
for (kind, faces) in table {
assert_eq!(kind.faces().len(), faces.len());
+193
View File
@@ -0,0 +1,193 @@
//! Symmetry about a plane not normal to X, Y or Z in the three decks (#242): Abaqus `*EQUATION`s,
//! Nastran `MPC` sets and an LS-DYNA `*BOUNDARY_SPC` in a local coordinate system, on a small
//! shell plate of our own whose diagonal x = y is the plane.
use fieldcraft_decks::{Error, abaqus, lsdyna, nastran};
use fieldcraft_model::{
BoundaryCondition, Elastic, ElementFace, ElementKind, Formulation, Load, Material, Model, Procedure, Section, SectionKind, Set, Step, Symmetry,
Target,
};
/// A 2 × 2 quad4 shell plate in the XY plane (nodes 1–9, x and y in 0, 1, 2), clamped on x = 0,
/// the inclined plane (normal (1, −1, 0)) on the diagonal nodes 1, 5 and 9, under pressure;
/// steps `Load` (static) and `Modes`, `in_modes` saying whether the plane is active in both.
fn plate(in_modes: bool) -> Model {
let mut m = Model::default();
for j in 0..3u32 {
for i in 0..3u32 {
m.set_node(1 + i + 3 * j, [f64::from(i), f64::from(j), 0.0]).unwrap();
}
}
for (e, n) in [(1, [1, 2, 5, 4]), (2, [2, 3, 6, 5]), (3, [4, 5, 8, 7]), (4, [5, 6, 9, 8])] {
m.set_element(e, ElementKind::Quad4, n.to_vec()).unwrap();
}
m.add_material(
"Steel",
Material { elastic: Some(Elastic::Isotropic { young: 210e3, poisson: 0.3 }), density: Some(7.85e-9), ..Material::default() },
)
.unwrap();
m.add_set("all", Set { elements: vec![1, 2, 3, 4], ..Set::default() }).unwrap();
let kind = SectionKind::Shell { thickness: 0.1, offset: 0.0, points: 5 };
m.add_section("plate", Section { set: "all".into(), material: "Steel".into(), kind, formulation: Formulation::default() }).unwrap();
m.add_set("edge", Set { nodes: vec![1, 4, 7], ..Set::default() }).unwrap();
m.add_set("diagonal", Set { nodes: vec![1, 5, 9], ..Set::default() }).unwrap();
let faces = (1..=4).map(|element| ElementFace { element, face: 1 }).collect();
m.add_set("top", Set { faces, ..Set::default() }).unwrap();
m.add_boundary_condition("clamp", BoundaryCondition::Fixed { target: Target::Set("edge".into()) }).unwrap();
let plane = Symmetry::from_normal([1.0, -1.0, 0.0]).unwrap();
m.add_boundary_condition("sym", BoundaryCondition::Symmetry { target: Target::Set("diagonal".into()), plane }).unwrap();
m.add_load("q", Load::Pressure { set: "top".into(), magnitude: 0.01 }).unwrap();
let mut load = Step::new("Load", Procedure::Static {});
load.boundary_conditions = ["clamp", "sym"].map(String::from).into();
load.loads = ["q".to_string()].into();
m.add_step(load, None).unwrap();
let mut modes = Step::new("Modes", Procedure::Frequency { modes: 3, min_hz: None, max_hz: None });
modes.boundary_conditions = if in_modes { ["clamp", "sym"].map(String::from).into() } else { ["clamp".to_string()].into() };
m.add_step(modes, None).unwrap();
m
}
/// A Nastran real: an exponent may come without `E`.
fn real(s: &str) -> f64 {
let split = s.char_indices().skip(1).find(|(i, c)| (*c == '+' || *c == '-') && !s[..*i].ends_with(['E', 'e']));
match split {
Some((i, _)) => format!("{}E{}", &s[..i], &s[i..]).parse().unwrap(),
None => s.parse().unwrap(),
}
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
(0..3).map(|k| a[k] * b[k]).sum()
}
/// Every equation is one of the plane's at a free node: its terms on one node, its coefficients
/// along the normal (translations) or along an in-plane axis (rotations), unit length; nodes 5
/// and 9 get three each (n · u, t₁ · θ, t₂ · θ); node 1 is clamped, so its equations are left out;
/// no dependent degree of freedom repeats.
fn check(equations: &[(u32, Vec<(u8, f64)>)]) {
let n = [1.0 / 2f64.sqrt(), -1.0 / 2f64.sqrt(), 0.0];
let mut dependents = std::collections::BTreeSet::new();
let mut per_node = std::collections::BTreeMap::<u32, usize>::new();
for (node, terms) in equations {
assert!(dependents.insert((*node, terms[0].0)), "{node} {terms:?}");
*per_node.entry(*node).or_default() += 1;
let mut v = [0.0; 3];
let rotation = terms[0].0 > 3;
for (dof, c) in terms {
assert_eq!(*dof > 3, rotation, "{terms:?}");
v[usize::from((dof - 1) % 3)] = *c;
}
assert!((dot(v, v) - 1.0).abs() < 1e-12, "{terms:?}");
if rotation {
assert!(dot(v, n).abs() < 1e-12, "{terms:?}");
} else {
assert!((dot(v, n).abs() - 1.0).abs() < 1e-12, "{terms:?}");
}
}
assert_eq!(per_node, [(5, 3), (9, 3)].into());
}
#[test]
fn abaqus_writes_equations_in_the_model_data() {
let deck = abaqus::write(&plate(true)).unwrap();
let lines: Vec<&str> = deck.lines().collect();
let mut equations = Vec::new();
for (i, l) in lines.iter().enumerate() {
if *l == "*EQUATION" {
let count: usize = lines[i + 1].trim().parse().unwrap();
let f: Vec<&str> = lines[i + 2].split(',').map(str::trim).collect();
assert_eq!(f.len(), 3 * count, "{}", lines[i + 2]);
let node: u32 = f[0].parse().unwrap();
let terms = f.chunks(3).map(|t| (t[1].parse().unwrap(), t[2].parse().unwrap())).collect();
assert!(f.chunks(3).all(|t| t[0] == f[0]));
equations.push((node, terms));
}
}
check(&equations);
// Model data, before the first step; the plane is no `*BOUNDARY` line.
let first_step = deck.find("*STEP").unwrap();
assert!(deck.rfind("*EQUATION").unwrap() < first_step);
assert!(!deck.contains("SYMM"), "{deck}");
let e = abaqus::write(&plate(false)).unwrap_err();
assert!(matches!(&e, Error::Unsupported { what, .. } if what.contains("boundary condition `sym`") && what.contains("some steps only")), "{e}");
}
#[test]
fn nastran_writes_one_mpc_set_per_step() {
// A static deck and a modes deck (one solution per run).
for (steps, sol) in [("Load", 101), ("Modes", 103)] {
let m = fieldcraft_decks::with_steps(&plate(true), &[steps.to_string()]).unwrap();
let deck = nastran::write(&m).unwrap();
assert!(deck.contains(&format!("SOL {sol}")));
let sid = deck.lines().find_map(|l| l.trim().strip_prefix("MPC = ")).unwrap().to_string();
// MPC entries: SID, then (grid, component, coefficient) triples; the continuation after
// two triples starts with a blank field. Large-field lines are 16 columns per field.
let mut equations = Vec::new();
let mut fields: Vec<String> = Vec::new();
let flush = |fields: &mut Vec<String>, equations: &mut Vec<(u32, Vec<(u8, f64)>)>| {
if fields.is_empty() {
return;
}
assert_eq!(fields[0], sid);
let data: Vec<&String> = fields[1..].iter().enumerate().filter(|(i, _)| !matches!(i % 8, 6 | 7)).map(|(_, f)| f).collect();
let triples: Vec<(u32, u8, f64)> = data
.chunks(3)
.filter(|t| t.len() == 3 && !t[0].is_empty())
.map(|t| (t[0].parse().unwrap(), t[1].parse().unwrap(), real(t[2])))
.collect();
assert!(triples.iter().all(|t| t.0 == triples[0].0));
equations.push((triples[0].0, triples.iter().map(|t| (t.1, t.2)).collect()));
fields.clear();
};
for line in deck.lines() {
let (head, rest) = line.split_at(8.min(line.len()));
let row: Vec<String> = rest.as_bytes().chunks(16).map(|c| String::from_utf8_lossy(c).trim().to_string()).collect();
let mut row = row;
row.resize(4, String::new());
match head.trim() {
"MPC*" => {
flush(&mut fields, &mut equations);
fields.extend(row);
}
"*" if !fields.is_empty() => fields.extend(row),
_ => flush(&mut fields, &mut equations),
}
}
flush(&mut fields, &mut equations);
check(&equations);
}
}
#[test]
fn lsdyna_holds_the_plane_in_a_local_system() {
let deck = lsdyna::write(&plate(true)).unwrap().text;
let block = |keyword: &str| -> Vec<Vec<f64>> {
let at = deck.find(keyword).unwrap();
deck[at..]
.lines()
.skip(1)
.filter(|l| !l.starts_with('$'))
.take_while(|l| !l.starts_with('*'))
.map(|l| l.as_bytes().chunks(20).filter_map(|c| String::from_utf8_lossy(c).trim().parse().ok()).collect())
.collect()
};
let system = block("*DEFINE_COORDINATE_SYSTEM");
let (cid, x, p) = (system[0][0], [system[0][4], system[0][5], system[0][6]], [system[1][0], system[1][1], system[1][2]]);
assert!((x[0] + x[1]).abs() < 1e-12 && x[2] == 0.0 && (dot(x, x) - 1.0).abs() < 1e-12, "{x:?}");
assert!(dot(x, p).abs() < 1e-12, "{p:?}");
// The SPC after the system: its id card, then nsid, cid and the held local directions.
let at = deck.find("*DEFINE_COORDINATE_SYSTEM").unwrap();
let spc = deck[at..].find("*BOUNDARY_SPC_SET_ID").unwrap() + at;
let card: Vec<f64> = deck[spc..]
.lines()
.skip(2)
.find(|l| !l.starts_with('$'))
.unwrap()
.as_bytes()
.chunks(20)
.map(|c| String::from_utf8_lossy(c).trim().parse().unwrap())
.collect();
assert_eq!(card[1], cid);
assert_eq!(&card[2..], [1.0, 0.0, 0.0, 0.0, 1.0, 1.0]);
}
+2 -2
View File
@@ -17,7 +17,7 @@ use serde_json::{Value, json};
use crate::crash::without_home;
/// The issue form.
pub const ISSUE_URL: &str = "https://github.com/jyviko/fieldcraft/issues/new";
pub const ISSUE_URL: &str = "https://github.com/tangency-labs/fieldcraft/issues/new";
/// The longest link a draft opens: GitHub refuses new-issue links much past 8 KB.
pub const MAX_URL: usize = 7000;
/// The session file a draft attaches.
@@ -329,7 +329,7 @@ mod tests {
..Default::default()
};
let d = draft(&input, "v1.2.3, testos x");
assert!(d.url.starts_with("https://github.com/jyviko/fieldcraft/issues/new?template=bug.yml&title="), "{}", d.url);
assert!(d.url.starts_with("https://github.com/tangency-labs/fieldcraft/issues/new?template=bug.yml&title="), "{}", d.url);
assert!(d.attachments.is_empty() && d.clipboard.is_none(), "nothing attached without Share my session");
let all = format!("{} {:?} {}", d.title, d.fields, decode(&d.url));
assert!(!all.contains(&home()), "no home folder: {all}");
+73 -5
View File
@@ -244,9 +244,16 @@ pub static CATALOGUE: &[Capability] = &[
Geometry,
"Inspect",
"List the open design's bodies, volumes and named faces, edges and vertices",
&["geometry.centre"],
&["geometry.centre", "geometry.features"],
),
ready("geometry.tessellate", "Tessellate", Geometry, "Inspect", "Tessellate a body into a face-tagged boundary surface"),
ready(
"geometry.check",
"Check Geometry",
Geometry,
"Inspect",
"Short edges, small and sliver faces and near faces against the element size, located",
),
ready(
"mesh.surface.check",
"Check Surface",
@@ -287,6 +294,37 @@ pub static CATALOGUE: &[Capability] = &[
"Cut bodies into cells by a datum plane; the two faces of each cut are paired by name",
&["geometry.partition.delete", "geometry.partition.suppress"],
),
covering(
"geometry.faces.merge",
"Merge Faces",
Geometry,
"Prepare",
"Mesh adjacent faces as one (virtual topology): a fillet in pieces, a face split by an imprint; the B-rep is unchanged",
&["geometry.faces.merge.delete"],
),
ready(
"geometry.face.absorb",
"Absorb Faces",
Geometry,
"Prepare",
"Absorb tiny and sliver faces into a neighbour for meshing, one picked or every one Check Geometry found",
),
covering(
"geometry.face.split",
"Split Face",
Geometry,
"Prepare",
"Split a face along a datum plane for meshing only: two faces on its surface, the cut an edge between them",
&["geometry.face.split.delete"],
),
covering(
"geometry.stitch",
"Stitch",
Geometry,
"Prepare",
"Close an imported surface's gaps under a tolerance: open points welded, T-junctions split",
&["geometry.stitch.delete"],
),
soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"),
// ---- Mesh ----
soon("mesh.size.global", "Global Size", Mesh, "Size", "M2", "One element size for the whole part"),
@@ -329,10 +367,32 @@ pub static CATALOGUE: &[Capability] = &[
"Partitioned Mesh",
Mesh,
"Structured Hex",
"One mesh of a partitioned part, a method per cell (hex, tet, or all hex as conforming blocks); cells joined across partitions",
"One mesh of a partitioned part, a method per cell (hex, tet, or all hex as conforming blocks); cells joined across partitions, tet cells to hex cells through conforming pyramids",
&["mesh.hex.blocks"],
),
ready("mesh.block", "Mesh Block", Mesh, "Structured Hex", "A mapped block of hex, wedge or quad elements between corner points"),
ready(
"mesh.hex.extrude",
"Extrude to Surface",
Mesh,
"Structured Hex",
"Hex layers from a meshed or planar section up to a curved target surface, following the side faces",
),
ready_more(
"mesh.plan",
"Mesh Plan",
Mesh,
"Structured Hex",
"A hex-meshing plan from the features that built a body (sweeps, blocks, O-grids, layers, imprints, copies); saved, editable, applied with the structured meshers",
&["mesh.plan.apply"],
),
ready_more(
"mesh.block",
"Mesh Block",
Mesh,
"Structured Hex",
"A mapped block of hex, wedge or quad elements between corner points; projection blocking (a block topology projected onto the part)",
&["mesh.hex.project"],
),
soon(
"mesh.hex.decompose",
"Decompose",
@@ -341,7 +401,6 @@ pub static CATALOGUE: &[Capability] = &[
"M5",
"Find sweepable and mappable regions of 3D parts (extruded cross-sections are already cut into blocks by Hex Mesh)",
),
soon("mesh.transition", "Transition", Mesh, "Structured Hex", "M5", "Conforming tet–hex interfaces with pyramids"),
ready(
"mesh.hex.octree",
"Octree Hex",
@@ -376,7 +435,15 @@ pub static CATALOGUE: &[Capability] = &[
soon("mesh.region.remesh", "Remesh Region", Mesh, "Edit", "M6", "Remesh a region in place; split and collapse elements"),
soon("mesh.import.orphan", "Orphan Mesh", Mesh, "Edit", "M6", "Import a mesh without geometry"),
ready("mesh.quality", "Quality", Mesh, "Verify", "Quality metrics and the certified validity check, failing elements listed"),
ready(
"mesh.quality.mark",
"Mark Bad Elements",
Mesh,
"Verify",
"Mark the elements below quality limits as a set, located and drawn in the marker colour",
),
soon("mesh.verify", "Verify Set", Mesh, "Verify", "M2", "Quality and validity per set"),
ready("mesh.export.stl", "Export STL", Mesh, "Verify", "Write the mesh's outer surface (or a set's faces) as binary STL"),
ready_more("mesh.tag", "Tag Geometry", Mesh, "Verify", "Tag elements, faces, edges and nodes with the geometry they mesh", &["mesh.tag.clear"]),
// ---- Properties ----
ready_more(
@@ -590,11 +657,12 @@ pub static CATALOGUE: &[Capability] = &[
),
ready_more("results.reactions", "Reactions", Results, "Query", "Reaction force and moment sums per set", &["results.export.csv"]),
soon("results.free_body", "Free Body", Results, "Query", "M9", "Free-body forces across a cut"),
soon("results.xy", "XY Plot", Results, "Plots", "M9", "Plot a quantity against time or another"),
ready("results.xy", "XY Plot", Results, "Plots", "Plot a quantity against the load factor, time or mode"),
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("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"),
// ---- Tools ----
+173 -30
View File
@@ -35,14 +35,14 @@ use fieldcraft_model::{ElementFace, GeometryRef, Model, Set, Tie};
use serde_json::{Value, json};
use super::hex::{Input, add_cells, add_hex, hex_body};
use super::mesh::{BodyTets, add_tets, mesh_body, quality_options};
use super::mesh::{add_tets, mesh_body, quality_options};
use crate::requirement::Requirement;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const CELLS: CommandSpec = CommandSpec {
id: "mesh.cells",
label: "Partitioned Mesh",
params: r#"{"methods": {"Body1/a": "tet", "Body1/b": "swept"} (per cell; default `default`), "default": "auto", "bodies": ["Body1/a", "Body1/b"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 | 2 (hex8 and tet4, or hex20 and tet10), "tolerance": 0.05 (chordal tolerance, mm)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one mesh of the cells, each with its method: "auto" (structured hex where a method fits, else tet), "hex" (any structured hex method), "swept", "extrude", "sweep", "revolve", "mapped" or "tet"; or "default": "blocks" (with no `methods`): every cell a mapped block, division counts assigned over all cells together, exactly mesh.hex.blocks with the same parameters. Cells on either side of a partition (faces `partition:<name>:a` and `:b`) are joined: where their element faces match node for node the nodes are merged (conforming); otherwise, as between a hex and a tet cell, they are tied: each cell's tied cut faces make one tie, `tie_<cell>` (`/` as `_`; sets `<tie>_main`, the facing cells' cut faces, and `<tie>_secondary`, the cell's own), hex cells main to tet cells and otherwise by name, so a node is a secondary node of one such tie only; the ties an earlier run made on these cells are replaced. The cells of one design body that may sweep (auto, hex, swept, extrude, revolve) are swept together first: one cross-section and one set of layers for all of them, so they share their nodes across every cut, T-junctions included, with no edge seeds (`swept_together` names the body; `not_swept_together` says why not, and those cells are meshed one by one). Reports each cell's method (and why auto fell back to tet), its `quality` (volume, smallest and mean scaled Jacobian, largest edge ratio), and each interface: `merged` with the nodes merged or shared, or `tied`"#,
params: r#"{"methods": {"Body1/a": "tet", "Body1/b": "swept"} (per cell; default `default`), "default": "auto", "bodies": ["Body1/a", "Body1/b"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 | 2 (hex8 and tet4, or hex20 and tet10), "tolerance": 0.05 (chordal tolerance, mm), "transition": "pyramid" | "tie" (how a tet cell meets hex cells; default pyramid)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one mesh of the cells, each with its method: "auto" (structured hex where a method fits, else tet), "hex" (any structured hex method), "swept", "extrude", "sweep", "revolve", "mapped" or "tet"; or "default": "blocks" (with no `methods`): every cell a mapped block, division counts assigned over all cells together, exactly mesh.hex.blocks with the same parameters. Cells on either side of a partition (faces `partition:<name>:a` and `:b`) are joined: where their element faces match node for node the nodes are merged (conforming); between a tet cell and the hex cells across its partitions, with "transition": "pyramid" (linear meshes), the tet cell is meshed after them with a pyramid (pyramid5) on every hex element face of the cut, its tetrahedra sharing the pyramids' sides, so the mesh is conforming there (`joined`: `pyramids`, with their count); where that cannot be built (a cut the hex faces do not cover exactly, quadratic elements, a pyramid that would reach through a thin cell) the cell says why in `no_pyramids` and is tied; otherwise, as between a hex and a tet cell with "transition": "tie", they are tied: each cell's tied cut faces make one tie, `tie_<cell>` (`/` as `_`; sets `<tie>_main`, the facing cells' cut faces, and `<tie>_secondary`, the cell's own), hex cells main to tet cells and otherwise by name, so a node is a secondary node of one such tie only; the ties an earlier run made on these cells are replaced. The cells of one design body that may sweep (auto, hex, swept, extrude, revolve) are swept together first: one cross-section and one set of layers for all of them, so they share their nodes across every cut, T-junctions included, with no edge seeds (`swept_together` names the body; `not_swept_together` says why not, and those cells are meshed one by one). Reports each cell's method (and why auto fell back to tet), its `quality` (volume, smallest and mean scaled Jacobian, largest edge ratio), and each interface: `merged` with the nodes merged or shared, or `tied` (with `why` when both cells are of one design body: the tet cell and why auto fell back, or why the body's cells were not swept together)"#,
form: Some(super::forms::CELLS),
kind: CommandKind::Model,
requires: &[Requirement::Design],
@@ -80,10 +80,22 @@ impl Method {
}
}
/// A meshed cell.
mod transition;
/// A meshed cell; tet cells are meshed after the hex cells are in the model, so they can meet
/// them through pyramids.
enum Meshed<'a> {
Hex(Input<'a>, HexMesh),
Tet(BodyTets),
Hex(Box<(Input<'a>, HexMesh)>),
Tet,
}
/// How a tet cell joins the hex cells across its partitions.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Transition {
/// Pyramids on the hex faces, conforming (falls back to a tie with the reason).
Pyramid,
/// A tie (dual mortar), each mesh as its method made it.
Tie,
}
fn cells(s: &mut Session, p: &Value) -> Result<Value> {
@@ -105,6 +117,11 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(EngineError::invalid("order", "must be 1 or 2")),
},
};
let transition = match p.get("transition").map(|v| v.as_str()) {
None | Some(Some("pyramid")) => Transition::Pyramid,
Some(Some("tie")) => Transition::Tie,
Some(_) => return Err(EngineError::invalid("transition", "must be \"pyramid\" or \"tie\"")),
};
let default = p.get("default").map(Method::parse).transpose()?.unwrap_or(Method::Auto);
let methods: BTreeMap<String, Method> = match p.get("methods") {
None => BTreeMap::new(),
@@ -215,20 +232,19 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
let mesh = hex_body(&hex_params, &b, m, quadratic, size)?;
Ok((b, mesh))
};
let tet = |s: &Session| mesh_body(s, &tet_params, input()?, &cdt, &tet_options, quality, true);
let (cell, mut info) = match method {
Method::Hex(m) => {
let (b, mesh) = hex(m)?;
let info = json!({"method": mesh.report.method.name()});
(Meshed::Hex(b, mesh), info)
(Meshed::Hex(Box::new((b, mesh))), info)
}
Method::Tet => (Meshed::Tet(tet(s)?), json!({"method": "tet"})),
Method::Tet => (Meshed::Tet, json!({"method": "tet"})),
Method::Auto => match hex(HexMethod::Auto) {
Ok((b, mesh)) => {
let info = json!({"method": mesh.report.method.name()});
(Meshed::Hex(b, mesh), info)
(Meshed::Hex(Box::new((b, mesh))), info)
}
Err(e) => (Meshed::Tet(tet(s)?), json!({"method": "tet", "why": e.to_string()})),
Err(e) => (Meshed::Tet, json!({"method": "tet", "why": e.to_string()})),
},
};
if let Some(o) = info.as_object_mut() {
@@ -259,19 +275,50 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
}
interfaces.extend(swept_interfaces(&design, inputs, cm));
}
let mut k = 0;
for (i, _) in shapes.iter().enumerate() {
if joint.get(i).copied().flatten().is_some() {
continue;
}
let Some((cell, mut info)) = meshed.get_mut(k).map(|(c, v)| (c, std::mem::take(v))) else { break };
k += 1;
let (kind, elements, nodes) = match &*cell {
Meshed::Hex(b, m) => {
// The cells meshed alone, in the order of `shapes`: the hex ones first, then the tet ones,
// which may meet the hex cells through pyramids.
let alone: Vec<usize> = (0..shapes.len()).filter(|&i| joint.get(i).copied().flatten().is_none()).collect();
let mut hex_cells: Vec<String> = swept.iter().flat_map(|(_, _, inputs, _)| inputs.iter().map(|b| b.name.clone())).collect();
let mut tet_cells = Vec::new();
for (&i, (cell, info)) in alone.iter().zip(meshed.iter_mut()) {
let mut info = std::mem::take(info);
match &*cell {
Meshed::Hex(hex) => {
let (b, m) = &**hex;
add_hex(model, b, m)?;
(m.kind, m.element_count(), m.nodes.len())
hex_cells.push(b.name.clone());
if let Some(o) = info.as_object_mut() {
o.insert("kind".into(), json!(m.kind.name()));
o.insert("elements".into(), json!(m.element_count()));
o.insert("nodes".into(), json!(m.nodes.len()));
}
report.push((i, info));
}
Meshed::Tet(t) => {
Meshed::Tet => tet_cells.push((i, info)),
}
}
for (i, mut info) in tet_cells {
let Some(shape) = shapes.get(i) else { continue };
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())
} else if quadratic {
Err("pyramid transitions are linear (pyramid5); order 2 is tied".to_string())
} else {
transition::mesh_with_pyramids(&design, model, &tet_params, &input, &hex_cells, &cdt, &tet_options)?
};
let (kind, elements, nodes) = match pyramids {
Ok(c) => {
interfaces.extend(c.interfaces.iter().cloned());
let kind = c.tets.mesh.kind;
let (elements, nodes) = transition::add(model, c)?;
(kind, elements, nodes)
}
Err(why) => {
if let Some(o) = info.as_object_mut() {
o.insert("no_pyramids".into(), json!(why));
}
let t = mesh_body(Some(&design), &tet_params, input, &cdt, &tet_options, quality, true)?;
add_tets(model, &t.name, &t.face_names, &t.mesh)?;
(t.mesh.kind, t.mesh.elements.len(), t.mesh.points.len())
}
@@ -300,6 +347,7 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|r| r.get("body").and_then(Value::as_str).map(String::from))
.collect();
interfaces.extend(join(model, &names, &tet, COINCIDENT * diagonal)?);
explain_ties(&design, &report, &mut interfaces);
let (nodes, elements) = (model.nodes().len(), model.elements().len());
Ok(cleared.report(s, json!({"cells": report, "interfaces": interfaces, "nodes": nodes, "elements": elements, "size": size})))
}
@@ -542,6 +590,52 @@ fn join(model: &mut Model, cells: &[String], tet: &BTreeSet<String>, tol: f64) -
Ok(out)
}
/// Give every tied interface between cells of one design body its reason (`why`), from the
/// cells' reports (#396): a cell meshed with tet (and why auto fell back), the reason the body's
/// cells did not sweep together, or a cell whose method does not sweep with the others; so an
/// agent knows which cut or method to change for a conforming mesh.
fn explain_ties(design: &fieldcraft_geometry::Design, report: &[Value], interfaces: &mut [Value]) {
let info = |cell: &str| report.iter().find(|r| r.get("body").and_then(Value::as_str) == Some(cell));
let text = |r: Option<&Value>, key: &str| r.and_then(|r| r.get(key)).and_then(Value::as_str).map(String::from);
for interface in interfaces.iter_mut() {
if interface.get("joined").and_then(Value::as_str) != Some("tied") {
continue;
}
let cells: Vec<String> = interface
.get("cells")
.and_then(Value::as_array)
.map(|c| c.iter().filter_map(Value::as_str).map(String::from).collect())
.unwrap_or_default();
let [a, b] = cells.as_slice() else { continue };
let Some(source) = design.source_of(a).filter(|s| design.source_of(b) == Some(*s)) else { continue };
let mut why: Vec<String> = Vec::new();
for cell in [a, b] {
let r = info(cell);
if text(r, "kind").is_some_and(|k| k.starts_with("tet")) {
why.push(match text(r, "why") {
Some(w) => format!("`{cell}` is meshed with tet: {w}"),
None => format!("`{cell}` is meshed with tet"),
});
}
}
if why.is_empty() {
let not_together = [a, b].into_iter().find_map(|c| text(info(c), "not_swept_together"));
let alone = [a, b].into_iter().find(|c| text(info(c), "swept_together").is_none() && text(info(c), "not_swept_together").is_none());
if let Some(w) = not_together {
why.push(format!("the cells of `{source}` do not sweep together ({w}), so each was meshed alone with its own divisions"));
} else if let Some(c) = alone {
let method = text(info(c), "method").unwrap_or_default();
why.push(format!("`{c}` is meshed alone (method `{method}`), not swept with the other cells of `{source}`"));
} else {
why.push(format!("the element faces of `{a}` and `{b}` do not match node for node"));
}
}
if let Some(o) = interface.as_object_mut() {
o.insert("why".into(), json!(why.join("; ")));
}
}
}
/// The name of the tie that holds `cell`'s tied cut faces.
fn tie_name(cell: &str) -> String {
let name = format!("tie_{cell}");
@@ -613,11 +707,12 @@ mod tests {
fn hex_and_tet_cells_are_tied_and_matching_hex_cells_merged() {
let (_dir, mut s) = cut_plate();
// A tet cell next to a hex cell: tied, the hex cell main.
let r = s.execute("mesh.cells", &json!({"methods": {"Body1/a": "tet", "Body1/b": "mapped"}, "size": 2.5})).unwrap();
let r = s.execute("mesh.cells", &json!({"methods": {"Body1/a": "tet", "Body1/b": "mapped"}, "size": 2.5, "transition": "tie"})).unwrap();
assert_eq!(r["cells"][0]["kind"], "tet4", "{r}");
assert_eq!(r["cells"][1]["kind"], "hex8", "{r}");
let i = &r["interfaces"][0];
assert_eq!(i["joined"], "tied", "{r}");
assert!(i["why"].as_str().is_some_and(|w| w.contains("`Body1/a` is meshed with tet")), "{r}");
assert_eq!(s.model.ties().len(), 1);
assert_eq!((i["main"].as_str(), i["secondary"].as_str(), i["tie"].as_str()), (Some("Body1/b"), Some("Body1/a"), Some("tie_Body1_a")), "{r}");
for set in ["tie_Body1_a_main", "tie_Body1_a_secondary"] {
@@ -631,10 +726,37 @@ mod tests {
let separate = r["cells"].as_array().unwrap().iter().map(|c| c["nodes"].as_u64().unwrap()).sum::<u64>();
assert_eq!(r["nodes"].as_u64().unwrap() + i["nodes"].as_u64().unwrap(), separate, "{r}");
// Rerunning replaces the tie it made rather than adding another.
s.execute("mesh.cells", &json!({"methods": {"Body1/a": "tet"}, "size": 2.5})).unwrap();
s.execute("mesh.cells", &json!({"methods": {"Body1/a": "tet"}, "size": 2.5, "transition": "tie"})).unwrap();
assert_eq!(s.model.ties().len(), 1);
}
/// A tie between cells of one design body says why they were not conforming (#396): the
/// reason the body's cells did not sweep together, or the cell meshed alone by its method.
#[test]
fn ties_inside_one_body_say_why() {
let (_dir, s) = cut_plate();
let design = s.design.clone().unwrap();
let tied = || vec![json!({"cells": ["Body1/a", "Body1/b"], "joined": "tied"}), json!({"cells": ["Body1/a", "Body1/b"], "joined": "merged"})];
let mut interfaces = tied();
let report = [
json!({"body": "Body1/a", "kind": "hex8", "method": "extrude", "not_swept_together": "extrude: the levels differ"}),
json!({"body": "Body1/b", "kind": "hex8", "method": "extrude", "not_swept_together": "extrude: the levels differ"}),
];
explain_ties(&design, &report, &mut interfaces);
assert_eq!(
interfaces[0]["why"],
"the cells of `Body1` do not sweep together (extrude: the levels differ), so each was meshed alone with its own divisions"
);
assert!(interfaces[1].get("why").is_none());
let mut interfaces = tied();
let report = [
json!({"body": "Body1/a", "kind": "hex8", "method": "mapped"}),
json!({"body": "Body1/b", "kind": "hex8", "method": "extrude", "swept_together": "Body1"}),
];
explain_ties(&design, &report, &mut interfaces);
assert_eq!(interfaces[0]["why"], "`Body1/a` is meshed alone (method `mapped`), not swept with the other cells of `Body1`");
}
/// The half crank arm (cut on its symmetry plane only) has no cell that maps or sweeps as
/// the design gives it; the extrusion decomposes its cross-section into blocks itself, alike
/// through mesh.hex.generate (auto) and mesh.cells, with no seeds.
@@ -687,11 +809,10 @@ mod tests {
}
/// A 40 × 10 × 10 bar in uniaxial tension, cut in the middle: one half hex, the other tet,
/// tied. With ν = 0, symmetry conditions on three faces and the end load spread by consistent
/// weights, the stress is uniform, which both elements and the dual mortar tie carry exactly:
/// the end moves F L / (E A).
#[test]
fn a_tied_hex_tet_bar_in_tension_stretches_as_the_formula_says() {
/// joined by `transition` (`joined` in the reply). With ν = 0, symmetry conditions on three
/// faces and the end load spread by consistent weights, the stress is uniform, which the
/// elements, the pyramids and the dual mortar tie all carry exactly: the end moves F L / (E A).
fn hex_tet_bar_in_tension(transition: &str, joined: &str) -> Session {
let commands = json!({"commands": [
{"command": "solid.box", "params": {"length": 40, "width": 10, "height": 10, "body_name": "Bar"}},
]});
@@ -711,8 +832,8 @@ mod tests {
point[axis] = mid;
s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "point_normal", "point": point, "normal": normal}})).unwrap();
s.execute("geometry.partition", &json!({"name": "Cut", "datum": "Mid"})).unwrap();
let r = s.execute("mesh.cells", &json!({"methods": {"Bar/a": "mapped", "Bar/b": "tet"}, "size": 2.5})).unwrap();
assert_eq!(r["interfaces"][0]["joined"], "tied", "{r}");
let r = s.execute("mesh.cells", &json!({"methods": {"Bar/a": "mapped", "Bar/b": "tet"}, "size": 2.5, "transition": transition})).unwrap();
assert_eq!(r["interfaces"][0]["joined"], joined, "{r}");
let m = &s.model;
let on = |k: usize, v: f64| -> Vec<u32> { m.nodes().iter().filter(|(_, n)| (n.xyz[k] - v).abs() < 1e-9).map(|(l, _)| *l).collect() };
@@ -754,6 +875,27 @@ mod tests {
let u = n["displacement"][axis].as_f64().unwrap();
assert!((u - expected).abs() < 1e-6 * expected, "{u} vs {expected}: {n}");
}
s
}
#[test]
fn a_tied_hex_tet_bar_in_tension_stretches_as_the_formula_says() {
let s = hex_tet_bar_in_tension("tie", "tied");
assert_eq!(s.model.ties().len(), 1);
}
/// Through pyramids the bar is one conforming mesh: no tie, no free faces inside, every
/// element valid.
#[test]
fn a_hex_tet_bar_joined_by_pyramids_is_conforming_and_stretches_as_the_formula_says() {
let mut s = hex_tet_bar_in_tension("pyramid", "pyramids");
assert!(s.model.ties().is_empty());
let kinds: BTreeSet<&str> = s.model.elements().values().map(|e| e.kind.name()).collect();
assert_eq!(kinds.into_iter().collect::<Vec<_>>(), ["hex8", "pyramid5", "tet4"]);
let d = s.execute("mesh.check.duplicates", &json!({})).unwrap();
assert_eq!(d["counts"], json!({"coincident": 0, "cracks": 0, "duplicate_elements": 0, "free_edges": 0}), "{d}");
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["invalid"]["count"], 0, "{q}");
}
/// A box cut by planes into cells, `methods` per cell, a heat source in every cell and
@@ -811,6 +953,7 @@ mod tests {
let tied: Vec<&Value> = interfaces.iter().filter(|i| i["joined"] == "tied").collect();
assert_eq!(tied.len(), 3, "{r}");
assert!(tied.iter().all(|i| i["secondary"] == "Box/b/a/a" && i["tie"] == "tie_Box_b_a_a"), "{r}");
assert!(tied.iter().all(|i| i["why"].as_str().is_some_and(|w| w.starts_with("`Box/b/a/a` is meshed with tet"))), "{r}");
let merged: Vec<&Value> = interfaces.iter().filter(|i| i["joined"] == "merged").collect();
assert_eq!(merged.len(), 4, "{r}");
// Box/a/a and Box/a/b meet across y = 10, the third cut.
+172
View File
@@ -0,0 +1,172 @@
//! Pyramid transitions between a tet cell and the hex cells across its partitions (#439).
//!
//! The tet cell is meshed after the hex cells: its cut faces that face hex cells are replaced by
//! a pyramid on every hex element face there ([`fieldcraft_mesh::transition`]), the tetrahedra
//! fill the rest and keep the pyramids' sides, and the pyramids join the hex nodes to the tet
//! nodes. The mesh is conforming across the cut, so no tie is needed there.
use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_mesh::cdt::{CdtOptions, tetrahedralize};
use fieldcraft_mesh::surface::BoundarySurface;
use fieldcraft_mesh::tet::{QualityMesh, QualityOptions, quality_mesh};
use fieldcraft_mesh::transition::{Transition, apex_points, pyramid_surface};
use fieldcraft_model::{ElementKind, GeometryRef, Model};
use serde_json::{Value, json};
use super::super::hex::Input;
use super::super::mesh::{BodyTets, add_tets, analytic_surfaces};
use crate::{EngineError, Result};
/// A tet cell meshed against the pyramids on its hex neighbours' faces.
pub(super) struct Pyramids {
pub(super) tets: BodyTets,
transition: Transition,
/// Each pyramid's base: the hex nodes of its face, oriented into the tet cell.
bases: Vec<[u32; 4]>,
/// The interfaces it makes (reply entries).
pub(super) interfaces: Vec<Value>,
}
/// The base name of a cut face (`partition:P:a` of `partition:P:a#1`): its partition and side.
fn cut_of(name: &str) -> Option<(&str, usize)> {
let base = name.split_once('#').map_or(name, |(b, _)| b);
let (partition, side) = base.strip_prefix("partition:")?.rsplit_once(':')?;
Some((partition, usize::from(side == "b")))
}
/// Mesh tet cell `input` with pyramids on the faces of the hex cells `hex` (already in `model`)
/// across its cut faces. `Ok(Err(why))` when there is no such face or the transition cannot be
/// built (the caller meshes the cell plainly and ties it); limits and a cancel are errors.
pub(super) fn mesh_with_pyramids(
design: &fieldcraft_geometry::Design,
model: &Model,
p: &Value,
input: &Input,
hex: &[String],
cdt: &CdtOptions,
options: &QualityOptions,
) -> Result<std::result::Result<Pyramids, String>> {
// The hex element faces across each of the cell's cut faces.
let sides = super::sides(model, hex);
let mut cut: Vec<u32> = Vec::new();
let mut quads: Vec<[u32; 4]> = Vec::new();
let mut interfaces = Vec::new();
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
for (i, name) in input.face_names.iter().enumerate() {
let Some((partition, side)) = cut_of(name) else { continue };
let Some(across) = sides.get(partition).and_then(|s| s.get(1 - side)) else { continue };
if across.is_empty() {
continue;
}
cut.push(i as u32);
for other in across {
if !seen.insert((partition.to_string(), other.body.clone())) {
continue;
}
for f in &other.faces {
let nodes = model.face_nodes(*f).unwrap_or_default();
let Ok(q) = <[u32; 4]>::try_from(nodes.as_slice()) else {
return Ok(Err(format!("`{}` has quadratic or non-quadrilateral faces on the cut; pyramid transitions are linear", other.body)));
};
quads.push(q);
}
interfaces.push((partition.to_string(), other.body.clone(), other.faces.len()));
}
}
if quads.is_empty() {
return Ok(Err("no hex cell faces it across a partition".into()));
}
// The hex faces must cover the cut faces exactly (a cut shared with a tet cell, or a hex
// cell larger than this one, is tied instead).
let xyz = |n: u32| model.node(n).map(|x| x.xyz).unwrap_or([f64::NAN; 3]);
let positions: Vec<[[f64; 3]; 4]> = quads.iter().map(|q| q.map(xyz)).collect();
let quad_area: f64 = positions.iter().map(|q| 0.5 * norm(cross(sub(q[2], q[0]), sub(q[3], q[1])))).sum();
let m = &input.mesh;
let cut_area: f64 = m
.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, f)| cut.contains(f))
.map(|(t, _)| {
let c = t.map(|v| m.positions.get(v as usize).copied().unwrap_or([f64::NAN; 3]));
0.5 * norm(cross(sub(c[1], c[0]), sub(c[2], c[0])))
})
.sum();
if !((quad_area - cut_area).abs() <= 1e-3 * cut_area) {
return Ok(Err(format!("the hex cells' faces ({quad_area:.6} mm²) do not cover its cut faces ({cut_area:.6} mm²)")));
}
let tag = u32::try_from(input.face_names.len()).map_err(|_| EngineError::Params("too many faces".into()))?;
let welding = super::super::mesh::surface_options(p, m)?;
let built = || -> fieldcraft_mesh::Result<(QualityMesh, Transition, usize, usize, f64)> {
let surface = BoundarySurface::new(&m.positions, &m.triangles, &m.tri_face, &welding)?;
let t = pyramid_surface(&surface, &cut, &positions, tag)?;
// Already welded: nothing more merges.
let exact = fieldcraft_mesh::surface::SurfaceOptions { weld_tolerance: 0.0, weld_distance: 0.0, ..welding };
let region = BoundarySurface::new(&t.positions, &t.triangles, &t.tri_face, &exact)?;
let tets = tetrahedralize(&region, cdt)?;
let mut analytic = design.body(&input.name).map(analytic_surfaces).unwrap_or_default();
analytic.resize(tag as usize, None);
analytic.push(None);
let o = QualityOptions { seeds: input.seeds.clone(), frozen_faces: vec![tag], ..options.clone() };
let mesh = quality_mesh(&region, &tets, analytic, &o)?;
Ok((mesh, t, tets.steiner.len(), tets.stats.cavities, region.volume()))
};
let (mesh, transition, steiner, cavities, volume) = match built() {
Ok(x) => x,
Err(fieldcraft_mesh::Error::Model(fieldcraft_model::Error::Cancelled)) => return Err(EngineError::Model(fieldcraft_model::Error::Cancelled)),
Err(e @ fieldcraft_mesh::Error::Limit(_)) => return Err(EngineError::Params(e.to_string())),
Err(e) => return Ok(Err(e.to_string())),
};
if let Err(e) = apex_points(&mesh, &transition) {
return Ok(Err(e.to_string()));
}
let tets = BodyTets::new(input.name.clone(), input.face_names.clone(), mesh, steiner, cavities, volume);
let interfaces = interfaces
.into_iter()
.map(|(partition, other, faces)| json!({"partition": partition, "cells": [other, input.name], "joined": "pyramids", "pyramids": faces}))
.collect();
Ok(Ok(Pyramids { tets, transition, bases: quads, interfaces }))
}
/// Add the cell's tetrahedra and pyramids to `model`: the tetrahedra's nodes on the hex faces
/// are the hex nodes, the pyramids join them to the apexes.
pub(super) fn add(model: &mut Model, mut c: Pyramids) -> Result<(usize, usize)> {
let tag = c.tets.face_names.len() as u32;
let apexes = apex_points(&c.tets.mesh, &c.transition).map_err(|e| EngineError::Params(e.to_string()))?;
// The pyramid sides are inside the part: no geometry face.
c.tets.mesh.boundary.retain(|b| b.face != tag);
let (first_node, _) = add_tets(model, &c.tets.name, &c.tets.face_names, &c.tets.mesh)?;
// The tet mesh keeps the hex nodes' coordinates exactly: merge its copies into them.
let hex: BTreeMap<[u64; 3], u32> = c.bases.iter().flatten().filter_map(|&n| model.node(n).map(|x| (x.xyz.map(f64::to_bits), n))).collect();
let mut merge = BTreeMap::new();
for (i, x) in c.tets.mesh.points.iter().enumerate() {
if let Some(&h) = hex.get(&x.map(f64::to_bits)) {
merge.insert(first_node + i as u32, h);
}
}
let shared = merge.len();
model.merge_nodes(&merge)?;
let (_, first) = crate::cmd::fresh_labels(model, 0, c.bases.len())?;
let body = model.geometry_tag(&GeometryRef::body(c.tets.name.as_str())?)?;
for (k, (base, apex)) in c.bases.iter().zip(apexes).enumerate() {
let label = first + k as u32;
let mut nodes = base.to_vec();
nodes.push(first_node + apex);
model.set_element(label, ElementKind::Pyramid5, nodes)?;
model.tag_element(label, body)?;
}
Ok((c.tets.mesh.elements.len() + c.bases.len(), c.tets.mesh.points.len() - shared))
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn norm(a: [f64; 3]) -> f64 {
(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt()
}
+32 -16
View File
@@ -12,7 +12,7 @@
use crate::schema::ProblemKind;
use std::sync::Arc;
use fieldcraft_geometry::{Cut, Design};
use fieldcraft_geometry::{Cut, Design, Merge};
use fieldcraft_io::{Item, ItemKind, MAX_HIDDEN};
use fieldcraft_model::{Datum, Model, Partition};
use serde::Deserialize;
@@ -94,7 +94,7 @@ pub const DATUM_DELETE: CommandSpec = CommandSpec {
pub const PARTITION: CommandSpec = CommandSpec {
id: "geometry.partition",
label: "Partition",
params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition:<name>:a` on the cells on the negative side, `partition:<name>:b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`<face>#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named, and `warnings` when the plane lies on a face of a body it cuts or a cell's boundary does not tessellate into one a mesher takes (a cut through existing edges or vertices, such as a hole's axis), naming the plane, the cell and where; a body it cuts loses its mesh (`replaced`, as a mesher reports it; remesh the cells); a plane that divides no body, a listed body it does not divide, or a body the kernel fails to cut (its message) is an error"#,
params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides; a body earlier partitions cut stands for its cells)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition:<name>:a` on the cells on the negative side, `partition:<name>:b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`<face>#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named, and `warnings` when the plane lies on a face of a body it cuts or a cell's boundary does not tessellate into one a mesher takes (a cut through existing edges or vertices, such as a hole's axis), naming the plane, the cell and where; a body it cuts loses its mesh (`replaced`, as a mesher reports it; remesh the cells); a plane that divides no body, a listed body it does not divide, or a body the kernel fails to cut (its message) is an error"#,
form: Some(super::forms::PARTITION),
kind: CommandKind::Model,
requires: &[Requirement::Design, Requirement::DatumPlane],
@@ -470,15 +470,30 @@ fn cuts(model: &Model) -> Vec<Cut> {
.collect()
}
/// `design` cut by `model`'s partitions (the design as opened when the model has none).
/// `design` cut by `model`'s partitions, then with its face merges and its face splits (those
/// along a suppressed datum left out; the design as opened when the model has none).
pub(crate) fn partitioned(design: &Design, model: &Model) -> Result<Design> {
Ok(design.partitioned(&cuts(model))?)
let merges: Vec<Merge> = model
.face_merges()
.iter()
.map(|m| Merge { name: m.name.clone(), body: m.body.clone(), faces: m.faces.clone(), into: m.into.clone() })
.collect();
let splits: Vec<fieldcraft_geometry::face_split::FaceSplit> = model
.face_splits()
.iter()
.filter(|s| !model.datum_suppressed(&s.datum))
.filter_map(|s| {
let (point, normal) = model.datum(&s.datum)?.plane()?;
Some(fieldcraft_geometry::face_split::FaceSplit { name: s.name.clone(), body: s.body.clone(), face: s.face.clone(), point, normal })
})
.collect();
Ok(design.partitioned(&cuts(model))?.merged(&merges)?.split(&splits)?)
}
/// Cut the session's design by its model's partitions again. The bodies that are gone (cells
/// Cut the session's design by its model's partitions again (and merge its faces again). The bodies that are gone (cells
/// whose partition went, a body a new partition cut) take their meshes with them (#385): what
/// went is in the returned [`super::replace::Cleared`], which the command reports.
fn recut(s: &mut Session) -> Result<super::replace::Cleared> {
pub(super) fn recut(s: &mut Session) -> Result<super::replace::Cleared> {
let Some(d) = &s.design else { return Ok(super::replace::Cleared::default()) };
let cut = partitioned(d, &s.model)?;
let gone: Vec<String> = d.bodies.iter().filter(|b| cut.body(&b.name).is_none()).map(|b| b.name.clone()).collect();
@@ -508,7 +523,7 @@ fn partition(s: &mut Session, p: &Value) -> Result<Value> {
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 {
if let Some(why) = design.body(cell).and_then(|shape| unmeshable(shape).err()) {
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)"
));
@@ -534,10 +549,10 @@ fn partition(s: &mut Session, p: &Value) -> Result<Value> {
Ok(cleared.report_if_any(s, reply))
}
/// Whether a cell's boundary, tessellated as the meshers take it by default, bounds a volume mesh:
/// Whether a cell's boundary, tessellated as the meshers take it (by default, or at `tolerance`), bounds a volume mesh:
/// the defects (open edges, edges on three triangles) when it does not.
fn unmeshable(shape: &fieldcraft_geometry::Shape) -> Result<()> {
let input = super::hex::shape_input(shape, None)?;
pub(super) fn unmeshable(shape: &fieldcraft_geometry::Shape, tolerance: Option<f64>) -> Result<()> {
let input = super::hex::shape_input(shape, tolerance)?;
let options = fieldcraft_mesh::surface::SurfaceOptions { weld_distance: input.mesh.weld_distance, ..Default::default() };
fieldcraft_mesh::surface::BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &options)
.map(|_| ())
@@ -945,7 +960,8 @@ pub(crate) mod tests {
/// #384: cuts stay on their plane (the kernel's own split put them 1.8 µm off it on the air
/// of the enclosure, so the cut faces were not even recognized), and a plane along an
/// existing face or through a hole's axis is a warning naming the plane, the body and where.
/// existing face or through a hole's axis is a warning naming the plane, the body and where,
/// or refused where the kernel could only cut it off the plane.
#[test]
fn cuts_stay_on_the_plane_and_risky_ones_are_named() {
let (_dir, mut s) = pi5();
@@ -972,11 +988,11 @@ pub(crate) mod tests {
assert!(!cut.is_empty() && cut.iter().all(|x| (x - 16.5).abs() < 1e-9), "{cell}: {cut:?}");
}
s.execute("edit.undo", &json!({})).unwrap();
// Along the side of the heat sink's pedestal: named.
let r = plane(&mut s, "Side", 34.0);
let warnings = r["warnings"].to_string();
assert!(warnings.contains("partition `PSide`: the plane lies on face `F45:-x` of `Air`"), "{r}");
s.execute("edit.undo", &json!({})).unwrap();
// Along the side of the heat sink's pedestal: the kernel only cuts with its box nudged
// off the plane, so the cut is refused, saying why (#427).
s.execute("geometry.datum", &json!({"name": "Side", "by": {"type": "offset", "plane": "yz", "offset": 34.0}})).unwrap();
let e = s.execute("geometry.partition", &json!({"name": "PSide", "datum": "Side", "bodies": ["Air"]})).unwrap_err().to_string();
assert!(e.contains("could not cut `Air`") && e.contains("only made off the plane"), "{e}");
// Through a standoff's hole: the cell whose boundary breaks at the hole's edge is named.
let r = plane(&mut s, "Axis", 74.5);
let warnings = r["warnings"].to_string();
+1 -1
View File
@@ -252,7 +252,7 @@ definition_commands!(
BoundaryCondition,
"boundary_condition",
"Boundary Condition",
r#"{"name": "clamp", "type": "fixed", "target": {"set": "root"} | {"reference_point": "RP"}} | {"name", "type": "displacement", "target", "values": [u1, u2, u3, ur1, ur2, ur3] (null leaves a degree of freedom free)} | {"name", "type": "symmetry", "target", "plane": "x|y|z" (the plane's normal)} — active in the steps that list it (model.step.activate)"#,
r#"{"name": "clamp", "type": "fixed", "target": {"set": "root"} | {"reference_point": "RP"}} | {"name", "type": "displacement", "target", "values": [u1, u2, u3, ur1, ur2, ur3] (null leaves a degree of freedom free)} | {"name", "type": "symmetry", "target", "plane": "x|y|z" | {"normal": [nx, ny, nz]} (the plane's normal; an inclined plane holds n · u = 0 and the rotations about its in-plane axes)} — active in the steps that list it (model.step.activate)"#,
super::forms::BOUNDARY_CONDITION,
super::forms::BOUNDARY_CONDITION_DELETE,
add_boundary_condition,
+515
View File
@@ -0,0 +1,515 @@
//! `mesh.hex.extrude`: hexahedra from a quad section up to a target surface
//! ([`fieldcraft_mesh::hex::extrude_to_surface`]).
//!
//! The section is, in this order: the quadrilaterals of a set (`source_set`: shell elements or
//! element faces); the mesh already on the source faces (element faces tagged with them, or the
//! boundary faces of a neighbouring body's mesh lying on them), whose nodes the extrusion shares
//! so it conforms with that mesh; or a fresh quad mesh of a planar source face. Target and side
//! faces are projected onto through the trimmed B-rep projection that projection blocking uses
//! ([`fieldcraft_mesh::tet::geometry::Tessellation::project_trimmed`]): exact on analytic faces,
//! the tessellation's accuracy on free-form ones.
use std::collections::BTreeMap;
use fieldcraft_geometry::{FaceSurface, Shape};
use fieldcraft_mesh::hex::{ExtrudeLayers, ExtrudePath, ExtrudeRole, ExtrudeSpec, MAX_BIAS, MAX_LAYERS, extrude_to_surface};
use fieldcraft_mesh::planar::{face_boundary, mesh_region};
use fieldcraft_mesh::project::Geometry;
use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
use fieldcraft_mesh::tet::geometry::Tessellation;
use fieldcraft_model::{ElementKind, Entity, GeometryRef, Model};
use serde_json::{Value, json};
use crate::requirement::Requirement;
use crate::schema::ProblemKind;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const HEX_EXTRUDE: CommandSpec = CommandSpec {
id: "mesh.hex.extrude",
label: "Extrude to Surface",
params: r#"{"source": [{"body": "Post", "face": "F1"}] (the section: faces already meshed, by this body or a neighbour, or one planar face meshed here) or "source_set": "Section" (a set of quad shell elements or element faces, such as a 2D mesh), "to_surface": [{"body": "Wall", "face": "F7"}] (the target faces the last layer lies on), "sides": [{"body": "Post", "face": "F3"}, …] (optional: the faces the section's sides follow), "direction": [0, 0, 1] or "normal": true (each node along the section's normal toward the target; the default), "layers": 8 or "size": 2.0 (layer thickness, mm; also the size of a section meshed here; default 1/25 of the paths' extent), "bias": 1.0 (last layer's thickness over the first's, 0.001 to 1000), "body": "Post" (the body the elements fill; default: the first source face's body), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of each body's diagonal)} — replaces `body`'s mesh with hex8 layers stacked on the section, each node along its own straight path, the last layer on the target surface; with `sides`, every layer's boundary nodes lie on the side faces (which need not be straight) and the interior follows them. A section taken from an existing mesh keeps its nodes, so the extrusion conforms with the neighbour across it. Boundary element faces are tagged with `body`'s source, target and side faces. Reports the layers, counts, quality, the target gap and where the section came from; a path that misses the target, or elements that would invert, is an error that says where"#,
form: Some(super::forms::HEX_EXTRUDE),
kind: CommandKind::Model,
requires: &[Requirement::Design],
run: hex_extrude,
};
/// Most faces in `source`, `to_surface` or `sides`.
const MAX_FACES: usize = 1000;
/// Default layer size and chordal tolerance, relative to the extent.
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
const DEFAULT_RELATIVE_TOLERANCE: f64 = 1e-3;
const MIN_RELATIVE_TOLERANCE: f64 = 1e-5;
/// Most quadrilaterals of a section meshed here.
const MAX_SECTION_QUADS: usize = 1_000_000;
/// The bodies the extrusion's faces lie on, and every face it names, with one projection.
struct Faces {
bodies: Vec<Tessellation>,
/// Per body: its name, and its faces' names.
names: Vec<(String, Vec<String>)>,
/// Face id → (body, B-rep face).
faces: Vec<(usize, u32)>,
/// The largest chordal tolerance used.
tolerance: f64,
}
impl Geometry for Faces {
fn project(&self, face: u32, p: [f64; 3]) -> Option<[f64; 3]> {
let &(b, f) = self.faces.get(face as usize)?;
self.bodies.get(b)?.project_trimmed(f, p)
}
fn normal(&self, face: u32, p: [f64; 3]) -> Option<[f64; 3]> {
let &(b, f) = self.faces.get(face as usize)?;
self.bodies.get(b)?.face_normal(f, p)
}
}
impl Faces {
/// The ids of the faces `refs` names (a name selects all its pieces), loading their bodies.
fn add(&mut self, s: &Session, refs: &[GeometryRef], tolerance: Option<f64>, key: &str) -> Result<Vec<u32>> {
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
let mut out = Vec::new();
for r in refs {
if !matches!(r.entity, Entity::Face(_)) {
return Err(EngineError::invalid(key, format!("{r} is not a face")));
}
let shape = design.body(&r.body).ok_or_else(|| EngineError::invalid(key, format!("no body `{}`", r.body)))?;
let b = match self.names.iter().position(|(n, _)| *n == shape.name) {
Some(b) => b,
None => self.load(shape, tolerance)?,
};
let names = self.names.get(b).map(|(_, f)| f.clone()).unwrap_or_default();
let before = out.len();
for (i, name) in names.iter().enumerate() {
if r.selects(&GeometryRef::face(shape.name.as_str(), name.as_str())?) {
let id = self.id(b, i as u32);
if !out.contains(&id) {
out.push(id);
}
}
}
if out.len() == before {
return Err(EngineError::invalid(key, format!("body `{}` has no face `{}`", r.body, r.entity.name().unwrap_or_default())));
}
}
Ok(out)
}
fn load(&mut self, shape: &Shape, tolerance: Option<f64>) -> Result<usize> {
let diag = shape.size()?;
let tol = tolerance.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * diag);
if tol < MIN_RELATIVE_TOLERANCE * diag {
return Err(EngineError::Params(format!("`tolerance` must be at least {} mm for body `{}`", MIN_RELATIVE_TOLERANCE * diag, shape.name)));
}
let mesh = shape.tessellate(tol)?;
let surface = BoundarySurface::new(
&mesh.positions,
&mesh.triangles,
&mesh.tri_face,
&SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() },
)
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", shape.name)))?;
self.bodies.push(Tessellation::new(&surface, super::mesh::analytic_surfaces(shape)));
self.names.push((shape.name.clone(), shape.faces().iter().map(|f| f.name.clone()).collect()));
self.tolerance = self.tolerance.max(tol);
Ok(self.bodies.len() - 1)
}
fn id(&mut self, b: usize, f: u32) -> u32 {
match self.faces.iter().position(|x| *x == (b, f)) {
Some(i) => i as u32,
None => {
self.faces.push((b, f));
(self.faces.len() - 1) as u32
}
}
}
/// The body and face name of face `id`.
fn name(&self, id: u32) -> Option<(&str, &str)> {
let &(b, f) = self.faces.get(id as usize)?;
let (body, faces) = self.names.get(b)?;
Some((body.as_str(), faces.get(f as usize)?.as_str()))
}
}
/// The section: node coordinates, the model label of each node it shares (none for a fresh
/// section), and its quadrilaterals.
struct Section {
nodes: Vec<[f64; 3]>,
labels: Vec<Option<u32>>,
quads: Vec<[u32; 4]>,
from: &'static str,
}
impl Section {
/// A section of model nodes: each label once, in order of first use.
fn of_labels(model: &Model, quads: &[[u32; 4]], from: &'static str) -> Result<Section> {
let mut index: BTreeMap<u32, u32> = BTreeMap::new();
let mut s = Section { nodes: Vec::new(), labels: Vec::new(), quads: Vec::with_capacity(quads.len()), from };
for q in quads {
let mut local = [0u32; 4];
for (slot, &l) in local.iter_mut().zip(q) {
*slot = match index.get(&l) {
Some(&i) => i,
None => {
let xyz = model.nodes().get(&l).map(|n| n.xyz).ok_or_else(|| EngineError::Params(format!("node {l} is not in the model")))?;
let i = s.nodes.len() as u32;
s.nodes.push(xyz);
s.labels.push(Some(l));
index.insert(l, i);
i
}
};
}
s.quads.push(local);
}
Ok(s)
}
}
/// The four corners of element `e`'s face `face` (1-based), when it is a quadrilateral.
fn quad_face(model: &Model, e: u32, face: u8) -> Option<[u32; 4]> {
let el = model.elements().get(&e)?;
let local = el.kind.faces().get(usize::from(face).checked_sub(1)?)?;
if local.len() < 4 || (local.len() != 4 && local.len() != 8) {
return None;
}
let corner = |k: usize| local.get(k).and_then(|&i| el.nodes.get(i)).copied();
Some([corner(0)?, corner(1)?, corner(2)?, corner(3)?])
}
/// The corners of a quad shell element.
fn quad_shell(model: &Model, e: u32) -> Option<[u32; 4]> {
let el = model.elements().get(&e)?;
if !matches!(el.kind, ElementKind::Quad4 | ElementKind::Quad8) {
return None;
}
Some([*el.nodes.first()?, *el.nodes.get(1)?, *el.nodes.get(2)?, *el.nodes.get(3)?])
}
/// The elements of `body`'s mesh.
fn body_elements(model: &Model, body: &str) -> std::collections::BTreeSet<u32> {
model.body_elements(body).into_iter().collect()
}
/// The quadrilaterals tagged with the faces `refs` names (shell elements filling them, element
/// faces on them), leaving out `exclude`'s elements.
fn tagged_quads(model: &Model, refs: &[GeometryRef], exclude: &std::collections::BTreeSet<u32>) -> Result<Vec<[u32; 4]>> {
let c = model.classification();
let mut tags: Vec<u32> = refs.iter().flat_map(|r| c.tags_selected_by(r)).collect();
tags.sort_unstable();
tags.dedup();
let mut out = Vec::new();
for (&e, t) in c.elements() {
if exclude.contains(&e) {
continue;
}
if t.region.is_some_and(|r| tags.binary_search(&r).is_ok()) {
out.push(quad_shell(model, e).ok_or_else(|| EngineError::Params(format!("element {e} on the source face is not a quadrilateral")))?);
}
for (&f, tag) in &t.faces {
if tags.binary_search(tag).is_ok() {
out.push(
quad_face(model, e, f)
.ok_or_else(|| EngineError::Params(format!("element {e}'s face {f} on the source face is not a quadrilateral")))?,
);
}
}
}
Ok(out)
}
/// The boundary faces of other bodies' meshes (tagged faces) that lie on the source faces.
fn neighbour_quads(model: &Model, geometry: &Faces, source: &[u32], exclude: &std::collections::BTreeSet<u32>) -> Vec<[u32; 4]> {
let tol = 10.0 * geometry.tolerance.max(1e-9);
let on = |p: [f64; 3]| {
source.iter().any(|&f| geometry.project(f, p).is_some_and(|q| (0..3).map(|k| (q[k] - p[k]).powi(2)).sum::<f64>().sqrt() <= tol))
};
let xyz = |l: u32| model.nodes().get(&l).map(|n| n.xyz);
let mut out = Vec::new();
for (&e, t) in model.classification().elements() {
if exclude.contains(&e) {
continue;
}
for &f in t.faces.keys() {
let Some(q) = quad_face(model, e, f) else { continue };
let Some(pts) = q.iter().map(|&l| xyz(l)).collect::<Option<Vec<_>>>() else { continue };
let centre = pts.iter().fold([0.0; 3], |a, p| [a[0] + p[0] / 4.0, a[1] + p[1] / 4.0, a[2] + p[2] / 4.0]);
if pts.iter().all(|p| on(*p)) && on(centre) {
out.push(q);
}
}
}
out
}
/// A quad mesh of one planar face of `shape` of about `size`.
fn mesh_planar_face(shape: &Shape, face: u32, size: f64, tolerance: f64) -> Result<Section> {
let surfaces = shape.face_surfaces()?;
let Some(Some(FaceSurface::Plane { normal, point })) = surfaces.get(face as usize).copied() else {
return Err(EngineError::Params(format!(
"the source face of body `{}` is not meshed and not planar: mesh it first (a 2D mesh, or a neighbouring body's mesh)",
shape.name
)));
};
let n = unit(normal).ok_or_else(|| EngineError::Params("the source face has no normal".into()))?;
let t = if n[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
let u = unit(cross(n, t)).ok_or_else(|| EngineError::Params("the source face has no normal".into()))?;
let v = cross(n, u);
let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
let mesh = shape.tessellate(tolerance)?;
// The tessellation in the face's frame, the face at z = 0.
let local: Vec<[f64; 3]> = mesh
.positions
.iter()
.map(|p| {
let d = [p[0] - point[0], p[1] - point[1], p[2] - point[2]];
[dot(d, u), dot(d, v), dot(d, n)]
})
.collect();
let boundary = face_boundary(&local, &mesh.triangles, &mesh.tri_face, face, mesh.weld_distance.max(tolerance))?;
let opts = fieldcraft_mesh::quad::QuadOptions { max_elements: MAX_SECTION_QUADS, ..Default::default() };
let m = mesh_region(&boundary.loops, ElementKind::Quad4, size, &opts)?;
let at = |xy: [f64; 2]| [0, 1, 2].map(|k| point[k] + xy[0] * u[k] + xy[1] * v[k]);
let nodes: Vec<[f64; 3]> = m.nodes.iter().map(|xy| at(*xy)).collect();
let quads = m
.elements
.iter()
.map(|e| match e.as_slice() {
[a, b, c, d, ..] => Ok([*a, *b, *c, *d]),
_ => Err(EngineError::Params("the section mesher made an element that is not a quadrilateral".into())),
})
.collect::<Result<Vec<_>>>()?;
Ok(Section { labels: vec![None; nodes.len()], nodes, quads, from: "meshed" })
}
fn unit(a: [f64; 3]) -> Option<[f64; 3]> {
let l = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt();
(l > 0.0 && l.is_finite()).then(|| a.map(|x| x / l))
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
/// A list of face references.
fn face_refs(p: &Value, key: &str, required: bool) -> Result<Vec<GeometryRef>> {
let refs: Vec<GeometryRef> = match p.get(key) {
None if !required => return Ok(Vec::new()),
None => return Err(EngineError::param(key, ProblemKind::Required)),
Some(v) => serde_json::from_value(v.clone()).map_err(|e| EngineError::invalid(key, format!("a list of {{\"body\", \"face\"}}: {e}")))?,
};
if refs.len() > MAX_FACES || (required && refs.is_empty()) {
return Err(EngineError::invalid(key, format!("1 to {MAX_FACES} faces")));
}
Ok(refs)
}
/// The body the elements fill: `body`, or the first source face's.
pub(super) fn extrude_body(_: &Session, p: &Value) -> Result<Vec<String>> {
if let Some(b) = p.get("body") {
let name = b.as_str().ok_or_else(|| EngineError::param("body", ProblemKind::NotAName))?;
GeometryRef::body(name)?;
return Ok(vec![name.to_string()]);
}
let source = face_refs(p, "source", false)?;
let first = source.first().ok_or_else(|| EngineError::Params("give `body` (the body the elements fill) with `source_set`".into()))?;
Ok(vec![first.body.clone()])
}
fn hex_extrude(s: &mut Session, p: &Value) -> Result<Value> {
let tolerance = super::hex::number(p, "tolerance")?;
let size = super::hex::number(p, "size")?;
let layers = match p.get("layers") {
None => None,
Some(v) => Some(
v.as_u64()
.filter(|n| (1..=MAX_LAYERS as u64).contains(n))
.ok_or_else(|| EngineError::invalid("layers", format!("a whole number from 1 to {MAX_LAYERS}")))? as usize,
),
};
let bias = match p.get("bias") {
None => 1.0,
Some(v) => v
.as_f64()
.filter(|b| (1.0 / MAX_BIAS..=MAX_BIAS).contains(b))
.ok_or_else(|| EngineError::invalid("bias", format!("a number from {} to {MAX_BIAS}", 1.0 / MAX_BIAS)))?,
};
let path = match (p.get("direction"), p.get("normal")) {
(Some(d), None) => {
let d: [f64; 3] = serde_json::from_value(d.clone()).map_err(|_| EngineError::param("direction", ProblemKind::NotAVector { len: 3 }))?;
if !d.iter().all(|x| x.is_finite()) || unit(d).is_none() {
return Err(EngineError::invalid("direction", "a vector with a length"));
}
ExtrudePath::Direction(d)
}
(None, None) => ExtrudePath::Normal,
(None, Some(n)) => match n.as_bool() {
Some(true) => ExtrudePath::Normal,
_ => return Err(EngineError::invalid("normal", "true, or give `direction`")),
},
(Some(_), Some(_)) => return Err(EngineError::Params("give `direction` or `normal`, not both".into())),
};
let source = face_refs(p, "source", false)?;
let source_set = match p.get("source_set") {
None => None,
Some(v) => Some(v.as_str().ok_or_else(|| EngineError::param("source_set", ProblemKind::NotAName))?.to_string()),
};
if source.is_empty() == source_set.is_none() {
return Err(EngineError::Params("give the section as `source` (faces) or `source_set` (a set), one of them".into()));
}
let targets = face_refs(p, "to_surface", true)?;
let sides = face_refs(p, "sides", false)?;
let body = extrude_body(s, p)?.into_iter().next().unwrap_or_default();
let body_ref = GeometryRef::body(body.as_str())?;
let mut faces = Faces { bodies: Vec::new(), names: Vec::new(), faces: Vec::new(), tolerance: 0.0 };
let source_ids = faces.add(s, &source, tolerance, "source")?;
let target_ids = faces.add(s, &targets, tolerance, "to_surface")?;
let side_ids = faces.add(s, &sides, tolerance, "sides")?;
if let Some(f) = target_ids.iter().find(|f| source_ids.contains(f) || side_ids.contains(f)) {
let (b, n) = faces.name(*f).unwrap_or_default();
return Err(EngineError::Params(format!("face `{n}` of body `{b}` is both a target and a source or side face")));
}
// The section: a set, the mesh already on the source faces, or a fresh mesh of one face.
let own = body_elements(&s.model, &body);
let section = match &source_set {
Some(name) => {
let r = s.model.resolve_set(name)?;
let mut quads = Vec::new();
for &e in &r.elements {
if let Some(q) = quad_shell(&s.model, e) {
quads.push(q);
}
}
for f in &r.faces {
quads.push(
quad_face(&s.model, f.element, f.face).ok_or_else(|| {
EngineError::Params(format!("set `{name}`: element {}'s face {} is not a quadrilateral", f.element, f.face))
})?,
);
}
if quads.is_empty() {
return Err(EngineError::Params(format!("set `{name}` has no quadrilaterals (quad shell elements or hex element faces)")));
}
Section::of_labels(&s.model, &quads, "set")?
}
None => {
let tagged = tagged_quads(&s.model, &source, &own)?;
if !tagged.is_empty() {
Section::of_labels(&s.model, &tagged, "mesh")?
} else {
let near = neighbour_quads(&s.model, &faces, &source_ids, &own);
if !near.is_empty() {
Section::of_labels(&s.model, &near, "neighbour")?
} else {
let [id] = source_ids.as_slice() else {
return Err(EngineError::Params("the source faces are not meshed: give one face to mesh here, or mesh them first".into()));
};
let (b, f) = faces.faces.get(*id as usize).copied().ok_or_else(|| EngineError::Params("no source face".into()))?;
let name = faces.names.get(b).map(|n| n.0.clone()).unwrap_or_default();
let shape = s.design.as_deref().and_then(|d| d.body(&name)).ok_or_else(|| EngineError::Params(format!("no body `{name}`")))?;
let extent = shape.size()?;
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * extent);
mesh_planar_face(shape, f, h, tolerance.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * extent))?
}
}
}
};
let layers = match (layers, size) {
(Some(n), _) => ExtrudeLayers::Count(n),
(None, Some(h)) => ExtrudeLayers::Size(h),
(None, None) => {
let extent = s.design.as_deref().and_then(|d| d.body(&body)).map(Shape::size).transpose()?.unwrap_or(1.0);
ExtrudeLayers::Size(DEFAULT_RELATIVE_SIZE * extent)
}
};
let spec = ExtrudeSpec {
nodes: &section.nodes,
quads: &section.quads,
path,
targets: &target_ids,
sides: &side_ids,
layers,
bias,
tolerance: faces.tolerance.max(1e-9),
max_elements: fieldcraft_model::MAX_ELEMENTS / 8,
};
let m = extrude_to_surface(&spec, &faces).map_err(|e| EngineError::of_body(&body, e))?;
// Replace the body's mesh; the section's shared nodes stay.
let mut model = s.model.clone();
let cleared = super::replace::clear(&mut model, [body.as_str()]);
if let Some(l) = section.labels.iter().flatten().find(|l| !model.nodes().contains_key(l)) {
return Err(EngineError::Params(format!("the section's node {l} belongs to body `{body}`'s mesh, which the extrusion replaces")));
}
let n_src = section.nodes.len();
let fresh = m.nodes.len() - section.labels.iter().flatten().count();
let (first_node, first_element) = super::fresh_labels(&model, fresh, m.hexes.len())?;
let mut labels = Vec::with_capacity(m.nodes.len());
let mut next = first_node;
for (i, xyz) in m.nodes.iter().enumerate() {
let shared = if i < n_src { section.labels.get(i).copied().flatten() } else { None };
labels.push(match shared {
Some(l) => l,
None => {
model.set_node(next, *xyz)?;
next += 1;
next - 1
}
});
}
let body_tag = model.geometry_tag(&body_ref)?;
for (e, h) in m.hexes.iter().enumerate() {
let nodes: Vec<u32> = h.iter().map(|&i| labels.get(i as usize).copied().unwrap_or(0)).collect();
let l = first_element + e as u32;
model.set_element(l, ElementKind::Hex8, nodes)?;
model.tag_element(l, body_tag)?;
}
// Boundary faces on the body's own B-rep faces get their tags.
let source_own: Vec<u32> = source_ids.iter().copied().filter(|&f| faces.name(f).is_some_and(|(b, _)| b == body)).collect();
let mut tags: BTreeMap<u32, u32> = BTreeMap::new();
for b in &m.boundary {
let face = match b.role {
ExtrudeRole::Source => source_own.first().copied(),
ExtrudeRole::Target(f) | ExtrudeRole::Side(Some(f)) => Some(f),
ExtrudeRole::Side(None) => None,
};
let Some((owner, name)) = face.and_then(|f| faces.name(f)) else { continue };
if owner != body {
continue;
}
let key = face.unwrap_or_default();
let tag = match tags.get(&key) {
Some(&t) => t,
None => {
let t = model.geometry_tag(&GeometryRef::face(body.as_str(), name)?)?;
tags.insert(key, t);
t
}
};
model.tag_element_face(first_element + b.element, b.face, tag)?;
}
let (nodes, elements) = (model.nodes().len(), model.elements().len());
s.model = model;
let reply = json!({
"body": body,
"section": {"from": section.from, "quadrilaterals": section.quads.len(), "nodes": n_src, "shared_nodes": section.labels.iter().flatten().count()},
"layers": m.layers,
"kind": "hex8",
"elements": m.hexes.len(),
"new_nodes": fresh,
"quality": {"min_scaled_jacobian": m.min_scaled_jacobian, "mean_scaled_jacobian": m.mean_scaled_jacobian},
"volume": m.gate.volume,
"max_target_gap": m.max_target_gap,
"nodes": nodes,
"model_elements": elements,
});
Ok(cleared.report(s, reply))
}
#[cfg(test)]
mod tests;
+185
View File
@@ -0,0 +1,185 @@
use super::*;
use crate::tests::{TempDir, design_file};
fn open(commands: Value) -> (TempDir, Session) {
let bytes = fieldcraft_geometry::script_design(&json!({ "commands": commands }).to_string()).unwrap();
let (dir, path) = design_file(&bytes);
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path})).unwrap();
(dir, s)
}
/// A square post whose top is cut by a cylinder (axis X, radius 20, at z = 30), a square post
/// whose top is a sphere cap (centre z = −5, radius 25), and a block under the first post.
fn parts() -> (TempDir, Session) {
open(json!([
{"command": "solid.box", "params": {"corner": [-4, -4, 0], "length": 8, "width": 8, "height": 30, "body_name": "Post"}},
{"command": "solid.cylinder", "params": {"base": [-20, 0, 30], "axis": [1, 0, 0], "radius": 20, "height": 40, "body_name": "Bar", "operation": "new"}},
{"command": "solid.combine", "params": {"target": "Post", "tools": ["Bar"], "operation": "cut"}},
{"command": "solid.box", "params": {"corner": [-6, -6, 0], "length": 12, "width": 12, "height": 30, "body_name": "Dome", "operation": "new"}},
{"command": "solid.sphere", "params": {"center": [0, 0, -5], "radius": 25, "body_name": "Ball", "operation": "new"}},
{"command": "solid.combine", "params": {"target": "Dome", "tools": ["Ball"], "operation": "intersect"}},
{"command": "solid.box", "params": {"corner": [-4, -4, -10], "length": 8, "width": 8, "height": 10, "body_name": "Base", "operation": "new"}},
]))
}
/// `{"body", "face"}` of each face of `body` whose surface `pick` accepts.
fn faces(s: &Session, body: &str, pick: impl Fn(&FaceSurface) -> bool) -> Vec<Value> {
let b = s.design.as_deref().unwrap().body(body).unwrap();
b.faces()
.iter()
.zip(b.face_surfaces().unwrap())
.filter(|(_, f)| f.as_ref().is_some_and(&pick))
.map(|(f, _)| json!({"body": body, "face": f.name}))
.collect()
}
fn plane(n: [f64; 3]) -> impl Fn(&FaceSurface) -> bool {
move |f| matches!(f, FaceSurface::Plane { normal, .. } if (0..3).all(|k| (normal[k] - n[k]).abs() < 1e-9))
}
fn sides(s: &Session, body: &str) -> Vec<Value> {
faces(s, body, |f| matches!(f, FaceSurface::Plane { normal, .. } if normal[2].abs() < 1e-9))
}
/// The coordinates of the nodes on face `face` (through a set by geometry).
fn nodes_on(s: &mut Session, name: &str, face: &Value) -> Vec<[f64; 3]> {
s.execute("model.set.create", &json!({"name": name, "geometry": [face]})).unwrap();
let r = s.model.resolve_set(name).unwrap();
r.nodes.iter().map(|n| s.model.nodes()[n].xyz).collect()
}
#[test]
fn a_square_post_extrudes_up_to_a_cylinder() {
let (_d, mut s) = parts();
let bottom = faces(&s, "Post", plane([0.0, 0.0, -1.0]));
let top = faces(&s, "Post", |f| matches!(f, FaceSurface::Cylinder { .. }));
let r = s
.execute("mesh.hex.extrude", &json!({"source": bottom, "to_surface": top, "sides": sides(&s, "Post"), "direction": [0, 0, 1], "size": 1.0}))
.unwrap();
assert_eq!((r["body"].as_str(), r["section"]["from"].as_str()), (Some("Post"), Some("meshed")), "{r}");
assert_eq!(r["layers"], 10, "{r}");
let min_sj = r["quality"]["min_scaled_jacobian"].as_f64().unwrap();
assert!(min_sj >= 0.5, "{r}");
assert!(r["max_target_gap"].as_f64().unwrap() < 1e-6, "{r}");
// ∫∫ (30 − √(400 − y²)) dx dy over the 8 × 8 square.
let exact = 8.0 * (30.0 * 8.0 - (4.0 * 384.0f64.sqrt() + 400.0 * 0.2f64.asin()));
let volume = r["volume"].as_f64().unwrap();
assert!((volume - exact).abs() < 1e-3 * exact, "{volume} vs {exact}");
// Every node of the last layer is on the cylinder: the target faces are tagged.
let on = nodes_on(&mut s, "Top", &top[0]);
assert_eq!(on.len() as u64, r["section"]["nodes"].as_u64().unwrap(), "one node per section node");
for p in on {
let radius = (p[1].powi(2) + (p[2] - 30.0).powi(2)).sqrt();
assert!((radius - 20.0).abs() < 1e-6, "{p:?}: {radius}");
}
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["invalid"]["count"], 0, "{q}");
}
#[test]
fn a_square_post_extrudes_up_to_a_sphere_cap_along_its_normal() {
let (_d, mut s) = parts();
let bottom = faces(&s, "Dome", plane([0.0, 0.0, -1.0]));
let cap = faces(&s, "Dome", |f| matches!(f, FaceSurface::Sphere { .. }));
let r = s
.execute("mesh.hex.extrude", &json!({"source": bottom, "to_surface": cap, "sides": sides(&s, "Dome"), "layers": 8, "bias": 0.5, "size": 1.5}))
.unwrap();
assert_eq!(r["layers"], 8, "{r}");
assert!(r["quality"]["min_scaled_jacobian"].as_f64().unwrap() >= 0.5, "{r}");
let on = nodes_on(&mut s, "Cap", &cap[0]);
assert!(on.len() > 50, "{}", on.len());
for p in on {
let radius = (p[0].powi(2) + p[1].powi(2) + (p[2] + 5.0).powi(2)).sqrt();
assert!((radius - 25.0).abs() < 1e-6, "{p:?}: {radius}");
}
}
#[test]
fn the_extrusion_conforms_with_the_neighbour_it_starts_from() {
let (_d, mut s) = parts();
s.execute("mesh.hex.generate", &json!({"body": "Base", "size": 2.0})).unwrap();
let base_nodes = s.model.nodes().len();
let bottom = faces(&s, "Post", plane([0.0, 0.0, -1.0]));
let top = faces(&s, "Post", |f| matches!(f, FaceSurface::Cylinder { .. }));
let r = s
.execute("mesh.hex.extrude", &json!({"source": bottom, "to_surface": top, "sides": sides(&s, "Post"), "direction": [0, 0, 1], "size": 2.0}))
.unwrap();
assert_eq!(r["section"]["from"], "neighbour", "{r}");
assert_eq!(r["section"]["shared_nodes"], 25, "the base's 5 × 5 top nodes: {r}");
assert_eq!(s.model.nodes().len(), base_nodes + r["new_nodes"].as_u64().unwrap() as usize);
let d = s.execute("mesh.check.duplicates", &json!({})).unwrap();
assert_eq!((d["counts"]["coincident"].as_u64(), d["counts"]["cracks"].as_u64()), (Some(0), Some(0)), "{d}");
// The base keeps its mesh; extruding again replaces only the post's.
let base = s.model.body_elements("Base").len();
s.execute("mesh.hex.extrude", &json!({"source": bottom, "to_surface": top, "direction": [0, 0, 1], "layers": 3})).unwrap();
assert_eq!(s.model.body_elements("Base").len(), base);
assert_eq!(s.model.body_elements("Post").len(), 16 * 3);
}
#[test]
fn a_2d_mesh_extrudes_from_its_set() {
let (_d, mut s) = parts();
s.execute("mesh.planar", &json!({"polygon": [[-3, -3], [3, -3], [3, 3], [-3, 3]], "size": 1.0, "name": "Plane"})).unwrap();
s.execute("model.set.create", &json!({"name": "Section", "geometry": [{"body": "Plane", "face": "area"}]})).unwrap();
let top = faces(&s, "Post", |f| matches!(f, FaceSurface::Cylinder { .. }));
let r = s.execute("mesh.hex.extrude", &json!({"source_set": "Section", "to_surface": top, "body": "Column", "layers": 5})).unwrap();
assert_eq!(r["section"]["from"], "set", "{r}");
let quads = r["section"]["quadrilaterals"].as_u64().unwrap() as usize;
assert_eq!(r["section"]["shared_nodes"], r["section"]["nodes"], "{r}");
assert_eq!(s.model.body_elements("Column").len(), quads * 5);
assert!(r["quality"]["min_scaled_jacobian"].as_f64().unwrap() >= 0.5, "{r}");
}
#[test]
fn hostile_params_are_errors_and_change_nothing() {
let (_d, mut s) = parts();
let bottom = faces(&s, "Post", plane([0.0, 0.0, -1.0]));
let top = faces(&s, "Post", |f| matches!(f, FaceSurface::Cylinder { .. }));
let ok = json!({"source": bottom, "to_surface": top, "direction": [0, 0, 1], "layers": 2});
let with = |k: &str, v: Value| {
let mut p = ok.clone();
p[k] = v;
p
};
let without = |k: &str| {
let mut p = ok.clone();
p.as_object_mut().unwrap().remove(k);
p
};
let cases = [
without("to_surface"),
without("source"),
with("to_surface", json!([])),
with("to_surface", json!("F1")),
with("to_surface", json!([{"body": "Nope", "face": "F1"}])),
with("to_surface", json!([{"body": "Post", "face": "nope"}])),
with("to_surface", json!([{"body": "Post"}])),
with("to_surface", bottom.clone().into()),
with("source_set", json!("Nope")),
with("direction", json!([0, 0, 0])),
with("direction", json!([0, 0, -1])),
with("direction", json!([1, 0])),
with("direction", json!([f64::MAX, 0, 0])),
with("normal", json!(false)),
with("layers", json!(0)),
with("layers", json!(1e9)),
with("layers", json!(-3)),
with("bias", json!(0)),
with("bias", json!(1e9)),
with("bias", json!("x")),
with("size", json!(-1)),
with("tolerance", json!(1e-30)),
with("body", json!(7)),
with("sides", json!([{"body": "Dome", "face": "F4:-z"}])),
with("source", json!([{"body": "Post", "face": "F1:-x"}, {"body": "Post", "face": "F1:+x"}])),
// A curved face that is not meshed cannot be meshed here.
with("source", top.clone().into()),
];
let before = s.model.clone();
for (i, p) in cases.iter().enumerate() {
assert!(s.execute("mesh.hex.extrude", p).is_err(), "case {i}: {p}");
assert_eq!(s.model, before, "case {i} changed the model");
}
assert!(s.execute("mesh.hex.extrude", &ok).is_ok());
}
+9 -2
View File
@@ -231,7 +231,12 @@ impl Session {
}
});
if let Some(d) = &design {
warnings.extend(d.errors.iter().filter(|e| e.feature.starts_with("partition ")).map(|e| format!("{}: {}", e.feature, e.message)));
warnings.extend(
d.errors
.iter()
.filter(|e| e.feature.starts_with("partition ") || e.feature.starts_with("merge "))
.map(|e| format!("{}: {}", e.feature, e.message)),
);
}
sources.design = Some(source);
}
@@ -241,7 +246,7 @@ impl Session {
surfaces.extend(opened.into_iter().flatten().map(Arc::new));
sources.meshes.push(source);
}
let session = Session {
let mut session = Session {
model: file.model,
design,
surfaces,
@@ -252,6 +257,8 @@ impl Session {
view: file.view,
..Session::default()
};
let (_, failed) = super::stitch::restitch(&mut session);
warnings.extend(failed.into_iter().map(|(n, why)| format!("stitch `{n}`: {why}")));
(session, warnings)
}
+279 -34
View File
@@ -58,8 +58,14 @@ pub const SET: &[Field] = &[
labels("elements", "Elements", LabelOf::Element)
.help("Mesh element labels; Use selection takes the picked elements.")
.hint("labels: 1, 2, 10-20"),
json("faces", "Element faces").help("Element faces, as a JSON list of element label and local face number.").hint("[{\"element\", \"face\"}]"),
json("edges", "Element edges").help("Element edges, as a JSON list of element label and local edge number.").hint("[{\"element\", \"edge\"}]"),
sides("faces", "Element faces", Side::Face)
.help(
"Element faces as element:face (local face number), ranges of elements sharing one: 1-20:3, 25:1. Use selection takes the picked faces.",
)
.hint("element:face, e.g. 1-20:3, 25:1"),
sides("edges", "Element edges", Side::Edge)
.help("Element edges as element:edge (local edge number), ranges of elements sharing one: 1-20:3, 25:1.")
.hint("element:edge, e.g. 1-20:3"),
];
const ELASTIC: Field = variants(
@@ -186,18 +192,19 @@ const HYPERELASTIC: Field = variants(
.help("A large-strain rubber model (nonlinear steps), built on the isotropic constants.")
.hint("large strain in nonlinear steps, from the isotropic constants");
/// The material axes 1, 2, 3 (the global X, Y, Z until material orientations land).
const AXES_123: &[&str] = &["1", "2", "3"];
const THERMAL: Field = group(
"thermal",
"Thermal",
&[
json("conductivity", "Conductivity")
.help("Thermal conductivity, one value or one per axis. Steel ≈ 50 W/(m·K), i.e. 50 in mm-t-s-N.")
.hint("k or [k1, k2, k3]"),
per_axis("conductivity", "Conductivity", D::Conductivity, Some(0.0), AXES_123)
.help("Thermal conductivity, one value or one per material axis. Steel ≈ 50 W/(m·K), i.e. 50 in mm-t-s-N."),
positive("specific_heat", "Specific heat", D::SpecificHeat)
.help("Heat that raises a unit mass by one degree. Steel ≈ 460 J/(kg·K), 4.6e8 in mm-t-s."),
json("expansion", "Expansion")
.help("Thermal expansion coefficient, one value or one per axis, in 1/K. Steel ≈ 1.2e-5, aluminium ≈ 2.3e-5.")
.hint("α or [α1, α2, α3], 1/K"),
per_axis("expansion", "Expansion", D::Expansion, None, AXES_123)
.help("Thermal expansion coefficient, one value or one per material axis. Steel ≈ 1.2e-5, aluminium ≈ 2.3e-5."),
num("reference_temperature", "Reference temperature", D::Temperature).help("The temperature at which there is no thermal strain.").def("0"),
],
)
@@ -371,7 +378,9 @@ pub const COUPLING: &[Field] = &[
pub const MASS: &[Field] = &[
NAME,
TARGET,
positive("mass", "Mass", D::Mass).help("A lumped mass at each node of the target.").hint("per node; [mx, my, mz] in JSON"),
per_axis("mass", "Mass", D::Mass, Some(0.0), XYZ)
.help("A lumped mass at each node of the target: one value, or one per direction X, Y, Z.")
.hint("per node"),
vector("inertia", "Inertia", D::Inertia, &["I11", "I22", "I33", "I12", "I13", "I23"])
.help("Rotary inertia about the reference point: three moments and three products.")
.hint("about the node; needs a reference point"),
@@ -581,6 +590,24 @@ const FACE_CONDITION: &[Variant] = &[
variant("periodic", "Periodic", &[]),
];
const BOX_FACE: Field =
choice("", "Box face", &[("x_min", "X min"), ("x_max", "X max"), ("y_min", "Y min"), ("y_max", "Y max"), ("z_min", "Z min"), ("z_max", "Z max")])
.help("A face of the domain's box, at the smallest or largest X, Y or Z.")
.req();
const OBSTACLE_FACE_FIELDS: &[Field] = &[
reference("body", "Body", C::Body).help("An obstacle body of the design.").req(),
text("face", "Face").help("A persistent face name of the body (e.g. F3); it also selects the face's split pieces.").req(),
];
const OBSTACLE_FACE: Field = group("face", "Face", OBSTACLE_FACE_FIELDS).help("The obstacle face.").req();
const SURFACE_CONDITION: &[Variant] = &[
variant("wall", "Wall at rest", &[]),
variant(
"moving_wall",
"Moving wall",
&[xyz("velocity", "Velocity", D::Velocity).help("The wall's tangential velocity; the body itself does not move.").req()],
),
];
const fn face(key: &'static str, label: &'static str) -> Field {
variants(key, label, FACE_CONDITION).hint("wall")
}
@@ -607,9 +634,16 @@ pub const FLUID_DOMAIN: &[Field] = &[
)
.help("The condition on each face of the box."),
references("obstacles", "Obstacles", C::Body).help("Bodies of the design the flow goes around."),
json("surfaces", "Obstacle faces")
.help("Conditions on single obstacle faces, as JSON; walls by default.")
.hint("[{\"face\", \"condition\"}]; walls by default"),
rows(
"surfaces",
"Obstacle faces",
&[
OBSTACLE_FACE,
variants("condition", "Condition", SURFACE_CONDITION).help("The flow condition on that face.").req().def(r#"{"type": "wall"}"#),
],
)
.help("Conditions on single obstacle faces; every other obstacle face is a wall at rest.")
.hint("walls by default"),
xyz("initial_velocity", "Initial velocity", D::Velocity).help("The fluid's velocity at the start."),
xyz("acceleration", "Acceleration", D::Acceleration).help("A body acceleration on the fluid, such as gravity."),
positive("tolerance", "Obstacle tolerance", D::Length)
@@ -619,11 +653,10 @@ pub const FLUID_DOMAIN: &[Field] = &[
pub const FLUID_BOUNDARY: &[Field] = &[
reference("domain", "Domain", C::FluidDomain).help("The fluid domain to change.").req(),
json("at", "Boundary")
.help("A box face (x_min … z_max) or an obstacle face, as JSON.")
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\"")
.hint("\"x_min\" … \"z_max\", or {\"body\", \"face\"}"),
.def("\"x_min\""),
variants("condition", "Condition", FACE_CONDITION).help("The flow condition on that boundary.").req().def(r#"{"type": "velocity"}"#),
];
@@ -661,9 +694,9 @@ const PROCEDURE: Field = variants(
flag("line_search", "Line search")
.help("Scale each Newton correction to reduce the residual; helps with hard convergence.")
.def("false"),
json("load_path", "Load path")
.help("How the load factor varies through the step, as JSON pairs of step fraction and factor; a linear ramp when empty.")
.hint("[[fraction, factor], …]; a ramp when empty"),
table("load_path", "Load path", D::None, &["fraction", "factor"], fieldcraft_model::MAX_LOAD_PATH_POINTS)
.help("How the load factor varies through the step: rows of step fraction (0 to 1) and load factor; a linear ramp when empty.")
.hint("a ramp when empty"),
group(
"imperfection",
"Imperfection",
@@ -684,9 +717,18 @@ const PROCEDURE: Field = variants(
.help("Cylindrical: displacements only. Spherical: displacements and load factor.")
.def("\"cylindrical\""),
positive("max_load_factor", "Max load factor", D::None).help("Stop when the load factor reaches this.").def("1"),
json("displacement_limit", "Displacement limit")
.help("Stop when a node's displacement reaches a value, as JSON.")
.hint("{\"node\": 1, \"direction\": 3, \"displacement\": -10}"),
group(
"displacement_limit",
"Displacement limit",
&[
int("node", "Node", 1, u32::MAX as i64).help("The label of the node watched.").req(),
int("direction", "Direction", 1, 3).help("1, 2, 3: its translation along X, Y, Z.").req().def("3"),
num("displacement", "Displacement", D::Length)
.help("The step stops when that translation reaches this size (its sign is ignored).")
.req(),
],
)
.help("Stop when a node's displacement reaches a value; at the max load factor when empty."),
],
)
.help("Follow the load-displacement path through limit points (snap-through), with the load factor as an unknown."),
@@ -1127,21 +1169,69 @@ pub const CELLS: &[Field] = &[
HEX_SIZE,
HEX_ORDER,
TOLERANCE,
choice("transition", "Hex–tet joint", &[("pyramid", "Pyramids (conforming)"), ("tie", "Tie")])
.help("How a tet cell meets the hex cells across its partitions: a layer of pyramids on the hex faces, one conforming mesh (linear elements), or a tie between the two meshes.")
.def("\"pyramid\""),
];
pub const HEX_PROJECT: &[Field] = &[
reference("body", "Body", C::Body).help("The body to mesh with a projected block topology.").req(),
HEX_SIZE,
choice(
"template",
"Template",
&[("grid", "Block grid (carve)"), ("sphere", "Sphere (7 blocks)"), ("butterfly", "Butterfly (5-block disc, extruded)"), ("blocks", "Blocks (given vertices)")],
)
.help("The block topology projected onto the body: a grid of blocks you may carve, the seven-block sphere, the five-block butterfly for round sections, or blocks you list.")
.def("\"grid\""),
xyz("center", "Centre", POINT).help("Sphere or butterfly centre.").hint("from the body's bounding box"),
positive("radius", "Radius", D::Millimetre).help("Sphere or butterfly radius.").hint("from the body"),
xyz("axis", "Axis", D::None).help("Butterfly axis direction.").hint("0, 0, 1"),
json("levels", "Levels")
.help("Sphere and butterfly levels; for a butterfly, distances along the axis from the centre, as JSON.")
.hint("[z0, z1, ...]; from the bounding box"),
range("inset", "Inset", D::None, 0.05, 0.95).help("Inner square or cube corner at this fraction of the radius.").def("0.55"),
json("grid", "Grid").help("The block grid: planes per axis or a cell count, and blocks to carve away, as JSON.").hint(r#"{"cells": [ni, nj, nk], "carve": [{"i": [a, b], "j": [c, d], "k": [e, f]}]}"#),
json("blocks", "Blocks").help("Template Blocks: vertices and hex8 blocks, as JSON.").hint(r#"{"vertices": [[x, y, z], ...], "blocks": [[v1..v8], ...]}"#),
json("operations", "Operations").help("O-grids, extrusions, connections, removals and merges applied in order, as JSON.").hint(r#"[{"op": "o_grid", "block": 0, "open": ["+z"]}]"#),
json("faces", "Face targets").help("Block faces sent to chosen B-rep faces; other outer faces go to the nearest surfaces.").hint(r#"[{"block": 0, "face": "+x", "to": ["F1:+x"]}]"#),
json("divisions", "Divisions").help("Element counts per block direction.").hint(r#"[{"block": 0, "direction": 1, "count": 8}]"#),
json("fixed", "Fixed faces")
.help("Faces of the body another body's mesh already covers: its element faces there are kept as they are and this mesh meets them node to node.")
.hint(r#"["F3:partition:P:b"]"#),
choice("smoothing", "Smoothing", &[("elliptic", "Elliptic"), ("elliptic-controlled", "Elliptic, controlled (Thomas–Middlecoff)"), ("laplace", "Laplace"), ("none", "None")])
.help("How interior nodes are smoothed after projection, with the boundary held on the geometry.")
.def("\"elliptic\""),
int("iterations", "Iterations", 1, 100_000).help("Smoothing iterations.").def("100"),
range("min_quality", "Minimum quality", D::None, 0.0, 1.0).help("Smallest scaled Jacobian accepted.").def("0.2"),
range("max_arc_angle", "Max arc angle", D::Degree, 0.0, 90.0)
.help("The most a curved face may turn across one element; the divisions grow to keep it. 0 for no limit.")
.def("30"),
range("merge", "Merge distance", D::Millimetre, 0.0, 1.0e9)
.help("Join this mesh's nodes with other bodies' meshes within this distance; 0 never.")
.hint("1e-6 of the diagonal"),
flag("preview", "Preview only").help("Return the block topology without meshing."),
TOLERANCE,
];
pub const CLEAR_MESH: &[Field] = &[references("bodies", "Bodies", C::Body)
.help("The bodies whose meshes go. When none is ticked: the meshes of bodies the open design no longer has because its partitions changed (model.check lists them).")
.hint("the bodies partitions removed")];
const MIRROR_DATUM: Field = reference("", "Datum plane", C::DatumPlane).help("A datum plane of the model.");
const MIRROR_POINT_NORMAL: &[Field] = &[
xyz("point", "Point", D::Length).help("A point on the plane.").req(),
xyz("normal", "Normal", D::None).help("The plane's normal; its length does not matter.").req(),
];
pub const MIRROR: &[Field] = &[
json("plane", "Plane")
.help("The mirror plane: a datum plane's name, or a point and normal as JSON.")
.req()
.hint(r#""Datum" or {"point": [x, y, z], "normal": [nx, ny, nz]}"#),
name_or("plane", "Plane", &MIRROR_DATUM, "Point and normal", MIRROR_POINT_NORMAL)
.help("The mirror plane: a datum plane, or a point on it and its normal.")
.req(),
references("bodies", "Bodies", C::Body).help("The meshed bodies to mirror; every meshed body when empty.").hint("every meshed body"),
json("as", "Images")
.help("Names for the mirrored bodies, as JSON.")
.hint(r#"{"body": "image body"}; default: the design's body at the reflected place"#),
reference_text("as", "Images", C::Body)
.help("The body each mirrored body becomes; when empty, the design's body at the reflected place, else <body>/mirror.")
.hint("default"),
];
pub const SYMMETRY: &[Field] = &[
@@ -1165,6 +1255,37 @@ pub const HEX_OCTREE: &[Field] = &[
FEATURE_ANGLE,
];
/// A face by body and persistent name, a row of a face list.
const FACE_ROW: &[Field] = &[
reference("body", "Body", C::Body).help("A body of the open design.").req(),
text("face", "Face").help("A persistent face name of the body (e.g. F3).").req().hint("persistent face name"),
];
const fn face_rows(key: &'static str, label: &'static str) -> Field {
rows(key, label, FACE_ROW)
}
pub const HEX_EXTRUDE: &[Field] = &[
face_rows("source", "Source")
.help("The section: faces already meshed (by this body or a neighbour, whose nodes are shared), or one planar face meshed here. Pick it, then Use as source.")
.hint("pick the source face"),
reference("source_set", "Source set", C::Set).help("Instead of Source: a set of quadrilaterals (a 2D mesh, or hex element faces)."),
face_rows("to_surface", "Target surface")
.help("The faces the last layer lies on. Pick them, then Use as target surface.")
.req()
.hint("pick the target surface"),
face_rows("sides", "Side faces").help("The faces the section's sides follow (they need not be straight); straight paths when empty.").hint("straight sides"),
xyz("direction", "Direction", D::None).help("The direction every node moves along; along the section's normal toward the target when empty.").hint("the section's normal"),
flag("normal", "Along the normal").help("Each node along the section's normal toward the target (the default when no direction is given)."),
int("layers", "Layers", 1, 10_000).help("The number of element layers; from Size when empty.").hint("from the size"),
positive("size", "Size", D::Length).help("Layer thickness, and the element size of a section meshed here.").hint("1/25 of the body"),
range("bias", "Bias", D::None, 0.001, 1000.0).help("The last layer's thickness over the first's: below 1 grades toward the target, above 1 toward the source.").def("1"),
reference("body", "Body", C::Body).help("The body the elements fill; its mesh is replaced.").hint("the source face's body"),
TOLERANCE,
];
/// A block's edge directions, from corner 1.
const BLOCK_DIRECTIONS: &[&str] = &["1-2", "1-4", "1-5"];
pub const BLOCK: &[Field] = &[
text("body", "Body").help("Name of the new block body.").req().def("\"Block\""),
choice(
@@ -1186,8 +1307,12 @@ pub const BLOCK: &[Field] = &[
.req()
.def("\"hex8\""),
points("corners", "Corners", D::Length, 8).help("The eight corner points (four for 2D, at z = 0).").req(),
json("divisions", "Divisions").help("Elements along each edge direction, as JSON.").req().hint("[n12, n14, n15] (2D: [n12, n14])"),
json("bias", "Bias").help("Element size ratio along each direction, as JSON; 1 is uniform.").hint("[1, 1, 1]"),
tuple("divisions", "Divisions", BLOCK_DIRECTIONS, 2, true)
.help("Elements along each edge direction: corner 1 to 2, 1 to 4 and 1 to 5; a 2D block takes the first two.")
.req(),
tuple("bias", "Bias", BLOCK_DIRECTIONS, 2, false)
.help("Last over first element size along each direction; 1 is uniform. A 2D block takes the first two.")
.hint("uniform"),
json("edges", "Curved and named edges").help("Edge curves and names, as JSON."),
json("faces", "Face names").help("Names of the block's faces, as JSON.").hint("{\"1\": \"bottom\"}"),
json("vertices", "Corner names").help("Names of the block's corners, as JSON."),
@@ -1214,6 +1339,25 @@ pub const QUALITY: &[Field] = &[
int("bins", "Histogram bins", 1, 1000).help("Number of bars in the quality histogram.").def("10"),
];
pub const QUALITY_MARK: &[Field] = &[
labels("elements", "Elements", LabelOf::Element).help("The elements to check; all when empty.").hint("all"),
group(
"limits",
"Limits",
&[
num("scaled_jacobian", "Scaled Jacobian at least", D::None)
.help("Elements with a lower scaled Jacobian are marked (1 is a perfect element).")
.hint("0.2"),
num("min_dihedral", "Min dihedral at least", D::Degree).help("Tetrahedra with a smaller dihedral angle are marked.").hint("10"),
num("max_dihedral", "Max dihedral at most", D::Degree).help("Tetrahedra with a larger dihedral angle are marked.").hint("165"),
num("aspect_ratio", "Aspect ratio at most", D::None).help("Elements with a larger aspect ratio are marked.").hint("10"),
num("min_angle", "Min angle at least", D::Degree).help("Shells with a smaller corner angle are marked.").hint("15"),
num("max_angle", "Max angle at most", D::Degree).help("Shells with a larger corner angle are marked.").hint("165"),
],
)
.help("Elements worse than a limit are marked; a limit left empty keeps its default."),
];
pub const SURFACE_CHECK: &[Field] = &[
reference("body", "Body", C::Body).help("The body whose surface to check.").req().hint("or an imported surface"),
SURFACE,
@@ -1243,6 +1387,7 @@ pub const ELEMENT_ADD: &[Field] = &[
("tet4", "tet4"),
("tet10", "tet10"),
("wedge6", "wedge6"),
("pyramid5", "pyramid5"),
("hex8", "hex8"),
("hex20", "hex20"),
],
@@ -1270,9 +1415,18 @@ pub const NODE_RELAX: &[Field] = &[
int("around_element", "Around element", 1, 99_999_999)
.help("Instead of a centre: a sphere round this element (radius default twice its longest edge).")
.hint("none"),
face_rows("faces", "Faces")
.help("Instead of a sphere: every node on these CAD faces and the elements touching them, nodes sliding on the faces. Shift-click faces in the view, then Use selection.")
.hint("Shift-click faces"),
labels("nodes", "Nodes", LabelOf::Node).help("Instead of a sphere: these nodes.").hint("none"),
labels("elements", "Elements", LabelOf::Element).help("Instead of a sphere: the nodes of these elements.").hint("none"),
references("bodies", "Bodies", C::Body).help("Only the nodes of these bodies.").hint("every body"),
int("iterations", "Iterations", 1, 100).help("Sweeps over the nodes.").def("5"),
choice("mode", "Mode", &[("guarded", "Guarded (local)"), ("strong", "Strong (optimise the region)")])
.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"),
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")])
.help("How the strength falls from the centre to the radius, so the edit blends into the mesh.")
@@ -1340,8 +1494,8 @@ pub const TAG: &[Field] = &[
.help("The geometry the mesh entities are tagged with.")
.req(),
labels("elements", "Elements", LabelOf::Element).help("Element labels."),
json("faces", "Element faces").help("Element faces, as JSON."),
json("edges", "Element edges").help("Element edges, as JSON."),
sides("faces", "Element faces", Side::Face).help("Element faces as element:face: 1-20:3, 25:1.").hint("element:face"),
sides("edges", "Element edges", Side::Edge).help("Element edges as element:edge: 1-20:3, 25:1.").hint("element:edge"),
labels("nodes", "Nodes", LabelOf::Node).help("Node labels."),
];
@@ -1565,6 +1719,64 @@ pub const PARTITION: &[Field] = &[
references("bodies", "Bodies", C::Body).help("The bodies to divide; every body the plane cuts when empty.").hint("every body the plane divides"),
];
pub const PARTITION_DELETE: &[Field] = existing!("Partition", C::Partition);
pub const FACES_MERGE: &[Field] = &[
NAME,
face_rows("faces", "Faces")
.help("Two or more adjacent faces of one body, meshed as one face. Shift-click faces in the view, then Use selection.")
.req()
.hint("Shift-click faces"),
];
pub const FACE_ABSORB: &[Field] = &[
text("name", "Name").help("The absorption's name (with From check: the prefix of each one's, Name-1, Name-2, …).").hint("Absorb"),
group("face", "Face", FACE_ROW).help("The tiny or sliver face to absorb; leave empty with From check."),
text("into", "Into").help("The neighbour face that absorbs it, by persistent name.").hint("the neighbour sharing the longest edges"),
flag("from_check", "From check").help("Absorb every small and sliver face the last Check Geometry found, each into its best neighbour."),
];
pub const STITCH: &[Field] = &[
NAME,
text("surface", "Surface").help("The imported surface to stitch, by name.").req(),
positive("tolerance", "Tolerance", D::Length)
.help("Gaps narrower than this close: open points this close become one, and a point this close to another open edge splits it.")
.req(),
];
pub const STITCH_DELETE: &[Field] = existing!("Stitch", C::Stitch);
pub const FACE_SPLIT: &[Field] = &[
NAME,
group("face", "Face", FACE_ROW).help("The face to split.").req(),
reference("datum", "Datum plane", C::DatumPlane).help("The plane the face is split along.").req(),
];
pub const FACE_SPLIT_DELETE: &[Field] = existing!("Face split", C::FaceSplit);
pub const FACES_MERGE_DELETE: &[Field] = existing!("Face merge", C::FaceMerge);
pub const MESH_PLAN: &[Field] = &[
reference("body", "Body", C::Body).help("The body of the design to plan (as opened, not a partition cell).").req(),
text("name", "Name").help("The name the plan is saved under.").hint("Plan-<body>"),
positive("size", "Element size", D::Millimetre).help("The largest element size; thin walls make it smaller.").hint("1/25 of the diagonal"),
int("layers", "Layers through walls", 1, 10).help("Elements through the thinnest wall the features give (a shell, a plate).").hint("2"),
int("max_elements", "Most elements", 1, 5_000_000).help("The size grows past the walls' when more elements would be needed.").hint("300000"),
];
pub const MESH_PLAN_APPLY: &[Field] = &[
text("plan", "Plan").help("The name of a saved mesh plan (mesh.plan).").req(),
flag("symmetry", "Use symmetry")
.help("Mesh one cell of a body that is its own mirror image across planes through its middle, and mirror it.")
.def("true"),
flag("corners", "Copy corners")
.help("With symmetry: when the body's two ends along another axis are alike (an enclosure's corners), mesh one end and mirror it onto the other, the middle meshed to fit both.")
.def("true"),
text("name", "Save as").help("The name a plan given as an object is saved under.").hint("Plan-<body>"),
];
pub const FEATURES: &[Field] = &[reference("body", "Body", C::Body).help("Only this body's features.").hint("every body")];
pub const GEOMETRY_CHECK: &[Field] = &[
reference("body", "Body", C::Body).help("The body to check; every body when empty.").hint("every body"),
positive("size", "Element size", D::Millimetre)
.help("The element size you intend to mesh with; features are small against it.")
.hint("1/20 of the diagonal"),
range("short_edge", "Short edge", D::None, 0.0, 10.0).help("Edges shorter than this fraction of the size are reported.").def("0.3"),
range("small_face", "Small face", D::None, 0.0, 10.0).help("Faces with area below this fraction of size² are reported.").def("0.1"),
range("sliver", "Sliver width", D::None, 0.0, 10.0).help("Faces narrower than this fraction of the size are reported.").def("0.3"),
range("gap", "Gap", D::None, 0.0, 10.0).help("Parallel faces closer than this fraction of the size, but not touching, are reported.").def("0.3"),
range("parallel_angle", "Parallel within", D::Degree, 0.0, 60.0).help("Faces this close to parallel count for the gap.").def("10"),
flag("mark", "Mark findings").help("Mark what is found in the viewport until the design changes.").def("true"),
];
pub const TESSELLATE: &[Field] = &[reference("body", "Body", C::Body).help("The body to tessellate.").req(), TOLERANCE];
pub const IMPORT_MESH: &[Field] = &[path("path", "3MF file", false).help("A 3MF surface mesh with named faces.").req()];
pub const FILE_OPEN: &[Field] = &[path("path", "Model", false).help("The FieldCraft model file to open.").req()];
@@ -1634,6 +1846,12 @@ pub const DECK: &[Field] = &[
references("steps", "Steps", C::Step).help("The steps to write; all when empty.").hint("all"),
];
pub const EXPORT_STL: &[Field] = &[
path("path", "File", true).help("Where to write the STL file.").req(),
reference("set", "Set", C::Set).help("Write only this set's element faces; the whole outer surface when empty.").hint("the outer surface"),
choice("format", "Format", &[("binary", "Binary"), ("ascii", "ASCII")]).help("Binary is compact; ASCII is readable text.").def("\"binary\""),
];
// ---- Solving ----
const STEP: Field = reference("step", "Step", C::Step).help("The step to solve; the only one when empty.").hint("the only one");
@@ -1762,6 +1980,29 @@ pub const EXPORT_CSV: &[Field] = &[
int("mode", "Mode", 1, 1_000_000).help("Probe: the mode to read, for frequency and buckling steps."),
int("frame", "Frame", 0, 1_000_000_000).help("Probe: the frame to read, for explicit and transient steps."),
];
pub const RESULTS_XY: &[Field] = &[
SOLVED,
json("y", "Quantity")
.help("What to plot: a nodal field at a node, an explicit energy or history, a history node's quantity, or \"frequency\".")
.hint(r#"{"field": "displacement_z", "node": 21}"#)
.req(),
choice("x", "Against", &[("auto", "The step's own axis"), ("load_factor", "Load factor"), ("time", "Time"), ("mode", "Mode")])
.help("The x axis: the load factor (nonlinear static), time (explicit, heat) or mode number (frequency).")
.def("\"auto\""),
int("every", "Keep one in", 1, 1_000_000_000).help("Keep one point in this many; by default enough to fit 100000 points."),
path("path", "CSV file", true).help("Also write the series as a two-column CSV."),
];
pub const EXPORT_VTU: &[Field] = &[
path("path", "File", true).help("A .vtu file for one state, or a .pvd collection for every frame or mode.").req(),
SOLVED,
int("mode", "Mode", 1, 1_000_000).help("Frequency steps: the mode to write; every mode in a .pvd when empty.").hint("all (.pvd) or 1"),
choice("frames", "Frames", &[("all", "Every frame"), ("last", "The last frame")])
.help("A .pvd series: every frame or only the last.")
.def("\"all\""),
choice("encoding", "Encoding", &[("ascii", "Text"), ("binary", "Binary (base64)")])
.help("Text is readable; binary is smaller and exact.")
.def("\"ascii\""),
];
pub const REACTIONS: &[Field] = &[
SOLVED,
references("sets", "Sets", C::Set)
@@ -1933,6 +2174,10 @@ mod tests {
}
match f.kind {
Kind::Group(g) | Kind::Rows { fields: g, .. } | Kind::Either(g) => walk(id, g, missing),
Kind::NameOr { name, fields, .. } => {
walk(id, std::slice::from_ref(name), missing);
walk(id, fields, missing);
}
Kind::Variants { variants, .. } => variants.iter().for_each(|v| walk(id, v.fields, missing)),
_ => {}
}
+273 -3
View File
@@ -33,6 +33,16 @@ pub const BODIES: CommandSpec = CommandSpec {
run: bodies,
};
pub const FEATURES: CommandSpec = CommandSpec {
id: "geometry.features",
label: "Feature Graph",
params: r#"{"body"?: "Base" (default: every body)} — the features of the SolidCraft timeline that built each body of the open design, in order (read-only): each feature's `id` (`F<id>` in persistent names), `name`, `type` (extrude, box, cylinder, hole, shell, fillet, combine, pattern, mirror, split, …), `operation` (new_body, join, cut, intersect), `values` (its numeric inputs evaluated: mm, radians, counts), `placement` (a primitive's corner, base or centre), `direction` (an extrude's along the sketch normal, a cylinder's axis, a hole's drilling direction), `two_sided`, `sketch` (plane origin, axes and normal, and the profiles used: lines, arcs and circles in sketch coordinates, holes, curve ids), `transforms` (pattern and mirror copies, 4×4 row-major), `targets` and `tools`, the stored `definition`, the persistent `faces` and `edges` of the body it made, and `error` when it failed to rebuild. A partition cell is described by the body it was cut from"#,
form: Some(super::forms::FEATURES),
kind: CommandKind::Model,
requires: &[Requirement::Design],
run: features,
};
pub const CENTRE: CommandSpec = CommandSpec {
id: "geometry.centre",
label: "Centre of Selection",
@@ -43,6 +53,16 @@ pub const CENTRE: CommandSpec = CommandSpec {
run: centre,
};
pub const CHECK: CommandSpec = CommandSpec {
id: "geometry.check",
label: "Check Geometry",
params: r#"{"body"?: "Body1" (default: every body of the open design), "size"?: mm (the target element size; default a twentieth of the smallest checked body's bounding-box diagonal), "short_edge": 0.3, "small_face": 0.1, "sliver": 0.3, "gap": 0.3 (fractions of the size, 0..=10), "parallel_angle": 10 (degrees, 0..=60)} — before meshing, finds what is small against the size (read-only): `short_edge` edges shorter than short_edge × size, `small_face` faces with area below small_face × size², `sliver_face` faces narrower (2 × area / perimeter) than sliver × size, and `near_faces` pairs of faces within parallel_angle of parallel that are closer than gap × size without touching (a gap between bodies or cells, an overlap, or a wall thinner than the gap; faces within twice the tessellation tolerance touch). Replies `size`, `counts` per kind, `summary`, and `findings` (by kind, worst first, at most 1000; `truncated` says when more were found), each with `kind`, `body`, `edge`, `faces` (persistent names; near faces: the second on `other_body`), `value` (mm, mm²), `limit` and `at` (a point on it). `mark` (default true): the viewport and pictures mark the findings in the marker colour (their faces, short edges as lines, and a dot at each `at`) until the design changes or a check with `mark: false` or nothing found"#,
form: Some(super::forms::GEOMETRY_CHECK),
kind: CommandKind::Model,
requires: &[Requirement::Design],
run: check,
};
pub const TESSELLATE: CommandSpec = CommandSpec {
id: "geometry.tessellate",
label: "Tessellate Body",
@@ -170,6 +190,16 @@ fn bodies(s: &mut Session, _: &Value) -> Result<Value> {
Ok(Value::Array(out))
}
fn features(s: &mut Session, p: &Value) -> Result<Value> {
let body = match p.get("body") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_str().ok_or_else(|| EngineError::param("body", ProblemKind::NotAName))?),
};
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
let graph = design.features(body)?;
Ok(json!({"bodies": graph}))
}
/// Most geometry items and nodes `geometry.centre` takes.
const MAX_CENTRE_ITEMS: usize = 10_000;
@@ -363,6 +393,105 @@ pub(crate) fn body_mesh<'a>(s: &'a Session, p: &Value) -> Result<(&'a Shape, Sur
Ok((shape, shape.tessellate(tolerance)?))
}
/// Most findings `geometry.check` lists.
const MAX_FINDINGS: usize = 1000;
fn check(s: &mut Session, p: &Value) -> Result<Value> {
use fieldcraft_geometry::check::{self, CheckOptions, Kind};
let shapes: Vec<&Shape> = match p.get("body") {
Some(_) => vec![body(s, p)?],
None => s.design.as_deref().map(|d| d.bodies.iter().collect()).unwrap_or_default(),
};
if shapes.is_empty() {
return Err(EngineError::Params("the design has no bodies to check".into()));
}
let size = match p.get("size") {
Some(v) => v.as_f64().filter(|x| *x > 0.0 && x.is_finite()).ok_or_else(|| EngineError::param("size", ProblemKind::NotPositive))?,
None => {
let mut smallest = f64::INFINITY;
for b in &shapes {
smallest = smallest.min(b.size()?);
}
smallest / 20.0
}
};
let fraction = |key: &str, default: f64| super::mesh::number_in(p, key, default, 0.0..=10.0);
let defaults = CheckOptions::new(size);
let options = CheckOptions {
size,
short_edge: fraction("short_edge", defaults.short_edge)?,
small_face: fraction("small_face", defaults.small_face)?,
sliver: fraction("sliver", defaults.sliver)?,
gap: fraction("gap", defaults.gap)?,
parallel_angle: super::mesh::number_in(p, "parallel_angle", defaults.parallel_angle.to_degrees(), 0.0..=60.0)?.to_radians(),
};
let mark = match p.get("mark") {
None => true,
Some(v) => v.as_bool().ok_or_else(|| EngineError::Params("`mark` must be true or false".into()))?,
};
let findings = check::check(&shapes, &options)?;
s.geometry_marks = s.design.as_ref().filter(|_| mark && !findings.is_empty()).map(|design| {
let marks = findings
.iter()
.map(|f| {
// A near pair's second face lies on the other body.
let on = |k: usize| if k == 1 { f.other_body.clone().unwrap_or_else(|| f.body.clone()) } else { f.body.clone() };
// A short edge is drawn itself: its faces can be large.
let faces = if f.kind == Kind::ShortEdge {
Vec::new()
} else if f.kind == Kind::NearFaces {
f.face_indices.iter().enumerate().map(|(k, &i)| (on(k), i)).collect()
} else {
f.face_indices.iter().map(|&i| (f.body.clone(), i)).collect()
};
let small =
matches!(f.kind, Kind::SmallFace | Kind::SliverFace).then(|| f.faces.first().map(|n| (f.body.clone(), n.clone()))).flatten();
crate::view::GeometryMark { faces, path: f.path.clone(), at: f.at, small }
})
.collect();
Arc::new(crate::view::GeometryMarks { design: Arc::downgrade(design), marks })
});
let counts: serde_json::Map<String, Value> =
Kind::ALL.iter().map(|k| (k.name().to_string(), json!(findings.iter().filter(|f| f.kind == *k).count()))).collect();
// The size to three decimals, without trailing zeros.
let shown = format!("{size:.3}").trim_end_matches('0').trim_end_matches('.').to_string();
let summary = if findings.is_empty() {
format!("nothing small against size {shown}")
} else {
let parts: Vec<String> = Kind::ALL
.iter()
.filter_map(|k| {
let n = findings.iter().filter(|f| f.kind == *k).count();
(n > 0).then(|| format!("{n} {}", k.name().replace('_', " ")))
})
.collect();
format!("{} against size {shown}", parts.join(", "))
};
let listed: Vec<Value> = findings
.iter()
.take(MAX_FINDINGS)
.map(|f| {
json!({
"kind": f.kind.name(),
"body": f.body,
"edge": f.edge,
"faces": f.faces,
"other_body": f.other_body,
"value": f.value,
"limit": f.limit,
"at": f.at,
})
})
.collect();
Ok(json!({
"size": size,
"counts": counts,
"summary": summary,
"findings": listed,
"truncated": findings.len() > MAX_FINDINGS,
}))
}
fn tessellate(s: &mut Session, p: &Value) -> Result<Value> {
let (shape, mesh) = body_mesh(s, p)?;
let mut per_face = vec![0usize; shape.faces().len()];
@@ -391,16 +520,157 @@ fn import_mesh(s: &mut Session, p: &Value) -> Result<Value> {
.collect();
s.surfaces.extend(surfaces.into_iter().map(Arc::new));
s.sources.meshes.push(source);
Ok(json!({"path": path, "surfaces": added}))
// The model's stitches of surfaces by these names apply to them too.
let (_, failed) = super::stitch::restitch(s);
let mut reply = json!({"path": path, "surfaces": added});
let warnings: Vec<String> =
failed.into_iter().filter(|(_, why)| !why.starts_with("no surface")).map(|(n, why)| format!("stitch `{n}`: {why}")).collect();
if !warnings.is_empty()
&& let Some(o) = reply.as_object_mut()
{
o.insert("warnings".into(), json!(warnings));
}
Ok(reply)
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use std::io::{Cursor, Write};
use super::*;
fn package(model: &str) -> Vec<u8> {
/// A session with the design the SolidCraft `commands` build open.
pub(crate) fn design(commands: Value) -> (crate::tests::TempDir, Session) {
let bytes = fieldcraft_geometry::script_design(&json!({"commands": commands}).to_string()).unwrap();
let (dir, path) = crate::tests::design_file(&bytes);
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path})).unwrap();
(dir, s)
}
fn kinds(r: &Value) -> Vec<&str> {
r["findings"].as_array().unwrap().iter().map(|f| f["kind"].as_str().unwrap()).collect()
}
/// A plain block is clean at a size it can be meshed with; a 0.4 mm step on its top is a
/// sliver riser with two short edges, located on the step; the check changes nothing.
#[test]
fn check_finds_a_small_step() {
let (_dir, mut s) = design(json!([{"command": "solid.box", "params": {"length": 40, "width": 20, "height": 10, "body_name": "Base"}}]));
let clean = s.execute("geometry.check", &json!({"size": 5})).unwrap();
assert!(kinds(&clean).is_empty(), "{clean}");
assert_eq!(clean["summary"], "nothing small against size 5");
let (_dir, mut s) = design(json!([
{"command": "solid.box", "params": {"length": 40, "width": 20, "height": 10, "body_name": "Base"}},
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10.4, "operation": "join", "targets": ["Base"]}},
]));
let journal = s.journal.len();
let r = s.execute("geometry.check", &json!({"body": "Base", "size": 5})).unwrap();
assert_eq!(s.journal.len(), journal, "read-only");
assert_eq!(r["counts"]["short_edge"], 2, "{r}");
assert_eq!(r["counts"]["sliver_face"], 1, "{r}");
assert_eq!(r["counts"]["small_face"], 0, "{r}");
assert_eq!(r["counts"]["near_faces"], 0, "{r}");
let names: Vec<String> = s.execute("geometry.bodies", &json!({})).unwrap()[0]["faces"]
.as_array()
.unwrap()
.iter()
.map(|f| f["name"].as_str().unwrap().to_string())
.collect();
for f in r["findings"].as_array().unwrap() {
let at: Vec<f64> = f["at"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!((at[0] - 20.0).abs() < 1e-6 && at[2] >= 10.0 - 1e-6 && at[2] <= 10.4 + 1e-6, "on the riser: {f}");
assert!(f["faces"].as_array().unwrap().iter().all(|n| names.contains(&n.as_str().unwrap().to_string())), "{f}");
assert!(f["value"].as_f64().unwrap() < f["limit"].as_f64().unwrap());
}
let short = &r["findings"][0];
assert!((short["value"].as_f64().unwrap() - 0.4).abs() < 1e-6, "{short}");
assert!(short["edge"].is_string());
// The size is what makes the step small: at 1 mm it is a feature like any other.
let fine = s.execute("geometry.check", &json!({"size": 1})).unwrap();
assert!(kinds(&fine).is_empty(), "{fine}");
}
/// The findings are drawn in the picture: the sliver riser's faces in the marker colour, the
/// short edges as lines and a dot at each finding over everything; a clean check, `mark:
/// false` or another design takes them away.
#[test]
fn check_marks_its_findings_in_the_picture() {
let (_dir, mut s) = design(json!([
{"command": "solid.box", "params": {"length": 40, "width": 20, "height": 10, "body_name": "Base"}},
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10.4, "operation": "join", "targets": ["Base"]}},
]));
let p = crate::view::Palette::LIGHT;
let marked = |s: &Session| {
let (scene, _, _) = crate::view::scene(s, &p);
let faces = scene.tris.iter().filter(|t| t.color == p.marked).count();
let lines: Vec<_> = scene.on_top.iter().filter(|l| l.color == p.marked).collect();
let dots = lines.iter().filter(|l| l.a == l.b).count();
(faces, lines.len() - dots, dots)
};
assert_eq!(marked(&s), (0, 0, 0), "nothing checked yet");
let r = s.execute("geometry.check", &json!({"size": 5})).unwrap();
let (faces, edges, dots) = marked(&s);
assert!(faces > 0, "the riser's faces are marked");
assert!(edges >= 2, "the short edges are drawn: {edges}");
assert_eq!(dots, r["findings"].as_array().unwrap().len(), "a dot at each finding");
// Every marked triangle lies on the riser, x = 20, between z = 10 and 10.4.
let (scene, _, _) = crate::view::scene(&s, &p);
for t in scene.tris.iter().filter(|t| t.color == p.marked) {
assert!(t.p.iter().all(|v| (v.x - 20.0).abs() < 1e-6 && v.z > 10.0 - 1e-6 && v.z < 10.4 + 1e-6), "{t:?}");
}
s.execute("geometry.check", &json!({"size": 5, "mark": false})).unwrap();
assert_eq!(marked(&s), (0, 0, 0), "mark: false clears");
s.execute("geometry.check", &json!({"size": 5})).unwrap();
s.execute("geometry.check", &json!({"size": 1})).unwrap();
assert_eq!(marked(&s), (0, 0, 0), "a clean check clears");
s.execute("geometry.check", &json!({"size": 5})).unwrap();
assert!(s.execute("geometry.check", &json!({"size": 5, "mark": "yes"})).is_err());
assert!(marked(&s).2 > 0, "a failed check keeps the marks");
s.design = s.design.as_deref().map(|d| Arc::new(d.clone()));
assert_eq!(marked(&s), (0, 0, 0), "stale once another design is open");
}
/// Two bodies 0.5 mm apart: their facing faces are near faces; once they touch they are not.
#[test]
fn check_finds_a_gap_between_bodies() {
let (_dir, mut s) = design(json!([
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "A"}},
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [0, 0, 10.5], "body_name": "B"}},
]));
let r = s.execute("geometry.check", &json!({"size": 2.5})).unwrap();
assert_eq!(kinds(&r), ["near_faces"], "{r}");
let f = &r["findings"][0];
assert!((f["value"].as_f64().unwrap() - 0.5).abs() < 1e-6, "{f}");
let bodies = [f["body"].as_str().unwrap(), f["other_body"].as_str().unwrap()];
assert!(bodies.contains(&"A") && bodies.contains(&"B"), "{f}");
assert!(s.execute("geometry.check", &json!({"size": 2.5, "gap": 0.1})).unwrap()["findings"].as_array().unwrap().is_empty());
// Without a size the default is a twentieth of the smaller diagonal.
let d = s.execute("geometry.check", &json!({})).unwrap();
assert!((d["size"].as_f64().unwrap() - 300f64.sqrt() / 20.0).abs() < 1e-9, "{d}");
}
#[test]
fn check_rejects_hostile_parameters() {
let mut s = Session::default();
assert!(s.execute("geometry.check", &json!({})).is_err(), "needs a design");
let (_dir, mut s) = design(json!([{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "A"}}]));
for p in [
json!({"size": 0}),
json!({"size": -1}),
json!({"size": "big"}),
json!({"size": 1e308, "gap": 1e308}),
json!({"short_edge": -1}),
json!({"sliver": 1e9}),
json!({"parallel_angle": 90}),
json!({"body": "Nope"}),
json!({"body": 3}),
] {
assert!(s.execute("geometry.check", &p).is_err(), "{p}");
}
}
pub(crate) fn package(model: &str) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file("3D/3dmodel.model", zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(model.as_bytes()).unwrap();
+301 -7
View File
@@ -40,7 +40,7 @@ pub const NODE_REMOVE: CommandSpec = CommandSpec {
pub const ELEMENT_ADD: CommandSpec = CommandSpec {
id: "mesh.element.add",
label: "Add Element",
params: r#"{"label": 1, "kind": "rod2|beam2|tri3|tri6|quad4|quad8|tet4|tet10|wedge6|hex8|hex20", "nodes": [labels]} — adds or replaces"#,
params: r#"{"label": 1, "kind": "rod2|beam2|tri3|tri6|quad4|quad8|tet4|tet10|wedge6|pyramid5|hex8|hex20", "nodes": [labels]} — adds or replaces"#,
form: Some(super::forms::ELEMENT_ADD),
kind: CommandKind::Model,
requires: &[Requirement::Nodes],
@@ -67,6 +67,26 @@ pub const QUALITY: CommandSpec = CommandSpec {
run: quality_report,
};
pub const QUALITY_MARK: CommandSpec = CommandSpec {
id: "mesh.quality.mark",
label: "Mark Bad Elements",
params: r#"{"limits"?: {"scaled_jacobian": 0.2, "min_dihedral": 10, "max_dihedral": 165, "aspect_ratio": 10, "min_angle": 15, "max_angle": 165} (the defaults; given limits are merged over them, null drops one; any metric of mesh.quality may be limited: a floor for those where higher is better, a ceiling for max_dihedral, aspect_ratio, edge_ratio, max_angle and skew; angles in degrees), "elements"?: [labels] (default all)} — after meshing, marks the elements that fail a limit (and every solid not proven valid) as the element set `quality_marked`, replaced on every run and removed when none fail; the viewport and `fieldcraft-cli snapshot` draw its elements in the marker colour. Replies `summary`, `measured`, `marked`, `invalid`, the `limits` used and `elements` (worst first: not valid, then lowest scaled Jacobian; at most 1000, `truncated` says when more): each `label`, `kind`, `body`, `at` (its centroid), `valid` and `failed` ({metric, value, limit} per limit it fails). One undo step"#,
form: Some(super::forms::QUALITY_MARK),
kind: CommandKind::Model,
requires: &[Requirement::Mesh],
run: quality_mark,
};
pub const EXPORT_STL: CommandSpec = CommandSpec {
id: "mesh.export.stl",
label: "Export STL",
params: r#"{"path": "skin.stl", "set": "top", "format": "binary|ascii"} — writes the mesh's outer surface as STL: every solid element face no other solid shares, and the top side (face 1) of every shell; `set` (optional) writes only that set's element faces instead; `format` defaults to binary. Quadrilaterals are split in two, normals point out of the elements, and coordinates are rounded to single precision (STL stores f32). Replies {path, triangles, bytes}"#,
form: Some(super::forms::EXPORT_STL),
kind: CommandKind::Session,
requires: &[Requirement::Mesh],
run: export_stl,
};
pub const DELAUNAY_POINTS: CommandSpec = CommandSpec {
id: "mesh.delaunay.points",
label: "Delaunay of Points",
@@ -146,7 +166,7 @@ pub(super) fn surface_options(p: &Value, mesh: &SurfaceMesh) -> Result<SurfaceOp
}
/// An optional number in `range`.
fn number_in(p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
pub(super) fn number_in(p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
let v = optional_number(p, key, default)?;
if range.contains(&v) { Ok(v) } else { Err(EngineError::param(key, ProblemKind::NotInRange { min: *range.start(), max: *range.end() })) }
}
@@ -235,10 +255,16 @@ pub(super) struct BodyTets {
surface_volume: f64,
}
impl BodyTets {
pub(super) fn new(name: String, face_names: Vec<String>, mesh: QualityMesh, steiner: usize, cavities: usize, surface_volume: f64) -> Self {
BodyTets { name, face_names, mesh, steiner, cavities, surface_volume }
}
}
/// Mesh one body or imported surface; the model is not touched. With `several` bodies an error
/// names the body.
pub(super) fn mesh_body(
s: &Session,
design: Option<&fieldcraft_geometry::Design>,
p: &Value,
input: super::hex::Input,
cdt: &CdtOptions,
@@ -249,7 +275,7 @@ pub(super) fn mesh_body(
let mesh = || -> Result<BodyTets> {
// The exact faces of a design body; an imported surface keeps to its triangles.
let analytic = match p.get("surface") {
None => s.design.as_deref().and_then(|d| d.body(&input.name)).map(analytic_surfaces).unwrap_or_default(),
None => design.and_then(|d| d.body(&input.name)).map(analytic_surfaces).unwrap_or_default(),
Some(_) => Vec::new(),
};
fieldcraft_model::progress::stage(&format!("{}: boundary", input.name), None, "");
@@ -283,7 +309,7 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
let several = inputs.len() > 1;
let mut meshed = Vec::with_capacity(inputs.len());
for input in inputs {
meshed.push(mesh_body(s, p, input, &cdt_options, &options, quality, several)?);
meshed.push(mesh_body(s.design.as_deref(), p, input, &cdt_options, &options, quality, several)?);
}
// Replace the bodies' meshes (replace.rs); other bodies and the definitions stay.
@@ -553,6 +579,53 @@ fn number(v: f64) -> Value {
if v.is_finite() { json!(v) } else { json!(if v > 0.0 { "inf" } else { "-inf" }) }
}
fn export_stl(s: &mut Session, p: &Value) -> Result<Value> {
let set: Option<String> = super::opt(p, "set")?;
let format: Option<String> = super::opt(p, "format")?;
let ascii = match format.as_deref() {
None | Some("binary") => false,
Some("ascii") => true,
Some(other) => {
let shown: String = other.chars().take(32).collect();
return Err(EngineError::Params(format!("unknown STL format `{shown}`; known: binary, ascii")));
}
};
let path = super::file_param(s, p, "path", fieldcraft_io::roots::Access::Write)?;
let m = &s.model;
if m.elements().is_empty() {
return Err(EngineError::Params("the mesh has no elements".into()));
}
let faces = match &set {
Some(name) => {
let shown: String = name.chars().take(64).collect();
if !m.sets().contains_key(name) {
return Err(EngineError::Params(format!("no set `{shown}`")));
}
let faces = m.resolve_set(name)?.faces;
if faces.is_empty() {
return Err(EngineError::Params(format!("set `{shown}` has no element faces")));
}
faces
}
None => {
let faces = m.skin_faces();
if faces.is_empty() {
return Err(EngineError::Params("the mesh has only line elements: no surface to write".into()));
}
faces
}
};
let bytes = if ascii {
let name = std::path::Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
fieldcraft_io::stl::write_ascii(m, &faces, &name)?.into_bytes()
} else {
fieldcraft_io::stl::write_binary(m, &faces)?
};
let triangles = fieldcraft_io::stl::triangles(m, &faces)?.len();
fieldcraft_io::fs::write_atomic(std::path::Path::new(&path), &bytes, false)?;
Ok(json!({"path": path, "triangles": triangles, "bytes": bytes.len()}))
}
fn quality_report(s: &mut Session, p: &Value) -> Result<Value> {
let bins = match p.get("bins") {
None => 10,
@@ -593,6 +666,103 @@ fn quality_report(s: &mut Session, p: &Value) -> Result<Value> {
}))
}
/// The element set `mesh.quality.mark` keeps the marked elements in.
pub const MARKED_SET: &str = "quality_marked";
/// The default limits of `mesh.quality.mark`.
const MARK_LIMITS: [(quality::Metric, f64); 6] = [
(quality::Metric::ScaledJacobian, 0.2),
(quality::Metric::MinDihedral, 10.0),
(quality::Metric::MaxDihedral, 165.0),
(quality::Metric::AspectRatio, 10.0),
(quality::Metric::MinAngle, 15.0),
(quality::Metric::MaxAngle, 165.0),
];
/// Most marked elements `mesh.quality.mark` lists.
const MAX_MARKED_LISTED: usize = 1000;
fn quality_mark(s: &mut Session, p: &Value) -> Result<Value> {
let mut limits: std::collections::BTreeMap<quality::Metric, f64> = MARK_LIMITS.into_iter().collect();
match p.get("limits") {
None | Some(Value::Null) => {}
Some(Value::Object(given)) => {
for (name, v) in given {
let metric = quality::Metric::parse(name).ok_or_else(|| {
let known: Vec<&str> = quality::Metric::ALL.iter().map(|m| m.name()).collect();
EngineError::Params(format!("no quality metric `{}` (one of {})", name.chars().take(40).collect::<String>(), known.join(", ")))
})?;
match v {
Value::Null => {
limits.remove(&metric);
}
v => {
let limit = v
.as_f64()
.filter(|x| x.is_finite())
.ok_or_else(|| EngineError::Params(format!("the limit of `{name}` must be a number")))?;
limits.insert(metric, limit);
}
}
}
}
Some(_) => return Err(EngineError::Params("`limits` must be an object of metric names and numbers".into())),
}
let selection = match p.get("elements") {
None => None,
Some(_) => Some(labels(p, "elements", MAX_ELEMENTS)?),
};
let limits: Vec<(quality::Metric, f64)> = limits.into_iter().collect();
let (measured, marked) = quality::mark_elements(&s.model, selection.as_deref(), &limits)?;
let set = fieldcraft_model::Set { elements: marked.iter().map(|m| m.label).collect(), ..Default::default() };
if set.elements.is_empty() {
if s.model.set(MARKED_SET).is_some() {
s.model.remove_set(MARKED_SET)?;
}
} else if s.model.set(MARKED_SET).is_some() {
s.model.replace_set(MARKED_SET, set)?;
} else {
s.model.add_set(MARKED_SET, set)?;
}
let invalid = marked.iter().filter(|m| m.is_invalid()).count();
let c = s.model.classification();
let body = |label: u32| c.elements().get(&label).and_then(|t| t.region).and_then(|r| c.entity(r)).map(|g| g.body.clone());
let elements: Vec<Value> = marked
.iter()
.take(MAX_MARKED_LISTED)
.map(|m| {
json!({
"label": m.label,
"kind": m.kind.name(),
"body": body(m.label),
"at": m.centroid,
"valid": m.validity.map(|v| v == quality::Validity::Valid),
"failed": m.failed.iter().map(|(metric, v, limit)| json!({"metric": metric.name(), "value": number(*v), "limit": limit})).collect::<Vec<_>>(),
})
})
.collect();
let mut summary = format!("{} of {measured} elements marked", marked.len());
if invalid > 0 {
summary.push_str(&format!(", {invalid} not valid"));
}
if let Some(worst) = marked.first().and_then(|m| m.scaled_jacobian) {
summary.push_str(&format!("; worst scaled Jacobian {worst:.3}"));
}
if !marked.is_empty() {
summary.push_str(&format!(" (set {MARKED_SET})"));
}
Ok(json!({
"summary": summary,
"set": (!marked.is_empty()).then_some(MARKED_SET),
"measured": measured,
"marked": marked.len(),
"invalid": invalid,
"limits": limits.iter().map(|(m, v)| (m.name().to_string(), json!(v))).collect::<serde_json::Map<_, _>>(),
"elements": elements,
"truncated": marked.len() > MAX_MARKED_LISTED,
}))
}
/// A quality report in a line, for the status bar and the CLI: the elements measured, how many
/// are invalid or undetermined, and the scaled Jacobian's minimum and mean.
fn quality_summary(r: &QualityReport) -> String {
@@ -671,6 +841,63 @@ fn surface_check(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
#[test]
fn export_stl_writes_the_skin_or_a_set() {
let dir = std::env::temp_dir().join(format!("fieldcraft-stl-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("skin.stl");
let path_str = path.to_string_lossy().into_owned();
let mut s = Session::default();
assert!(s.execute("mesh.export.stl", &json!({"path": path_str})).is_err(), "no mesh");
s.execute("file.sample", &json!({"name": "cantilever"})).unwrap();
let journal = s.journal.len();
let out = s.execute("mesh.export.stl", &json!({"path": path_str})).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert!(!bytes.starts_with(b"solid"));
let skin = fieldcraft_io::stl::triangles(&s.model, &s.model.skin_faces()).unwrap().len();
assert!(skin >= s.model.skin_faces().len() && skin > 0);
assert_eq!(out["triangles"], skin);
assert_eq!(bytes.len(), 84 + 50 * skin);
assert_eq!(out["bytes"], bytes.len());
assert_eq!(s.journal.len(), journal, "exporting does not change the model");
// A set with faces writes only those; one without faces, or an unknown one, is refused.
let faces = s.model.skin_faces();
let two: Vec<serde_json::Value> = faces.iter().take(2).map(|f| json!({"element": f.element, "face": f.face})).collect();
let first = *s.model.elements().keys().next().unwrap();
s.execute("model.set.create", &json!({"name": "stl_faces", "faces": two})).unwrap();
s.execute("model.set.create", &json!({"name": "stl_elements", "elements": [first]})).unwrap();
let out = s.execute("mesh.export.stl", &json!({"path": path_str, "set": "stl_faces"})).unwrap();
assert_eq!(out["triangles"], fieldcraft_io::stl::triangles(&s.model, &faces[..2]).unwrap().len());
let other = dir.join("other.stl").to_string_lossy().into_owned();
let e = s.execute("mesh.export.stl", &json!({"path": other, "set": "nope"})).unwrap_err().to_string();
assert!(e.contains("no set `nope`"), "{e}");
let e = s.execute("mesh.export.stl", &json!({"path": other, "set": "stl_elements"})).unwrap_err().to_string();
assert!(e.contains("has no element faces"), "{e}");
let e = s.execute("mesh.export.stl", &json!({"path": other, "format": "obj"})).unwrap_err().to_string();
assert!(e.contains("known: binary, ascii"), "{e}");
assert!(!dir.join("other.stl").exists());
let out = s.execute("mesh.export.stl", &json!({"path": path_str, "format": "ascii"})).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.starts_with("solid skin\n"));
assert_eq!(text.matches("facet normal").count(), skin);
assert_eq!(out["triangles"], skin);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn export_stl_refuses_a_mesh_of_lines() {
let mut s = Session::default();
s.execute("mesh.node.add", &json!({"label": 1, "xyz": [0, 0, 0]})).unwrap();
s.execute("mesh.node.add", &json!({"label": 2, "xyz": [1, 0, 0]})).unwrap();
s.execute("mesh.element.add", &json!({"label": 1, "kind": "rod2", "nodes": [1, 2]})).unwrap();
let path = std::env::temp_dir().join(format!("fieldcraft-stl-lines-{}.stl", std::process::id()));
let e = s.execute("mesh.export.stl", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string();
assert!(e.contains("only line elements"), "{e}");
assert!(!path.exists());
}
use serde_json::json;
use crate::Session;
@@ -711,6 +938,73 @@ mod tests {
}
}
fn mark_session() -> Session {
let mut s = Session::default();
let corners = json!([[0, 0, 0], [2, 0, 0], [2, 1, 0], [0, 1, 0], [0, 0, 1], [2, 0, 1], [2, 1, 1], [0, 1, 1]]);
s.execute("mesh.block", &json!({"body": "Bar", "kind": "hex8", "corners": corners, "divisions": [2, 1, 1]})).unwrap();
s
}
/// Two good hexes mark nothing; a corner pulled in marks its element, located, as the set
/// the viewport draws; looser limits empty it; undo brings it back.
#[test]
fn marks_bad_elements_as_a_set() {
let mut s = mark_session();
let r = s.execute("mesh.quality.mark", &json!({})).unwrap();
assert_eq!((r["measured"].as_u64(), r["marked"].as_u64()), (Some(2), Some(0)), "{r}");
assert!(s.model.set(super::MARKED_SET).is_none() && r["set"].is_null());
assert_eq!(r["limits"]["scaled_jacobian"], 0.2);
// Pull the far top corner of the second hex nearly onto its middle.
let (&corner, _) = s.model.nodes().iter().find(|(_, n)| n.xyz == [2.0, 1.0, 1.0]).unwrap();
let (&bad, _) = s.model.elements().iter().find(|(_, e)| e.nodes.contains(&corner)).unwrap();
s.execute("mesh.node.add", &json!({"label": corner, "xyz": [1.7, 0.7, 0.7]})).unwrap();
let r = s.execute("mesh.quality.mark", &json!({})).unwrap();
assert_eq!((r["marked"].as_u64(), r["invalid"].as_u64()), (Some(1), Some(0)), "{r}");
assert_eq!(r["set"], super::MARKED_SET);
let e = &r["elements"][0];
assert_eq!((e["label"].as_u64(), e["kind"].as_str(), e["body"].as_str()), (Some(bad as u64), Some("hex8"), Some("Bar")), "{e}");
assert!(e["failed"].as_array().unwrap().iter().any(|f| f["metric"] == "scaled_jacobian"), "{e}");
let at: Vec<f64> = e["at"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!(at[0] > 1.0 && at[0] < 2.0, "{at:?}");
assert!(r["summary"].as_str().unwrap().starts_with("1 of 2 elements marked"), "{r}");
assert_eq!(s.model.set(super::MARKED_SET).unwrap().elements, [bad]);
assert_eq!(crate::view::marked(&s).into_iter().collect::<Vec<_>>(), [bad]);
// The picture draws its faces in the marker colour, and only its.
let p = crate::view::Palette::LIGHT;
let (scene, _, _) = crate::view::scene(&s, &p);
let red = scene.tris.iter().filter(|t| t.color == p.marked).count();
assert_eq!(red, 5 * 2, "the bad hex's outer faces (all but the shared one), as triangles");
// Without limits the (valid) element passes and the set goes.
let all_off = json!({"limits": {"scaled_jacobian": null, "min_dihedral": null, "max_dihedral": null, "aspect_ratio": null, "min_angle": null, "max_angle": null}});
let r = s.execute("mesh.quality.mark", &all_off).unwrap();
assert_eq!(r["marked"], 0, "{r}");
assert!(s.model.set(super::MARKED_SET).is_none());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.model.set(super::MARKED_SET).unwrap().elements, [bad], "undo restores the marks");
// Only the elements asked about.
let other = s.model.elements().keys().copied().find(|l| *l != bad).unwrap();
assert_eq!(s.execute("mesh.quality.mark", &json!({"elements": [other]})).unwrap()["marked"], 0);
}
#[test]
fn mark_rejects_hostile_parameters() {
let mut s = Session::default();
assert!(s.execute("mesh.quality.mark", &json!({})).is_err(), "needs a mesh");
let mut s = mark_session();
for p in [
json!({"limits": {"nope": 1}}),
json!({"limits": {"scaled_jacobian": "low"}}),
json!({"limits": {"scaled_jacobian": true}}),
json!({"limits": [0.2]}),
json!({"limits": {"x".repeat(10000): 1}}),
json!({"elements": [0]}),
json!({"elements": [99]}),
json!({"elements": "all"}),
] {
assert!(s.execute("mesh.quality.mark", &p).is_err(), "{p}");
}
}
#[test]
fn reports_quality_and_inverted_elements() {
let mut s = Session::default();
@@ -787,7 +1081,7 @@ mod tests {
SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
for mesh in [tet, open] {
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 }));
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2, original: None }));
}
let r = s.execute("mesh.surface.check", &json!({"surface": 0, "feature_angle": 180})).unwrap();
assert_eq!((r["valid"].clone(), r["feature_edges"].clone()), (json!(true), json!({"face_boundary": 3, "sharp": 0})));
@@ -1020,7 +1314,7 @@ mod tests {
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
let mut s = Session::default();
for mesh in [tet, open] {
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 }));
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2, original: None }));
}
let r = s.execute("mesh.tet.generate", &json!({"surface": 0, "quality": false})).unwrap();
assert_eq!((r["elements"].clone(), r["nodes"].clone(), r["boundary_faces"].clone()), (json!(1), json!(4), json!(4)));
+62 -39
View File
@@ -9,8 +9,10 @@
use crate::schema::ProblemKind;
use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_geometry::Shape;
use fieldcraft_geometry::{Design, Shape};
use fieldcraft_mesh::mirror::{mirror, reflected_edges, reflected_faces};
use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
use fieldcraft_mesh::tet::geometry::Tessellation;
use fieldcraft_model::geometry::Entity;
use fieldcraft_model::{ElementFace, GeometryRef, Model};
use serde_json::{Value, json};
@@ -21,7 +23,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const MIRROR: CommandSpec = CommandSpec {
id: "mesh.mirror",
label: "Mirror Mesh",
params: r#"{"plane": "Sym" (a datum plane) or {"point": [x, y, z], "normal": [nx, ny, nz]}, "bodies": ["Part/a"] (default: every body the mesh is tagged with), "as": {"Part/a": "Part/b"} (optional: the body each image is; default the design's body at the reflected place, else "<body>/mirror")} — replaces each image body's mesh (as mesh.tet.generate) with the reflection of the bodies' elements (tet4, tet10, wedge6, hex8, hex20) across the plane: each copy right-handed (its nodes in reflected order), the copies of the nodes on the plane merged with them so the mesh is conforming there, geometry tags carried to the image (its body, and on it the faces the reflected faces lie on, so sets by face name select both halves). Reports the elements and nodes added, the nodes shared on the plane and each image's body"#,
params: r#"{"plane": "Sym" (a datum plane) or {"point": [x, y, z], "normal": [nx, ny, nz]}, "bodies": ["Part/a"] (default: every body the mesh is tagged with), "as": {"Part/a": "Part/b"} (optional: the body each image is; default the design's body at the reflected place, else "<body>/mirror")} — replaces each image body's mesh (as mesh.tet.generate) with the reflection of the bodies' elements (tet4, tet10, wedge6, pyramid5, hex8, hex20) across the plane: each copy right-handed (its nodes in reflected order), the copies of the nodes on the plane merged with them so the mesh is conforming there, geometry tags carried to the image (its body, and on it the faces the reflected faces lie on, so sets by face name select both halves). Reports the elements and nodes added, the nodes shared on the plane and each image's body"#,
form: Some(super::forms::MIRROR),
kind: CommandKind::Model,
requires: &[Requirement::Mesh],
@@ -52,6 +54,11 @@ fn plane(s: &Session, p: &Value) -> Result<([f64; 3], [f64; 3])> {
}
}
/// The distance between two points.
fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
(0..3).map(|k| (a[k] - b[k]).powi(2)).sum::<f64>().sqrt()
}
fn reflect(x: [f64; 3], point: [f64; 3], n: [f64; 3]) -> [f64; 3] {
let d: f64 = (0..3).map(|i| (x[i] - point[i]) * n[i]).sum();
[x[0] - 2.0 * d * n[0], x[1] - 2.0 * d * n[1], x[2] - 2.0 * d * n[2]]
@@ -165,7 +172,7 @@ fn mirror_mesh(s: &mut Session, p: &Value) -> Result<Value> {
let cleared = super::replace::clear(model, image.values().map(|(name, _)| name.as_str()));
let before = model.clone();
let r = mirror(model, &region, point, n, ON_PLANE * diagonal).map_err(|e| EngineError::Params(e.to_string()))?;
tag_image(model, &before, &r, &image, point, n, diagonal)?;
tag_image(model, &before, &r, &image, design.as_deref(), point, n, diagonal)?;
let added_nodes = model.nodes().len() - before.nodes().len();
let images: Vec<Value> = image.iter().map(|(b, (t, shape))| json!({"body": b, "image": t, "in_design": shape.is_some()})).collect();
let reply = json!({
@@ -185,50 +192,66 @@ fn tag_image(
before: &Model,
r: &fieldcraft_mesh::mirror::Mirrored,
image: &BTreeMap<String, (String, Option<&Shape>)>,
design: Option<&Design>,
point: [f64; 3],
n: [f64; 3],
diagonal: f64,
) -> Result<()> {
let c = before.classification();
let entity = |t: u32| c.entity(t).cloned();
// A source face's image: by the image body's face under its reflected centroid, one lookup per
// face name (the first element face of it stands for all).
let mut face_points: BTreeMap<(String, String), [f64; 3]> = BTreeMap::new();
for (&e, tags) in c.elements() {
if !r.elements.contains_key(&e) {
continue;
}
for (&f, &t) in &tags.faces {
let Some(GeometryRef { body, entity: Entity::Face(name) }) = entity(t) else { continue };
if face_points.contains_key(&(body.clone(), name.clone())) {
continue;
// Each image body's faces, to find the one each reflected boundary face lies on. A source
// face's image is the set of image faces its own reflection covers (sampled at its
// triangles' centres: one face for an exact mirror, pieces where the image is split
// differently); each element face goes to the one of them nearest its reflected centre. So
// an element face the mesher gave a face narrower than the elements (its centre on a
// neighbour) keeps that face's image.
let tolerance = (1e-3 * diagonal).max(1e-4);
let mut targets: BTreeMap<&str, (Tessellation, Vec<String>)> = BTreeMap::new();
for (target, shape) in image.values() {
let Some(shape) = shape else { continue };
let mesh = shape.tessellate(tolerance)?;
let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() };
let surface = BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options)
.map_err(|e| EngineError::Params(format!("body `{target}`: {e}")))?;
let names = shape.faces().iter().map(|f| f.name.clone()).collect();
targets.insert(target.as_str(), (Tessellation::new(&surface, Vec::new()), names));
}
// The image faces each source face's reflection covers, by (source body, face name).
let mut covers: BTreeMap<(String, String), Vec<u32>> = BTreeMap::new();
for (body, (target, _)) in image {
let (Some(shape), Some((tess, _))) = (design.and_then(|d| d.body(body)), targets.get(target.as_str())) else { continue };
let mesh = shape.tessellate(tolerance)?;
let at = |v: u32| mesh.positions.get(v as usize).copied().unwrap_or_default();
for (t, &f) in mesh.triangles.iter().zip(&mesh.tri_face) {
let Some(name) = shape.faces().get(f as usize).map(|x| x.name.clone()) else { continue };
let centre = [0, 1, 2].map(|k| (at(t[0])[k] + at(t[1])[k] + at(t[2])[k]) / 3.0);
let Some(hit) = tess.nearest_face(reflect(centre, point, n)) else { continue };
let list = covers.entry((body.clone(), name)).or_default();
if !list.contains(&hit) {
list.push(hit);
}
let Some(nodes) = before.face_nodes(ElementFace { element: e, face: f }) else { continue };
}
}
let image_face = |e: u32, f: u8, body: &str, name: &str| -> Option<(String, String)> {
let (target, _) = image.get(body)?;
let mapped = targets.get(target.as_str()).and_then(|(tess, names)| {
let nodes = before.face_nodes(ElementFace { element: e, face: f })?;
let pts: Vec<[f64; 3]> = nodes.iter().filter_map(|n| before.node(*n).map(|x| x.xyz)).collect();
let k = pts.len().max(1) as f64;
let centre = [0, 1, 2].map(|i| pts.iter().map(|p| p[i]).sum::<f64>() / k);
face_points.insert((body, name), reflect(centre, point, n));
}
}
let mut face_names: BTreeMap<(String, String), (String, String)> = BTreeMap::new();
let mut by_body: BTreeMap<String, Vec<(String, [f64; 3])>> = BTreeMap::new();
for ((body, name), p) in &face_points {
by_body.entry(body.clone()).or_default().push((name.clone(), *p));
}
for (body, faces) in by_body {
let Some((target, shape)) = image.get(&body) else { continue };
let found = match shape {
Some(shape) => {
let pts: Vec<[f64; 3]> = faces.iter().map(|(_, p)| *p).collect();
shape.nearest_faces(&pts, (1e-3 * diagonal).max(1e-4))?
}
None => vec![None; faces.len()],
};
for ((name, _), f) in faces.iter().zip(found) {
let mapped = f.and_then(|f| shape.and_then(|s| s.faces().get(f as usize))).map_or_else(|| name.clone(), |x| x.name.clone());
face_names.insert((body.clone(), name.clone()), (target.clone(), mapped));
}
}
let p = reflect([0, 1, 2].map(|i| pts.iter().map(|p| p[i]).sum::<f64>() / k), point, n);
let face = match covers.get(&(body.to_string(), name.to_string())).map(Vec::as_slice) {
Some([only]) => Some(*only),
Some(several @ [_, _, ..]) => several
.iter()
.filter_map(|&g| tess.project_trimmed(g, p).map(|q| (dist(p, q), g)))
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, g)| g),
_ => tess.nearest_face(p),
};
face.and_then(|i| names.get(i as usize)).cloned()
});
Some((target.clone(), mapped.unwrap_or_else(|| name.to_string())))
};
let mut tags: BTreeMap<GeometryRef, u32> = BTreeMap::new();
let mut tag = |model: &mut Model, r: GeometryRef| -> Result<u32> {
if let Some(&t) = tags.get(&r) {
@@ -251,7 +274,7 @@ fn tag_image(
}
for (&f, &ft) in &t.faces {
let Some(GeometryRef { body, entity: Entity::Face(name) }) = entity(ft) else { continue };
let Some((tb, tn)) = face_names.get(&(body, name)) else { continue };
let Some((tb, tn)) = image_face(e, f, &body, &name) else { continue };
let g = faces.get(usize::from(f).saturating_sub(1)).copied().unwrap_or(f);
let tg = tag(model, GeometryRef::face(tb.as_str(), tn.as_str())?)?;
model.tag_element_face(copy, g, tg)?;
+24
View File
@@ -8,6 +8,7 @@ pub(crate) mod deck;
pub(crate) mod define;
pub mod diagnostics;
mod edit;
mod extrude;
pub(crate) mod file;
pub(crate) mod forms;
mod geometry;
@@ -18,20 +19,26 @@ pub use datum::edit_params as datum_edit_params;
pub use datum::preview as datum_preview;
pub use mesh::analytic_surfaces;
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 nodes;
pub(crate) mod octree;
pub(crate) mod plan;
mod project;
pub(crate) mod relax;
pub mod replace;
mod results;
pub(crate) mod seeds;
pub(crate) mod solve;
pub(crate) mod stitch;
mod symmetry;
mod tags;
pub mod update;
pub(crate) mod view;
mod virtual_faces;
pub mod xy;
use crate::schema::ProblemKind;
use fieldcraft_model::MAX_LABEL;
@@ -46,7 +53,9 @@ pub static ALL: &[CommandSpec] = &[
model::INFO,
geometry::OPEN,
geometry::BODIES,
geometry::FEATURES,
geometry::CENTRE,
geometry::CHECK,
geometry::TESSELLATE,
geometry::IMPORT_MESH,
datum::DATUM_PLANE,
@@ -64,12 +73,21 @@ pub static ALL: &[CommandSpec] = &[
datum::DATUM_SUPPRESS,
datum::PARTITION_SUPPRESS,
datum::PARTITION_DELETE,
virtual_faces::FACES_MERGE,
virtual_faces::FACES_MERGE_DELETE,
virtual_faces::FACE_ABSORB,
virtual_faces::FACE_SPLIT,
virtual_faces::FACE_SPLIT_DELETE,
stitch::STITCH,
stitch::STITCH_DELETE,
mesh::NODE_ADD,
mesh::NODE_REMOVE,
mesh::ELEMENT_ADD,
mesh::ELEMENT_REMOVE,
replace::CLEAR,
mesh::QUALITY,
mesh::QUALITY_MARK,
mesh::EXPORT_STL,
mesh::DELAUNAY_POINTS,
mesh::SURFACE_CHECK,
mesh::PLANAR,
@@ -80,7 +98,11 @@ pub static ALL: &[CommandSpec] = &[
hex::HEX_SWEEP,
hex::HEX_MAPPED,
hex::HEX_BLOCKS,
project::HEX_PROJECT,
extrude::HEX_EXTRUDE,
cells::CELLS,
plan::PLAN,
plan::APPLY,
seeds::SEED_EDGE,
seeds::SEED_CLEAR,
seeds::SEED_LIST,
@@ -173,7 +195,9 @@ pub static ALL: &[CommandSpec] = &[
results::FRAME,
results::REACTIONS,
results::EXPORT_CSV,
results::EXPORT_VTU,
results::CLEAR,
xy::XY,
diagnostics::CRASH_REPORTS,
diagnostics::CRASH_REPORT,
diagnostics::CRASH_REPORTS_DELETE,
File diff suppressed because it is too large. Load diff
+832
View File
@@ -0,0 +1,832 @@
//! The plan's block topology for the projection blocking mesher (#402 step 3, #399): the
//! translation of a mesh plan into one block topology over the whole body, so the rings round
//! its circles and the blend layer along its fillets, chamfers and shell walls come out of one
//! conforming mesh (no partitions, no ties, one division count per sheet of blocks).
//!
//! The body is read in a frame whose third axis is the plan's sweep direction (a principal axis):
//! - **Grid.** The planes of the body's planar faces square to each axis (its sides, a shell's
//! inner walls and floor, a boss's top, a blind hole's bottom) and the frames round its
//! circles make a grid of blocks over the body.
//! - **Rings.** The frame cell round each circle (concentric circles together) is O-gridded
//! through every level, square inside square: the frame, then a square with its corners at
//! 45° on each circle, then a core. Corners on a circle sit on it before projection, so the
//! ring's radial block edges meet the cylinder square on. A hole's frame is centred on it: a
//! side of the body near the hole is the frame there and the far side is as far; a fillet
//! round a corner of the outline that holds a hole (a screw hole in a rounded corner) is taken
//! in by the hole's ring, its frame reaching the two sides and its outer corner projected onto
//! the fillet's middle, so the corner needs no blend layer. A boss the body's walls run into
//! (planes of the body crossing its circle outside it) is framed by the square round its
//! whole circle, subdivided by those planes, every square inside it divided alike so the
//! ring's blocks meet the walls node to node; on the walls' side the frame stops at their
//! inner plane and its corner projects onto the inner fillet's middle (a boss just touching
//! the fillet included).
//! - **Blend layer.** A side of the body a fillet or chamfer runs along, or that a shell's wall
//! stands on, gets a layer of blocks between the grid (on the body shrunk by the blend's
//! depth or the wall's thickness) and the side, one block per boundary face of the grid, the
//! layer's vertices pushed out square to every side they lie on: diagonally at an edge and a
//! corner. Projected, a corner vertex lands on the fillet (or chamfer) at its middle, so the
//! two blocks meeting there share the blend's 180° as two right angles; on a shell's inner
//! corner, as three 120°-ish ones. The layer's depth is the blend's measured depth (its
//! stored radius or distance) or the wall's thickness; its divisions follow the element size.
//! - **Carving.** Every block whose middle lies outside the body (a cavity, the space round a
//! boss, a hole's core) is removed, by the generalized winding number of the body's
//! tessellation (solid angles, Van Oosterom and Strackee 1983).
//!
//! The projection mesher then projects the outer block faces onto the body's faces (each the
//! smooth group nearest it) and smooths the grid. Parallel block edges share one division count
//! across the whole topology, so neighbouring blocks always match: no shear bands, no ties.
//!
//! A body this layout does not fit (a face at an angle to the axes that is not a chamfer, a
//! curved face that is neither a circle's cylinder nor a blend, two circles in one frame) is
//! reported with the reason; the plan then keeps its frame cuts and swept cells.
use fieldcraft_geometry::{FaceSurface, Shape, SurfaceMesh};
use fieldcraft_mesh::project::Topology;
use fieldcraft_model::{PlanRing, RingKind};
type V3 = [f64; 3];
/// Most blocks a plan's layout makes before carving.
const MAX_LAYOUT_BLOCKS: usize = 20_000;
/// Most triangles the winding number classification sums over per point.
const MAX_CLASSIFY_TRIANGLES: usize = 400_000;
/// A body's block layout.
#[derive(Debug, Clone)]
pub(crate) struct Layout {
pub(crate) topology: Topology,
/// Circles ringed (concentric ones each).
pub(crate) rings: usize,
/// The sides with a blend layer: (side, depth, what).
pub(crate) layers: Vec<(String, f64, &'static str)>,
/// Grid planes per axis of the frame (across, across, along the sweep).
pub(crate) planes: [usize; 3],
/// The frame round each circle group: its axis point (the sweep's coordinate 0) and the
/// smallest half-side of the frame.
pub(crate) frames: Vec<(V3, f64)>,
/// The ring-and-pillow layout ([`super::pillow`]): the pillow's least thickness along the walls.
pub(crate) pillow: Option<f64>,
}
impl Layout {
/// The half-side of the frame round the circle about the axis through `center` along the
/// sweep `direction`.
pub(crate) fn frame_of(&self, center: V3, direction: V3) -> Option<f64> {
self.frames.iter().find_map(|(p, half)| {
let off = sub(center, *p);
let along = dot(off, direction);
(dot(off, off) - along * along < 1e-12 * dot(center, center).max(1.0)).then_some(*half)
})
}
pub(crate) fn summary(&self) -> String {
let layers: Vec<String> = self.layers.iter().map(|(s, d, w)| format!("{s} {d:.4} mm ({w})")).collect();
format!(
"projection blocking: {} blocks over a {}×{}×{} grid, {} circle(s) ringed{}{}",
self.topology.blocks.len(),
self.planes[0].saturating_sub(1),
self.planes[1].saturating_sub(1),
self.planes[2].saturating_sub(1),
self.rings,
if layers.is_empty() { String::new() } else { format!(", a blend layer on {}", layers.join(", ")) },
self.pillow.map_or_else(String::new, |t| format!(", a pillow layer from the carved grid to the walls ({t:.4} mm and more)"))
)
}
}
/// What the layout reads.
pub(crate) struct Input<'a> {
pub(crate) shape: &'a Shape,
pub(crate) surfaces: &'a [Option<FaceSurface>],
pub(crate) mesh: &'a SurfaceMesh,
pub(crate) direction: V3,
pub(crate) rings: &'a [PlanRing],
/// The faces fillets and chamfers made (indices on `shape`).
pub(crate) blends: Vec<u32>,
/// A shell's wall thickness.
pub(crate) shell: Option<f64>,
pub(crate) size: f64,
}
/// The local frame: local axis `a` is world axis `PERM[k][a]`, `k` the sweep's world axis (a
/// cyclic permutation, so blocks keep their handedness).
pub(super) const PERM: [[usize; 3]; 3] = [[1, 2, 0], [2, 0, 1], [0, 1, 2]];
const SIDE_NAMES: [[&str; 2]; 3] = [["-x", "+x"], ["-y", "+y"], ["-z", "+z"]];
fn dot(a: V3, b: V3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn sub(a: V3, b: V3) -> V3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn norm(a: V3) -> f64 {
dot(a, a).sqrt()
}
fn cross(a: V3, b: V3) -> V3 {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
/// Twice the solid angle triangle `a b c` subtends at the origin, signed (Van Oosterom and
/// Strackee 1983: tan(Ω/2) = a·(b×c) / (|a||b||c| + (a·b)|c| + (a·c)|b| + (b·c)|a|)).
fn solid_angle(a: V3, b: V3, c: V3) -> f64 {
let (la, lb, lc) = (norm(a), norm(b), norm(c));
let num = dot(a, cross(b, c));
let den = la * lb * lc + dot(a, b) * lc + dot(a, c) * lb + dot(b, c) * la;
2.0 * num.atan2(den)
}
/// The generalized winding number of the closed surface `mesh` at `p`: 1 inside, 0 outside.
pub(super) fn winding(mesh: &SurfaceMesh, p: V3) -> f64 {
let at = |i: u32| mesh.positions.get(i as usize).map(|q| sub(*q, p));
let mut sum = 0.0;
for t in mesh.triangles.iter().take(MAX_CLASSIFY_TRIANGLES) {
if let (Some(a), Some(b), Some(c)) = (at(t[0]), at(t[1]), at(t[2])) {
sum += solid_angle(a, b, c);
}
}
sum / (4.0 * std::f64::consts::PI)
}
/// A circle's axis point and radius in the local frame, with its kind.
#[derive(Debug, Clone, Copy)]
struct Circle {
c: [f64; 2],
r: f64,
kind: RingKind,
}
/// Concentric circles framed together: radii descending.
#[derive(Debug, Clone)]
struct Group {
c: [f64; 2],
circles: Vec<Circle>,
/// The frame's extent per local axis (0, 1): [low, high].
frame: [[f64; 2]; 2],
/// The frame's half-side per local axis when a corner blend it takes in sets it: the
/// distance from the centre to the body's side the blend runs along.
forced: [Option<f64>; 2],
/// A boss the body's walls run into: planes of the body cross its circle (outside it, where
/// the walls join), so its frame is the square round the whole circle, subdivided by those
/// planes, and the ring's blocks follow the subdivision.
patch: bool,
/// The extent per local axis of the concave blends a patch takes in (the inner fillet where
/// the walls meet, in pieces where the boss touches it): the frame's side on the blends'
/// side of the centre goes to their middle.
blends: Option<[[f64; 2]; 2]>,
}
/// Plan the block topology of the body.
// Axes index several per-axis arrays at once (planes, frames, layers).
#[allow(clippy::needless_range_loop)]
pub(crate) fn layout(inp: &Input) -> Result<Layout, String> {
let d = inp.direction;
let k = (0..3).find(|&k| (d[k].abs() - 1.0).abs() < 1e-9).ok_or("the sweep direction is not along an axis")?;
let perm = PERM[k];
let local = |p: V3| -> V3 { [p[perm[0]], p[perm[1]], p[perm[2]]] };
let world = |l: V3| -> V3 {
let mut w = [0.0; 3];
for a in 0..3 {
w[perm[a]] = l[a];
}
w
};
if inp.mesh.triangles.is_empty() || inp.mesh.triangles.len() > MAX_CLASSIFY_TRIANGLES {
return Err(format!("the body's tessellation has {} triangles", inp.mesh.triangles.len()));
}
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
let mut face_points: Vec<Vec<V3>> = vec![Vec::new(); inp.surfaces.len()];
for (t, &f) in inp.mesh.triangles.iter().zip(&inp.mesh.tri_face) {
for &i in t {
let Some(p) = inp.mesh.positions.get(i as usize).map(|p| local(*p)) else { continue };
for a in 0..3 {
lo[a] = lo[a].min(p[a]);
hi[a] = hi[a].max(p[a]);
}
if let Some(list) = face_points.get_mut(f as usize) {
list.push(p);
}
}
}
let diag = norm(sub(hi, lo));
if !(diag.is_finite() && diag > 0.0) {
return Err("the body has no extent".into());
}
let eps = 1e-6 * diag;
let name = |f: usize| inp.shape.faces().get(f).map_or_else(|| format!("#{f}"), |x| x.name.clone());
// Circles: the plan's rings along the sweep, in the local frame.
let mut circles: Vec<Circle> = Vec::new();
for r in inp.rings {
if (dot(r.axis, d).abs() - 1.0).abs() > 1e-6 {
return Err(format!("the ring of F{} is not square to the sweep", r.feature));
}
let c = local(r.center);
circles.push(Circle { c: [c[0], c[1]], r: r.radius, kind: r.kind });
}
let on_circle = |origin: V3, radius: f64| {
let o = local(origin);
circles.iter().any(|c| (c.r - radius).abs() < 1e-6 * radius.max(1.0) && (c.c[0] - o[0]).hypot(c.c[1] - o[1]) < 1e-6 * radius.max(1.0))
};
// The faces: planes square to an axis (with their outward sign), the circles' cylinders, and
// the blends; anything else is not this layout's.
let mut planes_on: [Vec<(f64, f64)>; 3] = [Vec::new(), Vec::new(), Vec::new()];
// The planar faces square to an in-plane axis: (axis, offset, face).
let mut side_faces: Vec<(usize, f64, usize)> = Vec::new();
let mut blend_faces: Vec<usize> = Vec::new();
for (f, s) in inp.surfaces.iter().enumerate() {
let blend = inp.blends.contains(&(f as u32));
match s {
Some(FaceSurface::Plane { normal, point }) => {
let n = local(*normal);
match (0..3).find(|&a| (n[a].abs() - 1.0).abs() < 1e-6) {
Some(a) => {
planes_on[a].push((local(*point)[a], n[a].signum()));
if a < 2 {
side_faces.push((a, local(*point)[a], f));
}
}
None if blend => blend_faces.push(f),
None => return Err(format!("face `{}` is a plane at an angle to the axes", name(f))),
}
}
// A ring's cylinder, even where a fillet's history names it (a boss over a filleted
// corner).
Some(FaceSurface::Cylinder { origin, axis, radius }) if (dot(*axis, d).abs() - 1.0).abs() < 1e-6 && on_circle(*origin, *radius) => {}
Some(FaceSurface::Cylinder { .. }) if !blend => {
return Err(format!("face `{}` is a cylinder that is not one of the plan's rings", name(f)));
}
_ if blend => blend_faces.push(f),
_ => return Err(format!("face `{}` is curved and neither a ring's cylinder nor a fillet's", name(f))),
}
}
// Circles, concentric ones grouped (radii descending).
let mut groups: Vec<Group> = Vec::new();
for c in &circles {
match groups.iter_mut().find(|g| (g.c[0] - c.c[0]).hypot(g.c[1] - c.c[1]) < 1e-6 * c.r.max(1.0)) {
Some(g) => {
if !g.circles.iter().any(|x| (x.r - c.r).abs() < 1e-6 * c.r.max(1.0)) {
g.circles.push(*c);
}
}
None => groups.push(Group { c: c.c, circles: vec![*c], frame: [[0.0; 2]; 2], forced: [None; 2], patch: false, blends: None }),
}
}
for g in &mut groups {
g.circles.sort_by(|a, b| b.r.total_cmp(&a.r));
let Some(outer) = g.circles.first().copied() else { continue };
let r0 = outer.r;
let crossing: Vec<usize> = side_faces.iter().filter(|(a, v, _)| (v - g.c[*a]).abs() < r0 - eps).map(|(_, _, f)| *f).collect();
if outer.kind != RingKind::Butterfly || crossing.is_empty() {
continue;
}
// The walls join the boss outside its circle: no face of theirs inside it.
for f in crossing {
let pts = face_points.get(f).map_or(&[][..], Vec::as_slice);
if pts.iter().any(|p| (p[0] - g.c[0]).hypot(p[1] - g.c[1]) < r0 * (1.0 - 1e-3)) {
return Err(format!("face `{}` runs through the circle of radius {r0:.4}", name(f)));
}
}
g.patch = true;
}
// The blend layer: per side, the depth of the blends along it, or the shell's wall. A blend
// round a corner of the outline that a hole's ring can frame (a screw hole in a rounded
// corner) is taken in by the ring instead.
let mut ring = [[0.0f64; 2]; 3];
let mut what = [[""; 2]; 3];
for &f in &blend_faces {
let pts = face_points.get(f).map_or(&[][..], Vec::as_slice);
if pts.is_empty() {
continue;
}
if let Some((g, half)) = corner_ring(&groups, pts, lo, hi, eps)
&& let Some(g) = groups.get_mut(g)
{
g.forced = half.map(Some);
continue;
}
if let Some((g, extent)) = patch_blend(&groups, pts)
&& let Some(g) = groups.get_mut(g)
{
let union = g.blends.map_or(extent, |b| std::array::from_fn(|a| [b[a][0].min(extent[a][0]), b[a][1].max(extent[a][1])]));
g.blends = Some(union);
continue;
}
let mut touched = false;
for a in 0..3 {
for s in 0..2 {
let depth = pts.iter().map(|p| if s == 0 { p[a] - lo[a] } else { hi[a] - p[a] }).fold(0.0f64, f64::max);
if depth < 0.45 * (hi[a] - lo[a]) {
touched = true;
if depth > ring[a][s] {
ring[a][s] = depth;
what[a][s] = "blend";
}
}
}
}
if !touched {
return Err(format!("the blend face `{}` does not run along a side of the body", name(f)));
}
}
if let Some(t) = inp.shell.filter(|t| t.is_finite() && *t > 0.0) {
// A wall: the inner face of the shell, `t` inside the side and facing away from it.
for a in 0..3 {
for s in 0..2 {
let at = if s == 0 { lo[a] + t } else { hi[a] - t };
let inward = if s == 0 { 1.0 } else { -1.0 };
if planes_on[a].iter().any(|(v, sign)| (v - at).abs() < 1e-4 * t.max(1.0) && *sign == inward) {
ring[a][s] = t;
what[a][s] = "shell wall";
}
}
}
}
let ilo: V3 = std::array::from_fn(|a| lo[a] + ring[a][0]);
let ihi: V3 = std::array::from_fn(|a| hi[a] - ring[a][1]);
if (0..3).any(|a| !(ihi[a] - ilo[a] > eps)) {
return Err("the blend layers leave no room inside the body".into());
}
// The fixed planes per axis: faces square to it (a side with a layer moves in by its depth).
let mut fixed: [Vec<f64>; 3] = [Vec::new(), Vec::new(), Vec::new()];
for a in 0..3 {
for &(v, _) in &planes_on[a] {
let v = if ring[a][0] > 0.0 && (v - lo[a]).abs() < eps {
ilo[a]
} else if ring[a][1] > 0.0 && (v - hi[a]).abs() < eps {
ihi[a]
} else {
v
};
if v < ilo[a] - eps || v > ihi[a] + eps {
return Err(format!("a face square to {} lies inside the blend layer", SIDE_NAMES[perm[a]][0].trim_start_matches('-')));
}
fixed[a].push(v);
}
if ring[a][0] > 0.0 {
fixed[a].push(ilo[a]);
}
if ring[a][1] > 0.0 {
fixed[a].push(ihi[a]);
}
fixed[a].sort_by(f64::total_cmp);
fixed[a].dedup_by(|x, y| (*x - *y).abs() < eps);
}
let snap = (0.3 * inp.size).min(0.25 * circles.iter().map(|c| c.r).fold(f64::INFINITY, f64::min));
for g in &mut groups {
let outer = g.circles.first().copied().ok_or("an empty circle group")?;
let r0 = outer.r;
for a in 0..2 {
let c = g.c[a];
if !g.patch && fixed[a].iter().any(|v| (v - c).abs() < r0 - eps) {
return Err(format!("a face of the body runs through the circle of radius {r0:.4}"));
}
}
let half = match outer.kind {
// The square round the whole circle (its corners projected onto it at 45°), or
// where a blend it takes in sets a side, at the walls' inner plane.
RingKind::Butterfly if g.patch => std::array::from_fn(|a| {
let mut h = [r0; 2];
// The blends' near edge: the walls' inner plane, whose corner projects onto
// the blend's middle.
if let Some(b) = g.blends {
if 0.5 * (b[a][0] + b[a][1]) > g.c[a] {
h[1] = b[a][0] - g.c[a];
} else {
h[0] = g.c[a] - b[a][1];
}
}
h
}),
RingKind::Butterfly => [[r0 / 2f64.sqrt(); 2]; 2],
RingKind::OGrid => o_grid_frame(g, &fixed, eps)?,
};
for a in 0..2 {
let c = g.c[a];
for s in 0..2 {
let h = half[a][s];
if outer.kind == RingKind::Butterfly && !g.patch && fixed[a].iter().any(|v| (v - c).abs() < h - eps) {
return Err(format!("a face of the body runs through the square inside the circle of radius {r0:.4}"));
}
g.frame[a][s] = if s == 0 { c - h } else { c + h };
}
}
}
// Frames that overlap along an axis share their planes there (a plane may not cut another
// frame); two frames that overlap along both are one cell, which the layout does not take.
for _ in 0..=groups.len() * groups.len() {
let mut changed = false;
for a in 0..2 {
for i in 0..groups.len() {
for j in 0..groups.len() {
if i == j {
continue;
}
let (fi, fj) = (groups[i].frame[a], groups[j].frame[a]);
let overlap = fi[0] < fj[1] - eps && fj[0] < fi[1] - eps;
let same = (fi[0] - fj[0]).abs() < eps && (fi[1] - fj[1]).abs() < eps;
if overlap && !same {
let u = [fi[0].min(fj[0]), fi[1].max(fj[1])];
groups[i].frame[a] = u;
groups[j].frame[a] = u;
changed = true;
}
}
}
}
if !changed {
break;
}
}
for (i, g) in groups.iter().enumerate() {
for h in groups.iter().skip(i + 1) {
if (0..2).all(|a| g.frame[a][0] < h.frame[a][1] - eps && h.frame[a][0] < g.frame[a][1] - eps) {
return Err(format!(
"the circles at ({:.4}, {:.4}) and ({:.4}, {:.4}) are too close for a frame each",
g.c[0], g.c[1], h.c[0], h.c[1]
));
}
}
}
// The planes: the fixed ones and the frames, a frame within `snap` of another plane moved
// onto it (a fixed plane wins).
let mut planes: [Vec<f64>; 3] = fixed.clone();
for a in 0..2 {
for g in &mut groups {
for s in 0..2 {
let v = g.frame[a][s];
let near = fixed[a].iter().copied().filter(|p| (p - v).abs() < snap).min_by(|p, q| (p - v).abs().total_cmp(&(q - v).abs()));
let v = match near {
Some(p) => p,
None => planes[a].iter().copied().find(|p| (p - v).abs() < snap).unwrap_or(v),
};
g.frame[a][s] = v;
planes[a].push(v);
}
}
planes[a].sort_by(f64::total_cmp);
planes[a].dedup_by(|x, y| (*x - *y).abs() < eps);
}
for g in &groups {
for a in 0..2 {
// A patch's frame may stop short of its circle where the walls join (the ring's
// faces there are projected out onto it), but holds the circles inside.
let r0 = g.circles.first().map_or(0.0, |c| c.r);
let hold = if g.patch { 1.2 * g.circles.get(1).map_or(0.0, |c| c.r) } else { 0.7 * r0 };
if !(g.frame[a][0] < g.c[a] - hold && g.frame[a][1] > g.c[a] + hold) {
return Err(format!("the frame round the circle of radius {r0:.4} does not hold it"));
}
if !g.patch && planes[a].iter().any(|p| *p > g.frame[a][0] + eps && *p < g.frame[a][1] - eps) {
return Err(format!("a plane of the body runs through the frame round the circle of radius {r0:.4}"));
}
}
}
if planes.iter().any(|p| p.len() < 2) {
return Err("the body has no planar sides or circles to lay a grid between".into());
}
let n = planes.each_ref().map(|p| p.len() - 1);
if n.iter().product::<usize>() * (1 + 4 * circles.len()) > MAX_LAYOUT_BLOCKS {
return Err(format!("a grid of {n:?} cells is too large for one layout"));
}
// The grid, in the local frame; each block's point that says whether it is material.
let mut t = Topology::grid([&planes[0], &planes[1], &planes[2]]).map_err(|e| e.to_string())?;
let mid = |p: &[f64], i: usize| 0.5 * (p[i] + p[i + 1]);
let mut probe: Vec<V3> = t
.blocks
.iter()
.enumerate()
.map(|(b, _)| [mid(&planes[0], b % n[0]), mid(&planes[1], (b / n[0]) % n[1]), mid(&planes[2], b / (n[0] * n[1]))])
.collect();
let index = |p: &[f64], v: f64| p.iter().position(|x| (x - v).abs() < eps);
// The grid cells the rings replace.
let mut replaced: Vec<usize> = Vec::new();
let mut rings = 0;
for g in &groups {
let (Some(i0), Some(i1), Some(j0), Some(j1)) =
(index(&planes[0], g.frame[0][0]), index(&planes[0], g.frame[0][1]), index(&planes[1], g.frame[1][0]), index(&planes[1], g.frame[1][1]))
else {
return Err("a frame lost its planes".into());
};
let r0 = g.circles.first().map_or(0.0, |c| c.r);
// The squares inside the frame: one per circle (corners at 45° on it), then the core.
// Each: half-sides [-u, +u, -v, +v] and the radius it stands for per side (a patch's
// frame stands for its circle, which the frame's corners are projected onto).
let mut squares: Vec<([f64; 4], [f64; 4])> = Vec::new();
let fr = [g.c[0] - g.frame[0][0], g.frame[0][1] - g.c[0], g.c[1] - g.frame[1][0], g.frame[1][1] - g.c[1]];
squares.push((fr, if g.patch { [r0; 4] } else { fr }));
for c in &g.circles {
let h = c.r / 2f64.sqrt();
if squares.last().is_some_and(|(s, _)| s.iter().all(|x| (x - h).abs() < eps)) || (g.patch && c.r == r0) {
continue;
}
squares.push(([h; 4], [c.r; 4]));
}
rings += g.circles.len();
let inner = g.circles.last().map_or(0.0, |c| c.r);
squares.push(([inner / (2.0 * 2f64.sqrt()); 4], [0.0; 4]));
if squares.windows(2).any(|w| (0..4).any(|i| !(w[1].0[i] < w[0].0[i] - eps))) {
return Err(format!("the frame round the circle of radius {r0:.4} is too tight for its ring"));
}
// The frame's subdivision by the grid's planes, as fractions along each side: every
// square inside is divided alike, so the ring's blocks meet node to node.
let frac = |a: usize, lo: usize, hi: usize| -> Vec<f64> {
let (p0, p1) = (planes[a][lo], planes[a][hi]);
planes[a][lo..=hi].iter().map(|p| (p - p0) / (p1 - p0)).collect()
};
let (fu, fv) = (frac(0, i0, i1), frac(1, j0, j1));
// Point (u, v) (fractions along the axes) of square `k`.
let at = |k: usize, u: f64, v: f64| -> [f64; 2] {
let h = squares.get(k).map_or([0.0; 4], |s| s.0);
[g.c[0] - h[0] + u * (h[0] + h[1]), g.c[1] - h[2] + v * (h[2] + h[3])]
};
// The frame's boundary, counter-clockwise, as (u, v) fractions with the side each
// segment that starts there lies on (0: -u, 1: +u, 2: -v, 3: +v).
let mut around: Vec<([f64; 2], usize)> = Vec::new();
around.extend(fu[..fu.len() - 1].iter().map(|&u| ([u, 0.0], 2)));
around.extend(fv[..fv.len() - 1].iter().map(|&v| ([1.0, v], 1)));
around.extend(fu[1..].iter().rev().map(|&u| ([u, 1.0], 3)));
around.extend(fv[1..].iter().rev().map(|&v| ([0.0, v], 0)));
for w in 0..n[2] {
let (z0, z1) = (planes[2][w], planes[2][w + 1]);
let zmid = 0.5 * (z0 + z1);
for i in i0..i1 {
for j in j0..j1 {
replaced.push((w * n[1] + j) * n[0] + i);
}
}
let add = |q: [[f64; 2]; 4], pr: V3, t: &mut Topology, probe: &mut Vec<V3>| {
let base = t.vertices.len() as u32;
for z in [z0, z1] {
for p in q {
t.vertices.push([p[0], p[1], z]);
}
}
let mut b = [base, base + 1, base + 2, base + 3, base + 4, base + 5, base + 6, base + 7];
let pos = b.map(|v| t.vertices.get(v as usize).copied().unwrap_or_default());
if corner_det(&pos) < 0.0 {
b = [b[0], b[3], b[2], b[1], b[4], b[7], b[6], b[5]];
}
t.blocks.push(b);
probe.push(pr);
};
for (k, pair) in squares.windows(2).enumerate() {
let (outer_rho, inner_rho) = (pair[0].1, pair[1].1);
for (s, &(p, side)) in around.iter().enumerate() {
let Some(&(q, _)) = around.get((s + 1) % around.len()) else { continue };
let quad = [at(k, p[0], p[1]), at(k, q[0], q[1]), at(k + 1, q[0], q[1]), at(k + 1, p[0], p[1])];
// The probe: between the two radii the squares stand for, toward the
// segment's middle.
let m = [0.5 * (quad[0][0] + quad[1][0]) - g.c[0], 0.5 * (quad[0][1] + quad[1][1]) - g.c[1]];
let len = m[0].hypot(m[1]).max(eps);
let rho = 0.5 * (outer_rho[side] + inner_rho[side]);
add(quad, [g.c[0] + m[0] / len * rho, g.c[1] + m[1] / len * rho, zmid], &mut t, &mut probe);
}
}
// The core, divided as the frame.
let last = squares.len() - 1;
for u in fu.windows(2) {
for v in fv.windows(2) {
let quad = [at(last, u[0], v[0]), at(last, u[1], v[0]), at(last, u[1], v[1]), at(last, u[0], v[1])];
let cx = 0.25 * quad.iter().map(|p| p[0]).sum::<f64>();
let cy = 0.25 * quad.iter().map(|p| p[1]).sum::<f64>();
add(quad, [cx, cy, zmid], &mut t, &mut probe);
}
}
}
}
if probe.len() != t.blocks.len() {
return Err("the layout lost track of its blocks".into());
}
replaced.sort_unstable();
replaced.dedup();
t.remove(&replaced).map_err(|e| e.to_string())?;
let mut k = 0;
probe.retain(|_| {
k += 1;
replaced.binary_search(&(k - 1)).is_err()
});
t.merge(eps);
// The blend layer: a block on every boundary face of the grid on a side with a layer.
let mut uses: std::collections::BTreeMap<[u32; 4], usize> = std::collections::BTreeMap::new();
for block in &t.blocks {
for f in hex_faces(block) {
let mut key = f;
key.sort_unstable();
*uses.entry(key).or_insert(0) += 1;
}
}
let mut moved: std::collections::BTreeMap<u32, u32> = std::collections::BTreeMap::new();
let original = t.blocks.len();
for b in 0..original {
let Some(block) = t.blocks.get(b).copied() else { continue };
for f in hex_faces(&block) {
let mut key = f;
key.sort_unstable();
if uses.get(&key) != Some(&1) {
continue;
}
let pts = f.map(|v| t.vertices.get(v as usize).copied().unwrap_or_default());
let side = (0..3).flat_map(|a| [(a, 0), (a, 1)]).find(|&(a, s)| {
let plane = if s == 0 { ilo[a] } else { ihi[a] };
ring[a][s] > 0.0 && pts.iter().all(|p| (p[a] - plane).abs() < eps)
});
let Some((a, s)) = side else { continue };
let mut outer = [0u32; 4];
for (o, &v) in outer.iter_mut().zip(&f) {
*o = match moved.get(&v) {
Some(&w) => w,
None => {
let p = t.vertices.get(v as usize).copied().unwrap_or_default();
let mut q = p;
for aa in 0..3 {
if ring[aa][0] > 0.0 && (p[aa] - ilo[aa]).abs() < eps {
q[aa] -= ring[aa][0];
}
if ring[aa][1] > 0.0 && (p[aa] - ihi[aa]).abs() < eps {
q[aa] += ring[aa][1];
}
}
let w = t.vertices.len() as u32;
t.vertices.push(q);
moved.insert(v, w);
w
}
};
}
let mut nb = [f[0], f[1], f[2], f[3], outer[0], outer[1], outer[2], outer[3]];
let at = |v: u32| t.vertices.get(v as usize).copied().unwrap_or_default();
if corner_det(&nb.map(at)) < 0.0 {
nb = [f[0], f[3], f[2], f[1], outer[0], outer[3], outer[2], outer[1]];
}
let centre = pts.iter().fold([0.0; 3], |acc, p| [acc[0] + p[0] / 4.0, acc[1] + p[1] / 4.0, acc[2] + p[2] / 4.0]);
let mut pr = centre;
pr[a] += if s == 0 { -0.5 * ring[a][s] } else { 0.5 * ring[a][s] };
t.blocks.push(nb);
probe.push(pr);
}
}
if t.blocks.len() > MAX_LAYOUT_BLOCKS {
return Err(format!("{} blocks: too many for one layout", t.blocks.len()));
}
// Carve: blocks whose probe lies outside the body.
let gone: Vec<usize> = probe.iter().enumerate().filter(|(_, p)| winding(inp.mesh, world(**p)) < 0.5).map(|(b, _)| b).collect();
if gone.len() == t.blocks.len() {
return Err("every block of the layout lies outside the body".into());
}
if !gone.is_empty() {
t.remove(&gone).map_err(|e| e.to_string())?;
}
for v in &mut t.vertices {
*v = world(*v);
}
t.check().map_err(|e| format!("the layout's blocks do not fit: {e}"))?;
let layers = (0..3)
.flat_map(|a| [(a, 0), (a, 1)])
.filter(|&(a, s)| ring[a][s] > 0.0)
.map(|(a, s)| (SIDE_NAMES[perm[a]][s].to_string(), ring[a][s], what[a][s]))
.collect();
let frames = groups
.iter()
.map(|g| {
let half = (0..2).flat_map(|a| [g.c[a] - g.frame[a][0], g.frame[a][1] - g.c[a]]).fold(f64::INFINITY, f64::min);
(world([g.c[0], g.c[1], 0.0]), half)
})
.collect();
Ok(Layout { topology: t, rings, layers, planes: planes.each_ref().map(Vec::len), frames, pillow: None })
}
/// The ring that takes in the blend with points `pts` (local frame): a blend round a corner of
/// the outline, touching the body's side along both in-plane axes, that lies inside the frame of
/// an O-grid ring reaching from the hole to those sides, the hole centred in it. Returns the
/// group and the frame's half-side per axis (the hole's distance to the side). A frame wider
/// than 6 radii (or narrower than 1.2) does not take a blend in: the hole is not the corner's.
fn corner_ring(groups: &[Group], pts: &[V3], lo: V3, hi: V3, eps: f64) -> Option<(usize, [f64; 2])> {
let (mut bl, mut bh) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
for p in pts {
for a in 0..2 {
bl[a] = bl[a].min(p[a]);
bh[a] = bh[a].max(p[a]);
}
}
let tol = 1e3 * eps;
groups.iter().enumerate().find_map(|(i, g)| {
let outer = g.circles.first()?;
if outer.kind != RingKind::OGrid {
return None;
}
let mut half = [0.0; 2];
for a in 0..2 {
let h = if (bl[a] - lo[a]).abs() < tol {
g.c[a] - lo[a]
} else if (bh[a] - hi[a]).abs() < tol {
hi[a] - g.c[a]
} else {
return None;
};
if !(1.2 * outer.r..=6.0 * outer.r).contains(&h) || bl[a] < g.c[a] - h - tol || bh[a] > g.c[a] + h + tol {
return None;
}
half[a] = h;
}
Some((i, half))
})
}
/// The patch ring that takes in the concave blend with points `pts` (local frame): a blend
/// inside the square round a boss's whole circle and not inside the circle (the inner fillet
/// where the walls meet the boss, the boss touching it at most). Returns the group and, per
/// axis, the blend's extent.
fn patch_blend(groups: &[Group], pts: &[V3]) -> Option<(usize, [[f64; 2]; 2])> {
let (mut bl, mut bh) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
for p in pts {
for a in 0..2 {
bl[a] = bl[a].min(p[a]);
bh[a] = bh[a].max(p[a]);
}
}
groups.iter().enumerate().find_map(|(i, g)| {
let r0 = g.circles.first()?.r;
let tol = 1e-3 * r0;
if !g.patch || pts.iter().any(|p| (p[0] - g.c[0]).hypot(p[1] - g.c[1]) < 0.9 * r0) {
return None;
}
if (0..2).any(|a| bl[a] < g.c[a] - r0 - tol || bh[a] > g.c[a] + r0 + tol) {
return None;
}
Some((i, [[bl[0], bh[0]], [bl[1], bh[1]]]))
})
}
/// The half-sides of an O-grid ring's frame, per local axis and side: the hole centred in it.
/// A side of the body within 3 radii (or a corner blend the ring takes in) is the frame on that
/// side, and the frame's other side is as far from the centre unless the body's side is nearer
/// there too; an axis with no side near takes the half-side of the other axis's (a square
/// frame) when that is 1.5 to 3 radii, else 2 radii.
fn o_grid_frame(g: &Group, fixed: &[Vec<f64>; 3], eps: f64) -> Result<[[f64; 2]; 2], String> {
let r0 = g.circles.first().map_or(0.0, |c| c.r);
let mut half: [Option<[f64; 2]>; 2] = [None; 2];
for a in 0..2 {
let c = g.c[a];
let mut dist = [0.0; 2];
for (s, d) in dist.iter_mut().enumerate() {
*d = fixed[a].iter().map(|v| if s == 0 { c - v } else { v - c }).filter(|x| *x > 0.0).fold(f64::INFINITY, f64::min);
if !d.is_finite() {
return Err(format!("the hole of radius {r0:.4} has no side of the body round it"));
}
if *d < 1.05 * r0 {
return Err(format!("the hole of radius {r0:.4} is {d:.4} mm from a face: too close for a ring"));
}
}
let near = dist[0].min(dist[1]);
half[a] = match g.forced[a] {
Some(h) => Some([h; 2]),
// The side itself frames the ring when a strip narrower than the radius would be
// left between them; the other side keeps the frame centred unless it is near too.
None if near <= 3.0 * r0 + eps => Some(dist.map(|d| if d <= 3.0 * r0 + eps { d } else { near })),
None => None,
};
}
let square = |b: usize| half[b].map(|h| h[0].min(h[1])).filter(|h| (1.5 * r0 - eps..=3.0 * r0 + eps).contains(h)).unwrap_or(2.0 * r0);
Ok([half[0].unwrap_or([square(1); 2]), half[1].unwrap_or([square(0); 2])])
}
/// The six faces of a hex8 block, as corner cycles.
fn hex_faces(b: &[u32; 8]) -> [[u32; 4]; 6] {
[
[b[0], b[1], b[2], b[3]],
[b[4], b[5], b[6], b[7]],
[b[0], b[1], b[5], b[4]],
[b[1], b[2], b[6], b[5]],
[b[2], b[3], b[7], b[6]],
[b[3], b[0], b[4], b[7]],
]
}
/// The Jacobian determinant at corner 1 of a hex8 block (positive: right-handed).
pub(super) fn corner_det(c: &[V3; 8]) -> f64 {
dot(sub(c[1], c[0]), cross(sub(c[3], c[0]), sub(c[4], c[0])))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn winding_of_a_cube_is_one_inside_and_zero_outside() {
let shape = fieldcraft_geometry::block([0.0; 3], [2.0, 2.0, 2.0]).unwrap();
let mesh = shape.tessellate(0.01).unwrap();
assert!((winding(&mesh, [1.0, 1.0, 1.0]) - 1.0).abs() < 1e-6);
assert!(winding(&mesh, [3.0, 1.0, 1.0]).abs() < 1e-6);
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Decomposition proposals (#439): for a body with features the plan cannot map, the partition
//! cuts that box those features into a cell of their own, so the rest of the body is cells the
//! sweep meshes and only the boxed cell is left to tetrahedra (joined to the hex cells by
//! pyramids, `mesh.cells`).
//!
//! The region is the bounding box of the unmapped features' faces (those not already prismatic
//! along the sweep), in the frame of the sweep direction and two directions square to it. Each
//! side of the box becomes a cut one element size outside it, where that plane lies inside the
//! body, clear of its planar faces and of its curved faces (a cut through a curved face does not
//! close in the partitions' tessellation); a side with no such plane is left open and the tet
//! cell reaches the body's boundary there. Decomposing a part into sweepable volumes and a
//! remainder for a free mesher is the practice of T. D. Blacker, *The Cooper Tool*, Proc. 5th
//! International Meshing Roundtable, 1996 (docs/meshing/literature.md, B11). The proposal is a
//! reply, not a change: the agent applies the cuts (`commands`) and meshes the cells.
use fieldcraft_geometry::{Design, FaceSurface};
use fieldcraft_model::MeshPlan;
use serde_json::{Value, json};
use super::{AXES, Clearance, V3, cross, dot, faces_prismatic, parallel, plan_shape, unit};
use crate::Result;
/// The decomposition proposal for `plan`, or `None` when every feature is mapped (or the plan
/// has no sweep direction to make cells along).
pub(super) fn proposal(design: &Design, plan: &MeshPlan) -> Result<Option<Value>> {
let Some(d) = plan.direction else { return Ok(None) };
if plan.unmapped.is_empty() {
return Ok(None);
}
let Some(shape) = plan_shape(design, &plan.body) else { return Ok(None) };
let source = design.source_of(&plan.body).unwrap_or(&plan.body);
let features: Vec<_> = design.features(Some(source))?.into_iter().next().map(|b| b.features).unwrap_or_default();
let surfaces = shape.face_surfaces()?;
// The unmapped features whose faces the sweep cannot mesh.
let lumps: Vec<_> = features
.iter()
.filter(|f| plan.unmapped.iter().any(|u| u.id == f.id))
.filter(|f| !f.faces.is_empty() && !faces_prismatic(shape, &surfaces, &f.faces, d))
.collect();
if lumps.is_empty() {
return Ok(None);
}
let faces: Vec<u32> = lumps.iter().flat_map(|f| &f.faces).filter_map(|n| shape.face_index(n)).collect();
let m = shape.measure()?;
let diagonal = (0..3).map(|k| (m.bbox_max[k] - m.bbox_min[k]).powi(2)).sum::<f64>().sqrt();
let mesh = shape.tessellate(plan.tolerance.unwrap_or(1e-3 * diagonal))?;
// The frame: the sweep direction and two directions square to it (axes when it is one).
let side = AXES.iter().copied().filter(|a| !parallel(*a, d)).min_by(|a, b| dot(*a, d).abs().total_cmp(&dot(*b, d).abs())).unwrap_or(AXES[0]);
let e1 = unit(cross(d, side)).unwrap_or(side);
let e2 = unit(cross(d, e1)).unwrap_or(side);
let frame = [d, e1, e2].map(|a| AXES.iter().copied().find(|x| parallel(*x, a)).unwrap_or(a));
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
let mut curved: Vec<Vec<V3>> = vec![Vec::new(); surfaces.len()];
for (t, &f) in mesh.triangles.iter().zip(&mesh.tri_face) {
let pts: Vec<V3> = t.iter().filter_map(|&i| mesh.positions.get(i as usize).copied()).collect();
if faces.contains(&f) {
for p in &pts {
for (k, a) in frame.iter().enumerate() {
lo[k] = lo[k].min(dot(*p, *a));
hi[k] = hi[k].max(dot(*p, *a));
}
}
}
if !matches!(surfaces.get(f as usize), Some(Some(FaceSurface::Plane { .. })))
&& let Some(c) = curved.get_mut(f as usize)
{
c.extend(pts);
}
}
if lo.iter().chain(&hi).any(|v| !v.is_finite()) {
return Ok(None);
}
curved.retain(|p| !p.is_empty());
let planes = surfaces
.iter()
.zip(shape.faces())
.filter_map(|(s, f)| match s {
Some(FaceSurface::Plane { normal, point }) => Some((*normal, dot(*normal, *point), f.name.as_str())),
_ => None,
})
.collect();
let corners = (0..8)
.map(|i| {
[
if i & 1 == 0 { m.bbox_min[0] } else { m.bbox_max[0] },
if i & 2 == 0 { m.bbox_min[1] } else { m.bbox_max[1] },
if i & 4 == 0 { m.bbox_min[2] } else { m.bbox_max[2] },
]
})
.collect();
let body = Clearance { corners, planes, curved, gap: 0.5 * plan.size };
let names: Vec<&str> = lumps.iter().map(|f| f.name.as_str()).collect();
let mut cuts = Vec::new();
let mut open = Vec::new();
let mut region = [[0.0; 3]; 2];
for (k, n) in frame.iter().enumerate() {
let (blo, bhi) = body.extent(*n);
region[0][k] = blo;
region[1][k] = bhi;
for (end, t) in [(0, lo[k] - plan.size), (1, hi[k] + plan.size)] {
let which = if end == 0 { "below" } else { "above" };
let why = if !body.inside(*n, t) {
Some("the body ends there".to_string())
} else if let Some((_, face)) = body.faces_near(*n, t).next() {
Some(format!("it would lie on face `{face}`"))
} else if body.crosses_curved(*n, t) {
Some("it would cross a curved face".into())
} else {
None
};
match why {
Some(why) => open.push(json!({"normal": n, "side": which, "reason": why})),
None => {
region[end][k] = t;
let point = n.map(|c| c * t);
cuts.push(json!({"point": point, "normal": n, "reason": format!("boxes {} ({which} along {n:?})", names.join(", "))}));
}
}
}
}
// The commands that make the cuts and mesh the cells (the boxed cell falls back to tet).
let mut commands = Vec::new();
for (i, c) in cuts.iter().enumerate() {
let name = format!("{}-split{}", plan.body, i + 1);
commands.push(json!({"command": "geometry.datum", "params": {"name": name, "by": {"type": "point_normal", "point": c["point"], "normal": c["normal"]}}}));
commands.push(json!({"command": "geometry.partition", "params": {"name": name, "datum": name}}));
}
commands.push(json!({"command": "mesh.cells", "params": {"default": "auto", "size": plan.size, "transition": "pyramid"}}));
let box_volume: f64 = (0..3).map(|k| region[1][k] - region[0][k]).product();
let share = if m.volume.abs() > 0.0 { (box_volume / m.volume.abs()).min(1.0) } else { 1.0 };
Ok(Some(json!({
"features": names,
"cuts": cuts,
"open_sides": open,
"tet_region": {"frame": frame, "min": region[0], "max": region[1]},
"tet_share_at_most": share,
"commands": commands,
"note": "cut the body by these planes; the cell inside `tet_region` is meshed with tet, every other cell is a box or a slice of the sweep's cross-section the hex meshers take, and mesh.cells joins the tet cell to them through pyramids",
})))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use serde_json::{Value, json};
use crate::Session;
/// A 80 × 40 × 12 plate with a ball (radius 8) joined on its top: the sweep takes the plate,
/// not the dome.
fn plate_with_dome() -> (crate::tests::TempDir, Session) {
let commands = json!({"commands": [
{"command": "solid.box", "params": {"length": 80, "width": 40, "height": 12, "body_name": "Plate"}},
{"command": "solid.sphere", "params": {"center": [60, 20, 12], "radius": 8, "body_name": "Ball", "operation": "new"}},
{"command": "solid.combine", "params": {"target": "Plate", "tools": ["Ball"], "operation": "join"}},
]});
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
let (dir, path) = crate::tests::design_file(&bytes);
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path})).unwrap();
(dir, s)
}
/// Fix the end x = 0, pull the end x = 80 with `force` (N), solve, and return the mean
/// displacement of the loaded end along `k`.
fn pull(s: &mut Session, force: [f64; 3], k: usize) -> f64 {
let m = &s.model;
let at = |x: f64| -> Vec<u32> { m.nodes().iter().filter(|(_, n)| (n.xyz[0] - x).abs() < 1e-6).map(|(l, _)| *l).collect() };
let (root, tip) = (at(0.0), at(80.0));
// The loaded end's element faces, so the force is shared by consistent weights.
let end: Vec<Value> = m
.elements()
.iter()
.flat_map(|(&e, el)| (1..=el.kind.faces().len() as u8).map(move |f| (e, f)))
.filter(|&(e, f)| {
m.face_nodes(fieldcraft_model::ElementFace { element: e, face: f })
.is_some_and(|ns| ns.iter().all(|n| (m.node(*n).unwrap().xyz[0] - 80.0).abs() < 1e-6))
})
.map(|(e, f)| json!({"element": e, "face": f}))
.collect();
let script = json!([
{"command": "model.set.create", "params": {"name": "all", "elements": m.elements().keys().collect::<Vec<_>>()}},
{"command": "model.set.create", "params": {"name": "root", "nodes": root}},
{"command": "model.set.create", "params": {"name": "tip", "faces": end}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "S", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "Clamp", "type": "fixed", "target": {"set": "root"}}},
{"command": "model.load.create", "params": {"name": "Pull", "type": "force", "target": {"set": "tip"}, "vector": force}},
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["Clamp"], "loads": ["Pull"]}},
]);
s.run_script(&script).unwrap();
s.execute("solve.static", &json!({})).unwrap();
let probe = s.execute("solve.probe", &json!({"step": "Load", "nodes": tip})).unwrap();
let w: Vec<f64> = probe["nodes"].as_array().unwrap().iter().map(|n| n["displacement"][k].as_f64().unwrap()).collect();
w.iter().sum::<f64>() / w.len() as f64
}
/// #439's acceptance: the plan proposes cuts that box the dome; with them the plate is hex
/// cells and the dome's cell tet, joined by pyramids into one valid, conforming mesh whose
/// static answer matches the all-tet mesh's within 2 %.
#[test]
fn a_dome_on_a_plate_is_boxed_into_a_tet_cell_and_the_rest_is_hex() {
let (_dir, mut s) = plate_with_dome();
let plan = s.execute("mesh.plan", &json!({"body": "Plate", "size": 4})).unwrap();
let d = &plan["decomposition"];
assert!(d["features"].as_array().is_some_and(|f| !f.is_empty()), "{plan}");
let cuts = d["cuts"].as_array().unwrap();
assert!(cuts.len() >= 4, "{d}");
let commands: Vec<Value> = d["commands"].as_array().unwrap().clone();
let (cut_commands, mesh) = commands.split_at(commands.len() - 1);
s.run_script(&json!(cut_commands)).unwrap();
// The cell inside the region is tet; the others take the default (auto: hex).
let (lo, hi) = (&d["tet_region"]["min"], &d["tet_region"]["max"]);
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
// The region is given along its frame's axes (here coordinate axes).
let axis = |k: usize| (0..3).find(|&i| d["tet_region"]["frame"][k][i].as_f64().unwrap().abs() > 0.5).unwrap();
let inside = |b: &Value| {
(0..3).all(|k| {
let i = axis(k);
b["bbox"][0][i].as_f64().unwrap() >= lo[k].as_f64().unwrap() - 1e-6
&& b["bbox"][1][i].as_f64().unwrap() <= hi[k].as_f64().unwrap() + 1e-6
})
};
let tet: Vec<String> = bodies.as_array().unwrap().iter().filter(|b| inside(b)).map(|b| b["name"].as_str().unwrap().to_string()).collect();
assert_eq!(tet.len(), 1, "{bodies}");
let mut params = mesh[0]["params"].clone();
params["methods"] = json!({tet[0].clone(): "tet"});
let r = s.execute("mesh.cells", &params).unwrap();
let kinds: BTreeSet<&str> = s.model.elements().values().map(|e| e.kind.name()).collect();
assert_eq!(kinds.into_iter().collect::<Vec<_>>(), ["hex8", "pyramid5", "tet4"], "{r}");
assert!(r["interfaces"].as_array().unwrap().iter().all(|i| i["joined"] != "tied"), "{r}");
let dup = s.execute("mesh.check.duplicates", &json!({})).unwrap();
assert_eq!(dup["counts"], json!({"coincident": 0, "cracks": 0, "duplicate_elements": 0, "free_edges": 0}), "{dup}");
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["invalid"]["count"], 0, "{q}");
// Tension along the plate, which the dome stiffens locally. (In bending, linear hexahedra
// and linear tetrahedra lock differently at three elements through the thickness, so
// the two meshes would differ by their discretisation, not by the transition.)
let hex = s.model.elements().values().filter(|e| e.kind.name() == "hex8").count();
let mixed = pull(&mut s, [8000.0, 0.0, 0.0], 0);
let (_dir2, mut t) = plate_with_dome();
t.execute("mesh.tet.generate", &json!({"body": "Plate", "size": 4})).unwrap();
let all_tet = pull(&mut t, [8000.0, 0.0, 0.0], 0);
eprintln!("mixed {mixed:.6e} ({hex} hex of {} elements), all tet {all_tet:.6e}", s.model.elements().len());
assert!(((mixed - all_tet) / all_tet).abs() < 0.02, "{mixed} vs {all_tet}");
}
}
File diff suppressed because it is too large. Load diff
+694
View File
@@ -0,0 +1,694 @@
//! `mesh.hex.project`: projection blocking — a block topology (a carved grid, a butterfly or
//! seven-block sphere template, O-grids, extruded faces) projected onto a body's B-rep and
//! smoothed ([`fieldcraft_mesh::project`]).
use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_mesh::hex::BoundaryFace;
use fieldcraft_mesh::join::{check_conforming, weld};
use fieldcraft_mesh::project::{FixedPatch, ProjectOptions, Smoothing, Topology, project_mesh};
use fieldcraft_mesh::surface::BoundarySurface;
use fieldcraft_mesh::tet::geometry::Tessellation;
use fieldcraft_model::{ElementKind, GeometryRef};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::requirement::Requirement;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const HEX_PROJECT: CommandSpec = CommandSpec {
id: "mesh.hex.project",
label: "Projection Blocking",
params: r#"{"body": "Body1", "size": 2.0 (element size, mm; default 1/25 of the body's diagonal), "template": "grid" (default) | "sphere" | "butterfly" | "blocks", "center": [x, y, z], "radius": r, "axis": [0, 0, 1], "levels": [z0, z1, …] (sphere and butterfly: default from the body's bounding box; butterfly levels are distances along the axis from the centre), "inset": 0.55 (inner square or cube corner at this fraction of the radius), "grid": {"planes": {"x": [...], "y": [...], "z": [...]} or "cells": [ni, nj, nk] (even planes over the bounding box, default [1, 1, 1]), "carve": [{"i": [a, b], "j": [c, d], "k": [e, f]}] (block index ranges, inclusive, removed)}, "blocks": {"vertices": [[x, y, z], ...], "blocks": [[v1..v8], ...] (0-based vertices, hex8 order)}, "operations": [{"op": "o_grid", "block": b, "open": [faces], "inset": 0.5 or [i1, i2, i3], "extrude": {"offset": [dx, dy, dz], "layers": 1, "faces": "all" (the open face's butterfly: a round branch) | "inner" (its inner face: a square stub)}} | {"op": "extrude", "faces": [[block, face], ...], "offset": [dx, dy, dz], "layers": 1} | {"op": "connect", "from": [block, face], "to": [block, face], "layers": 1} (blocks bridging two faces: closes a handle) | {"op": "remove", "blocks": [...]} (carve; later blocks renumber) | {"op": "merge", "distance": d} | {"op": "split", "block": b, "direction": 1|2|3 or "x"|"y"|"z", "at": [fractions]} (cuts the block and the whole sheet of blocks the cut runs through, so the topology stays conforming; fractions from the block's side-0 face, or from the low end along an axis; the block keeps its first slab, the others are appended)] (applied in order; blocks are 0-based, new blocks appended, the O-grid's inner block keeping the old index; a face is its hex8 number 1-6 or the direction it faces, "+x" … "-z"), "preview": true (return the topology without meshing), "faces": [{"block": b, "face": 1-6, "to": ["face names"]}] (targets set by hand; other outer block faces go to the smooth group of B-rep faces nearest them), "divisions": [{"block": b, "direction": 1|2|3, "count": n}], "smoothing": "elliptic" (default) | "elliptic-controlled" (Thomas–Middlecoff) | "laplace" | "none", "iterations": 100, "min_quality": 0.2 (smallest scaled Jacobian accepted), "max_arc_angle": 30 (degrees: the most a curved face may turn across one element; the divisions grow to keep it, 0 for no limit, at most 90), "fixed": ["face names"] (faces of the body another body's mesh already covers: its element faces there are taken as given, their nodes held and the block divisions there taken from them, so the two meshes meet node to node), "merge": distance (join nodes of other bodies' meshes within it; default 1e-6 of the diagonal, 0 never), "tolerance": chordal tolerance of the tessellation} — replaces the body's mesh with an all-hex mesh of the block topology: outer block faces projected onto their B-rep faces, edges between faces with different targets onto the curve where those meet (tangent-plane iteration), vertices on three onto their point, nodes placed by transfinite interpolation and smoothed with the boundary held on the geometry. Elements are tagged with the body and boundary faces with their B-rep faces. Reports blocks, divisions, targets and quality before and after smoothing; a corner that flattens on a curved face is an error that says to use an O-grid or template there"#,
form: Some(super::forms::HEX_PROJECT),
kind: CommandKind::Model,
requires: &[Requirement::Geometry],
run: hex_project,
};
/// Most operations, face targets or division settings one command takes.
const MAX_LIST: usize = 10_000;
/// Default element size relative to the body's diagonal.
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
const MIN_RELATIVE_SIZE: f64 = 1e-4;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct GridParams {
planes: Option<BTreeMap<String, Vec<f64>>>,
cells: Option<[usize; 3]>,
#[serde(default)]
carve: Vec<BTreeMap<String, [usize; 2]>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BlocksParams {
vertices: Vec<[f64; 3]>,
blocks: Vec<[u32; 8]>,
}
/// A block face: its hex8 number, or the direction it faces ("+x", "-z", …).
#[derive(Deserialize, Clone)]
#[serde(untagged)]
enum FaceSel {
Number(u8),
Towards(String),
}
/// An inset: one fraction, or one per block direction.
#[derive(Deserialize, Clone, Copy)]
#[serde(untagged)]
enum Inset {
All(f64),
Each([f64; 3]),
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct OGridExtrude {
offset: [f64; 3],
layers: Option<usize>,
/// "all" (default: the whole open face, a butterfly section) or "inner" (the inner block's
/// face alone, a four-sided section inside the face).
faces: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, tag = "op", rename_all = "snake_case")]
enum Operation {
OGrid {
block: usize,
#[serde(default)]
open: Vec<FaceSel>,
inset: Option<Inset>,
extrude: Option<OGridExtrude>,
},
Extrude {
faces: Vec<(usize, FaceSel)>,
offset: [f64; 3],
layers: Option<usize>,
},
Connect {
from: (usize, FaceSel),
to: (usize, FaceSel),
layers: Option<usize>,
},
Remove {
blocks: Vec<usize>,
},
Merge {
distance: f64,
},
Split {
block: usize,
direction: DirSel,
at: Vec<f64>,
},
}
/// A block direction: 1, 2 or 3 (along 1→2, 1→4, 1→5), or the axis it runs nearest ("x", "y",
/// "z"; fractions then count from its low end).
#[derive(Deserialize, Clone)]
#[serde(untagged)]
enum DirSel {
Number(usize),
Axis(String),
}
fn face_of(t: &Topology, block: usize, f: &FaceSel) -> Result<u8> {
match f {
FaceSel::Number(n) if (1..=6).contains(n) => Ok(*n),
FaceSel::Number(n) => Err(EngineError::Params(format!("face {n} is not a hex8 face (1 to 6)"))),
FaceSel::Towards(d) => {
let dir = match d.as_str() {
"+x" => [1.0, 0.0, 0.0],
"-x" => [-1.0, 0.0, 0.0],
"+y" => [0.0, 1.0, 0.0],
"-y" => [0.0, -1.0, 0.0],
"+z" => [0.0, 0.0, 1.0],
"-z" => [0.0, 0.0, -1.0],
other => return Err(EngineError::Params(format!("face `{other}`: give a hex8 face number or +x, -x, +y, -y, +z, -z"))),
};
Ok(t.face_towards(block, dir)?)
}
}
}
/// Apply the topology operations in order.
fn operate(t: &mut Topology, ops: &[Operation]) -> Result<()> {
for op in ops {
match op {
Operation::OGrid { block, open, inset, extrude } => {
bounded(open, "operations.open")?;
let open: Vec<u8> = open.iter().map(|f| face_of(t, *block, f)).collect::<Result<_>>()?;
let inset = match inset.unwrap_or(Inset::All(0.5)) {
Inset::All(x) => [x; 3],
Inset::Each(v) => v,
};
let on = t.o_grid(*block, &open, inset)?;
if let Some(e) = extrude {
let first = on.first().ok_or_else(|| EngineError::Params("an O-grid extrusion needs an open face".into()))?;
let faces: Vec<(usize, u8)> = match e.faces.as_deref().unwrap_or("all") {
"all" => first.clone(),
"inner" => first.iter().take(1).copied().collect(),
other => return Err(EngineError::Params(format!("O-grid extrusion faces `{other}`: all or inner"))),
};
t.extrude(&faces, e.offset, e.layers.unwrap_or(1))?;
}
}
Operation::Extrude { faces, offset, layers } => {
bounded(faces, "operations.faces")?;
let faces: Vec<(usize, u8)> = faces.iter().map(|(b, f)| Ok((*b, face_of(t, *b, f)?))).collect::<Result<_>>()?;
t.extrude(&faces, *offset, layers.unwrap_or(1))?;
}
Operation::Connect { from, to, layers } => {
let a = (from.0, face_of(t, from.0, &from.1)?);
let b = (to.0, face_of(t, to.0, &to.1)?);
t.connect(a, b, layers.unwrap_or(1))?;
}
Operation::Remove { blocks } => {
bounded(blocks, "operations.blocks")?;
t.remove(blocks)?;
}
Operation::Merge { distance } => {
if !(distance.is_finite() && *distance >= 0.0) {
return Err(EngineError::invalid("operations.distance", "a distance of 0 or more"));
}
t.merge(*distance);
}
Operation::Split { block, direction, at } => {
bounded(at, "operations.at")?;
let (d, against) = match direction {
DirSel::Number(n @ 1..=3) => (n - 1, false),
DirSel::Number(n) => return Err(EngineError::Params(format!("split direction {n}: 1, 2, 3 or x, y, z"))),
DirSel::Axis(a) => {
let axis = match a.as_str() {
"x" => [1.0, 0.0, 0.0],
"y" => [0.0, 1.0, 0.0],
"z" => [0.0, 0.0, 1.0],
other => return Err(EngineError::Params(format!("split direction `{other}`: 1, 2, 3 or x, y, z"))),
};
t.direction_along(*block, axis)?
}
};
// Fractions count from the block's side-0 face; along an axis, from its low end.
let at: Vec<f64> = if against { at.iter().rev().map(|x| 1.0 - x).collect() } else { at.clone() };
t.split(*block, d, &at)?;
}
}
}
Ok(())
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct FaceTarget {
block: usize,
face: FaceSel,
to: Vec<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Division {
block: usize,
direction: usize,
count: usize,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ProjectParams {
body: String,
size: Option<f64>,
tolerance: Option<f64>,
template: Option<String>,
center: Option<[f64; 3]>,
radius: Option<f64>,
axis: Option<[f64; 3]>,
levels: Option<Vec<f64>>,
inset: Option<f64>,
grid: Option<GridParams>,
blocks: Option<BlocksParams>,
#[serde(default)]
operations: Vec<Operation>,
#[serde(default)]
faces: Vec<FaceTarget>,
#[serde(default)]
divisions: Vec<Division>,
smoothing: Option<String>,
iterations: Option<usize>,
min_quality: Option<f64>,
max_arc_angle: Option<f64>,
merge: Option<f64>,
#[serde(default)]
fixed: Vec<String>,
// Read from the raw parameters by the surface preparation (mesh.tet.generate's options).
#[allow(dead_code)]
weld: Option<f64>,
#[allow(dead_code)]
weld_distance: Option<f64>,
#[allow(dead_code)]
feature_angle: Option<f64>,
#[serde(default)]
preview: bool,
}
/// The body, its tessellation as a boundary surface and projection target, and its size.
pub(crate) struct Body {
input: super::hex::Input<'static>,
surface: BoundarySurface,
target: Tessellation,
lo: [f64; 3],
hi: [f64; 3],
}
impl Body {
/// The body's face names, by face index.
pub(crate) fn face_names(&self) -> &[String] {
&self.input.face_names
}
/// The body's bounding-box diagonal.
pub(crate) fn diagonal(&self) -> f64 {
self.input.diagonal
}
}
pub(crate) fn open_body(s: &Session, name: &str, tolerance: Option<f64>, raw: &Value) -> Result<Body> {
let shape =
s.design.as_deref().and_then(|d| d.body(name)).ok_or_else(|| EngineError::Params(format!("no body `{name}` (open a design first)")))?;
if tolerance.is_some_and(|t| !(t > 0.0 && t.is_finite())) {
return Err(EngineError::Params("`tolerance` must be a positive number".into()));
}
let input = super::hex::shape_input(shape, tolerance)?;
let surface =
BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &super::mesh::surface_options(raw, &input.mesh)?)
.map_err(|e| EngineError::Params(format!("body `{name}`: {e}")))?;
let target = Tessellation::new(&surface, super::mesh::analytic_surfaces(shape));
let m = shape.measure()?;
Ok(Body { input, surface, target, lo: m.bbox_min, hi: m.bbox_max })
}
fn face_index(b: &Body, body: &str, name: &str) -> Result<u32> {
b.input
.face_names
.iter()
.position(|f| f == name)
.map(|i| i as u32)
.ok_or_else(|| EngineError::Params(format!("body `{body}` has no face `{name}`")))
}
fn element_size(size: Option<f64>, diag: f64) -> Result<f64> {
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * diag);
if !(h.is_finite() && h >= MIN_RELATIVE_SIZE * diag) {
return Err(EngineError::Params(format!("`size` must be at least {} mm", MIN_RELATIVE_SIZE * diag)));
}
Ok(h)
}
pub(crate) fn merge_distance(merge: Option<f64>, diag: f64) -> Result<f64> {
let m = merge.unwrap_or(1e-6 * diag);
if !(m.is_finite() && m >= 0.0 && m <= 1e-2 * diag) {
return Err(EngineError::Params(format!("`merge` must be from 0 to 1/100 of the body's diagonal ({diag})")));
}
Ok(m)
}
/// Replace `body`'s mesh with `nodes` and `hexes`, sharing nodes of other bodies within
/// `merge`; tag the elements with the body and `boundary` with its B-rep faces.
fn add_mesh(
s: &mut Session,
body: &super::hex::Input,
nodes: &[[f64; 3]],
hexes: &[[u32; 8]],
boundary: &[BoundaryFace],
merge: f64,
) -> Result<(Value, u32, usize)> {
let mut model = s.model.clone();
let cleared = super::replace::clear(&mut model, [body.name.as_str()]);
let (first_node, first_element) = super::fresh_labels(&model, nodes.len(), hexes.len())?;
let welded = if merge > 0.0 { weld(&model, nodes, merge)? } else { vec![None; nodes.len()] };
let mut labels = Vec::with_capacity(nodes.len());
let mut added = 0u32;
for (xyz, existing) in nodes.iter().zip(&welded) {
let label = match existing {
Some(l) => *l,
None => {
let l = first_node + added;
model.set_node(l, *xyz)?;
added += 1;
l
}
};
labels.push(label);
}
let joined: BTreeSet<u32> = welded.iter().flatten().copied().collect();
let label = |i: u32| labels.get(i as usize).copied().ok_or_else(|| EngineError::Params(format!("node {i} has no label")));
let elements: Vec<(ElementKind, Vec<u32>)> =
hexes.iter().map(|h| Ok((ElementKind::Hex8, h.iter().map(|&i| label(i)).collect::<Result<Vec<u32>>>()?))).collect::<Result<_>>()?;
check_conforming(&model, &elements, &joined, merge)?;
let body_tag = model.geometry_tag(&GeometryRef::body(body.name.as_str())?)?;
for (l, (kind, nodes)) in (first_element..).zip(elements) {
model.set_element(l, kind, nodes)?;
model.tag_element(l, body_tag)?;
}
super::hex::tag_faces(&mut model, std::slice::from_ref(body), |_| 0, boundary, first_element)?;
s.model = model;
let reply = json!({"first_node": first_node, "first_element": first_element, "nodes": added, "joined": joined.len()});
let reply = cleared.report(s, reply);
Ok((reply, first_element, joined.len()))
}
fn extend(reply: &mut Value, more: Value) {
if let (Some(o), Some(m)) = (reply.as_object_mut(), more.as_object()) {
for (k, v) in m {
o.insert(k.clone(), v.clone());
}
}
}
fn round3(x: f64) -> Value {
if x.is_finite() { json!((x * 1000.0).round() / 1000.0) } else { Value::Null }
}
fn bounded<T>(v: &[T], what: &str) -> Result<()> {
if v.len() > MAX_LIST { Err(EngineError::Params(format!("`{what}` lists more than {MAX_LIST} entries"))) } else { Ok(()) }
}
/// The blocks for a reply: corners (positions before projection) and divisions.
fn listing(t: &Topology, divisions: Option<&[[usize; 3]]>) -> Vec<Value> {
t.blocks
.iter()
.enumerate()
.take(500)
.map(|(i, v)| {
let corners: Vec<Value> =
v.iter().map(|&k| t.vertices.get(k as usize).map_or(Value::Null, |p| json!(p.map(|x| (x * 1e4).round() / 1e4)))).collect();
let mut b = json!({"block": i, "corners": corners});
if let Some(d) = divisions.and_then(|d| d.get(i)) {
b["divisions"] = json!(d);
}
b
})
.collect()
}
/// The base topology from the template parameters.
fn topology(p: &ProjectParams, lo: [f64; 3], hi: [f64; 3]) -> Result<Topology> {
let centre = [0, 1, 2].map(|d| 0.5 * (lo[d] + hi[d]));
let inset = p.inset.unwrap_or(0.55);
let t = match p.template.as_deref().unwrap_or(if p.blocks.is_some() { "blocks" } else { "grid" }) {
"sphere" => {
let r = p.radius.unwrap_or_else(|| (0..3).map(|d| 0.5 * (hi[d] - lo[d])).fold(0.0, f64::max));
Topology::sphere(p.center.unwrap_or(centre), r, inset)?
}
"butterfly" => {
let axis = p.axis.unwrap_or([0.0, 0.0, 1.0]);
let c = p.center.unwrap_or(centre);
let len = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
if !(len > 0.0 && len.is_finite()) {
return Err(EngineError::invalid("axis", "must be a nonzero direction"));
}
let a = axis.map(|x| x / len);
// The bounding box's extent along the axis and across it.
let corners: Vec<[f64; 3]> = (0..8).map(|k| [0, 1, 2].map(|d| if k >> d & 1 == 1 { hi[d] } else { lo[d] })).collect();
let along: Vec<f64> = corners.iter().map(|q| (0..3).map(|d| (q[d] - c[d]) * a[d]).sum()).collect();
let across = corners
.iter()
.zip(&along)
.map(|(q, s)| (0..3).map(|d| (q[d] - c[d] - s * a[d]).powi(2)).sum::<f64>().sqrt() / 2f64.sqrt())
.fold(0.0, f64::max);
let r = p.radius.unwrap_or(across);
let levels = p.levels.clone().unwrap_or_else(|| {
vec![along.iter().copied().fold(f64::INFINITY, f64::min), along.iter().copied().fold(f64::NEG_INFINITY, f64::max)]
});
if levels.len() > 1001 {
return Err(EngineError::Params("`levels` lists more than 1001 levels".into()));
}
Topology::butterfly(c, a, r, &levels, inset)?
}
"blocks" => {
let b = p.blocks.as_ref().ok_or_else(|| EngineError::Params("template `blocks` needs `blocks`: {vertices, blocks}".into()))?;
bounded(&b.blocks, "blocks.blocks")?;
if b.vertices.len() > 8 * MAX_LIST {
return Err(EngineError::Params("`blocks.vertices` lists too many vertices".into()));
}
Topology { vertices: b.vertices.clone(), blocks: b.blocks.clone() }
}
"grid" => {
let g = p.grid.as_ref();
let planes: [Vec<f64>; 3] = match g.and_then(|g| g.planes.as_ref()) {
Some(pl) => {
if let Some(k) = pl.keys().find(|k| !matches!(k.as_str(), "x" | "y" | "z")) {
return Err(EngineError::Params(format!("grid planes are keyed x, y and z, not `{k}`")));
}
[("x", 0), ("y", 1), ("z", 2)].map(|(k, d)| pl.get(k).cloned().unwrap_or_else(|| vec![lo[d], hi[d]]))
}
None => {
let cells = g.and_then(|g| g.cells).unwrap_or([1, 1, 1]);
if cells.iter().any(|&c| !(1..=1000).contains(&c)) {
return Err(EngineError::invalid("grid.cells", "1 to 1000 per direction"));
}
[0, 1, 2].map(|d| (0..=cells[d]).map(|q| lo[d] + (hi[d] - lo[d]) * q as f64 / cells[d] as f64).collect())
}
};
if planes.iter().any(|p| p.len() > 1001) {
return Err(EngineError::Params("at most 1000 grid cells per direction".into()));
}
let mut t = Topology::grid([&planes[0], &planes[1], &planes[2]])?;
if let Some(carve) = g.map(|g| &g.carve).filter(|c| !c.is_empty()) {
bounded(carve, "grid.carve")?;
let n = planes.each_ref().map(|p| p.len() - 1);
let mut ranges = Vec::with_capacity(carve.len());
for c in carve {
if let Some(k) = c.keys().find(|k| !matches!(k.as_str(), "i" | "j" | "k")) {
return Err(EngineError::Params(format!("carve ranges are keyed i, j and k, not `{k}`")));
}
ranges.push([("i", 0), ("j", 1), ("k", 2)].map(|(k, d)| c.get(k).copied().unwrap_or([0, n[d].saturating_sub(1)])));
}
t.carve(n, &ranges)?;
}
t
}
other => return Err(EngineError::Params(format!("unknown template `{other}` (grid, sphere, butterfly or blocks)"))),
};
Ok(t)
}
fn hex_project(s: &mut Session, raw: &Value) -> Result<Value> {
let p: ProjectParams = serde_json::from_value(raw.clone()).map_err(|e| EngineError::Params(format!("mesh.hex.project: {e}")))?;
bounded(&p.operations, "operations")?;
bounded(&p.faces, "faces")?;
bounded(&p.divisions, "divisions")?;
bounded(&p.fixed, "fixed")?;
let b = open_body(s, &p.body, p.tolerance, raw)?;
let diag = b.input.diagonal;
let size = element_size(p.size, diag)?;
let mut t = topology(&p, b.lo, b.hi)?;
operate(&mut t, &p.operations)?;
if p.preview {
return Ok(json!({"body": p.body, "blocks": t.blocks.len(), "topology": listing(&t, None)}));
}
let mut faces = Vec::with_capacity(p.faces.len());
for f in &p.faces {
bounded(&f.to, "faces.to")?;
faces.push((f.block, face_of(&t, f.block, &f.face)?, f.to.iter().map(|n| face_index(&b, &p.body, n)).collect::<Result<Vec<u32>>>()?));
}
let mut divisions = Vec::with_capacity(p.divisions.len());
for d in &p.divisions {
if !(1..=3).contains(&d.direction) {
return Err(EngineError::invalid("divisions.direction", "1, 2 or 3 (along 1→2, 1→4, 1→5)"));
}
divisions.push((d.block, d.direction - 1, d.count));
}
let smoothing = match p.smoothing.as_deref().unwrap_or("elliptic") {
"elliptic" => Smoothing::Elliptic,
"elliptic-controlled" => Smoothing::EllipticControlled,
"laplace" => Smoothing::Laplace,
"none" => Smoothing::None,
other => return Err(EngineError::Params(format!("unknown smoothing `{other}` (elliptic, elliptic-controlled, laplace or none)"))),
};
let iterations = p.iterations.unwrap_or(100);
if iterations > 10_000 {
return Err(EngineError::invalid("iterations", "at most 10000"));
}
let min_quality = p.min_quality.unwrap_or(fieldcraft_mesh::project::DEFAULT_MIN_QUALITY);
if !(-1.0..=1.0).contains(&min_quality) {
return Err(EngineError::invalid("min_quality", "from -1 to 1"));
}
let max_arc_angle = p.max_arc_angle.unwrap_or(fieldcraft_mesh::project::DEFAULT_MAX_ARC_ANGLE.to_degrees());
if !(0.0..=90.0).contains(&max_arc_angle) {
return Err(EngineError::invalid("max_arc_angle", "from 0 to 90 degrees"));
}
let fixed = if p.fixed.is_empty() {
Vec::new()
} else {
let patch = fixed_patch(s, &b, &p.body, &p.fixed, size)?;
if patch.quads.is_empty() {
return Err(EngineError::Params(format!(
"`fixed`: no other body's mesh has element faces on faces {:?} of `{}`; mesh the neighbour first",
p.fixed, p.body
)));
}
vec![patch]
};
let opts = ProjectOptions {
size,
max_arc_angle: max_arc_angle.to_radians(),
divisions,
faces,
smoothing,
iterations,
max_elements: fieldcraft_model::MAX_ELEMENTS / 8,
min_quality,
fixed,
planes: Vec::new(),
};
let merge = merge_distance(p.merge, diag)?;
project_body(s, &p.body, b, &t, &opts, merge)
}
/// The projection mesh of topology `t` over body `b`, not added to the model.
pub(crate) fn project_only(b: &Body, name: &str, t: &Topology, opts: &ProjectOptions) -> Result<fieldcraft_mesh::project::ProjectedMesh> {
project_mesh(t, &b.surface, &b.target, opts).map_err(|e| EngineError::of_body(name, e))
}
/// Replace body `name`'s mesh (opened as `b`) with `nodes` and `hexes` made for it elsewhere
/// (a part of another body's mesh): its outer faces (those no other of the hexes shares) tagged
/// with the face `tags` names for them (element, face number) when `b` has it, else with the
/// B-rep face of `b` nearest their middle. Nodes within `merge` of other meshes' join them.
pub(crate) fn add_hexes(
s: &mut Session,
b: &Body,
nodes: &[[f64; 3]],
hexes: &[[u32; 8]],
tags: &BTreeMap<(u32, u8), String>,
merge: f64,
) -> Result<Value> {
use fieldcraft_mesh::project::Geometry;
let faces = ElementKind::Hex8.faces();
let mut count: BTreeMap<[u32; 4], usize> = BTreeMap::new();
let quad = |h: &[u32; 8], f: &[usize]| -> [u32; 4] { [0, 1, 2, 3].map(|i| f.get(i).and_then(|&l| h.get(l)).copied().unwrap_or(0)) };
for h in hexes {
for f in faces {
let mut k = quad(h, f);
k.sort_unstable();
*count.entry(k).or_insert(0) += 1;
}
}
let face_count = b.input.face_names.len() as u32;
let mut boundary = Vec::new();
for (e, h) in hexes.iter().enumerate() {
for (fi, f) in faces.iter().enumerate() {
let q = quad(h, f);
let mut k = q;
k.sort_unstable();
if count.get(&k) != Some(&1) {
continue;
}
let mid = q.iter().fold([0.0; 3], |a, &v| {
let p = nodes.get(v as usize).copied().unwrap_or_default();
[a[0] + 0.25 * p[0], a[1] + 0.25 * p[1], a[2] + 0.25 * p[2]]
});
if let Some(f) = tags.get(&(e as u32, fi as u8 + 1)).and_then(|n| b.input.face_names.iter().position(|x| x == n)) {
boundary.push(BoundaryFace { element: e as u32, face: fi as u8 + 1, brep_face: f as u32 });
continue;
}
let nearest = (0..face_count)
.filter_map(|f| b.target.project(f, mid).map(|p| ((0..3).map(|i| (p[i] - mid[i]).powi(2)).sum::<f64>(), f)))
.min_by(|x, y| x.0.total_cmp(&y.0));
if let Some((_, brep_face)) = nearest {
boundary.push(BoundaryFace { element: e as u32, face: fi as u8 + 1, brep_face });
}
}
}
let (reply, _, _) = add_mesh(s, &b.input, nodes, hexes, &boundary, merge)?;
Ok(reply)
}
/// Fraction of the element size within which a node of another mesh lies on a face.
const ON_FACE: f64 = 0.05;
/// The quads other bodies' meshes have on faces `names` of body `body` (opened as `b`), as one
/// fixed patch: the element faces of other bodies, tagged as on their boundary, whose nodes all
/// lie on one of those faces (within [`ON_FACE`] of the element size `size`). Empty when no other
/// mesh is there.
pub(crate) fn fixed_patch(s: &Session, b: &Body, body: &str, names: &[String], size: f64) -> Result<FixedPatch> {
use fieldcraft_mesh::project::Geometry;
let faces: Vec<u32> = names.iter().map(|n| face_index(b, body, n)).collect::<Result<_>>()?;
let tol = (ON_FACE * size).max(1e-9 * b.diagonal());
let c = s.model.classification();
let mut on: BTreeMap<u32, Option<usize>> = BTreeMap::new();
let mut index: BTreeMap<u32, u32> = BTreeMap::new();
let mut seen: BTreeSet<[u32; 4]> = BTreeSet::new();
let mut patch = FixedPatch { faces: faces.clone(), ..FixedPatch::default() };
for (&element, tags) in c.elements() {
let theirs = tags.region.and_then(|r| c.entity(r)).is_some_and(|g| g.body != body);
if !theirs {
continue;
}
for &face in tags.faces.keys() {
let Some(labels) = s.model.face_nodes(fieldcraft_model::ElementFace { element, face }) else { continue };
let Ok(quad) = <[u32; 4]>::try_from(labels) else { continue };
let mut lies = true;
for l in quad {
let hit = *on.entry(l).or_insert_with(|| {
let x = s.model.node(l)?.xyz;
faces
.iter()
.position(|&f| b.target.project(f, x).is_some_and(|q| (0..3).map(|k| (q[k] - x[k]).powi(2)).sum::<f64>().sqrt() <= tol))
});
lies &= hit.is_some();
}
let mut key = quad;
key.sort_unstable();
if !lies || !seen.insert(key) {
continue;
}
let q = quad.map(|l| {
*index.entry(l).or_insert_with(|| {
patch.nodes.push(s.model.node(l).map_or([0.0; 3], |n| n.xyz));
patch.nodes.len() as u32 - 1
})
});
patch.quads.push(q);
}
}
Ok(patch)
}
/// Mesh body `name` (opened as `b`) with the block topology `t` projected onto it, replace its
/// mesh, and reply what `mesh.hex.project` replies. The plan's projection mesher (plan.rs) takes
/// the same path.
pub(crate) fn project_body(s: &mut Session, name: &str, b: Body, t: &Topology, opts: &ProjectOptions, merge: f64) -> Result<Value> {
let m = project_mesh(t, &b.surface, &b.target, opts).map_err(|e| EngineError::of_body(name, e))?;
let r = &m.report;
let names = b.input.face_names.clone();
let face_name = |f: &u32| names.get(*f as usize).cloned().unwrap_or_else(|| f.to_string());
let blocks = listing(t, Some(&r.divisions));
let targets: Vec<Value> = r.targets.iter().map(|set| json!(set.iter().map(face_name).collect::<Vec<_>>())).collect();
let mut reply = json!({
"body": name,
"kind": "hex8",
"blocks": r.blocks,
"elements": r.elements,
"initial_min_scaled_jacobian": round3(r.initial_min_scaled_jacobian),
"min_scaled_jacobian": round3(r.min_scaled_jacobian),
"mean_scaled_jacobian": round3(r.mean_scaled_jacobian),
"smoothing": {"iterations": r.smoothing.iterations, "refused": r.smoothing.refused},
"fixed_nodes": r.fixed_nodes,
"max_gap": r.max_gap,
"volume": r.volume,
"body_volume": r.surface_volume,
"targets": targets,
"topology": blocks,
});
let (more, _, _) = add_mesh(s, &b.input, &m.nodes, &m.hexes, &m.boundary, merge)?;
extend(&mut reply, more);
Ok(reply)
}
#[cfg(test)]
mod tests;
+300
View File
@@ -0,0 +1,300 @@
use serde_json::{Value, json};
use crate::Session;
/// A session with the design the SolidCraft commands build.
fn session(commands: Value) -> (crate::tests::TempDir, Session) {
let bytes = fieldcraft_geometry::script_design(&json!({ "commands": commands }).to_string()).unwrap();
let (dir, path) = crate::tests::design_file(&bytes);
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path})).unwrap();
(dir, s)
}
fn sj(r: &Value) -> f64 {
r["min_scaled_jacobian"].as_f64().unwrap()
}
fn faces(s: &mut Session, body: &str) -> Vec<String> {
let b = s.execute("geometry.bodies", &json!({})).unwrap();
let body = b.as_array().unwrap().iter().find(|x| x["name"] == body).unwrap().clone();
body["faces"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap().to_string()).collect()
}
/// The 7-block sphere at two refinements: all hex, min scaled Jacobian at least 0.5, the volume
/// converging, and a set on the sphere's face resolving to the projected boundary.
#[test]
fn sphere_seven_blocks_at_two_refinements() {
let (_d, mut s) = session(json!([{"command": "solid.sphere", "params": {"radius": 10, "body_name": "Ball"}}]));
let exact = 4.0 / 3.0 * std::f64::consts::PI * 1000.0;
let mut errors = Vec::new();
for size in [3.0, 1.5] {
let r = s.execute("mesh.hex.project", &json!({"body": "Ball", "template": "sphere", "size": size})).unwrap();
assert_eq!(r["blocks"], 7, "{r}");
assert!(sj(&r) >= 0.5, "{r}");
errors.push((r["volume"].as_f64().unwrap() - exact).abs() / exact);
assert_eq!(s.model.elements().len() as u64, r["elements"].as_u64().unwrap());
}
assert!(errors[1] < errors[0] && errors[1] < 0.03, "{errors:?}");
let names = faces(&mut s, "Ball");
let geometry: Vec<Value> = names.iter().map(|f| json!({"body": "Ball", "face": f})).collect();
s.execute("model.set.create", &json!({"name": "skin", "geometry": geometry})).unwrap();
let skin = s.model.resolve_set("skin").unwrap();
assert!(!skin.faces.is_empty());
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["valid"].as_u64(), Some(s.model.elements().len() as u64), "{q}");
}
/// A disc: the five-block butterfly, its rims on both circles.
#[test]
fn disc_butterfly() {
let (_d, mut s) = session(json!([{"command": "solid.cylinder", "params": {"radius": 10, "height": 2, "body_name": "Disc"}}]));
let r = s.execute("mesh.hex.project", &json!({"body": "Disc", "template": "butterfly", "size": 1})).unwrap();
assert_eq!(r["blocks"], 5, "{r}");
assert!(sj(&r) >= 0.5, "{r}");
let v = r["volume"].as_f64().unwrap();
assert!((v - std::f64::consts::PI * 200.0).abs() < 0.01 * 200.0 * std::f64::consts::PI, "{r}");
assert_eq!(r["targets"].as_array().unwrap().len(), 3, "{r}");
}
/// A cylinder with a side branch: the main tube a butterfly along X, the top block of its middle
/// layer O-gridded open upward and its open face extruded into the branch; the junction edges go
/// onto the curve where the two cylinders meet.
#[test]
fn cylinder_with_a_side_branch() {
let (_d, mut s) = session(json!([
{"command": "solid.cylinder", "params": {"base": [-30, 0, 0], "axis": "X", "radius": 10, "height": 60, "body_name": "Tee"}},
{"command": "solid.cylinder", "params": {"base": [0, 0, 0], "axis": "Z", "radius": 7, "height": 25, "operation": "join", "targets": ["Tee"]}}
]));
let r = s
.execute(
"mesh.hex.project",
&json!({"body": "Tee", "template": "butterfly", "center": [0, 0, 0], "axis": [1, 0, 0], "radius": 10, "levels": [-30, -7, 7, 30], "size": 1.5,
"operations": [{"op": "o_grid", "block": 8, "open": ["+z"], "inset": 0.5, "extrude": {"offset": [0, 0, 18], "layers": 2}}]}),
)
.unwrap();
assert!(sj(&r) >= 0.3, "{r}");
let exact = std::f64::consts::PI * (100.0 * 60.0) + std::f64::consts::PI * 49.0 * 15.0;
assert!((r["volume"].as_f64().unwrap() - exact).abs() < 0.03 * exact, "{r}");
assert!(r["max_gap"].as_f64().unwrap() < 1e-6, "{r}");
}
#[test]
fn hostile_parameters_change_nothing() {
let (_d, mut s) = session(json!([{"command": "solid.sphere", "params": {"radius": 10, "body_name": "Ball"}}]));
let model = s.model.clone();
for bad in [
json!({"body": "Nope", "template": "sphere"}),
json!({"body": "Ball", "template": "teapot"}),
json!({"body": "Ball", "template": "sphere", "size": -1}),
json!({"body": "Ball", "template": "sphere", "size": 1e-9}),
json!({"body": "Ball", "template": "sphere", "radius": f64::MAX}),
json!({"body": "Ball", "template": "sphere", "inset": 2}),
json!({"body": "Ball", "template": "sphere", "smoothing": "magic"}),
json!({"body": "Ball", "template": "sphere", "iterations": 1_000_000}),
json!({"body": "Ball", "template": "sphere", "faces": [{"block": 0, "face": 1, "to": ["nope"]}]}),
json!({"body": "Ball", "template": "sphere", "divisions": [{"block": 99, "direction": 1, "count": 2}]}),
json!({"body": "Ball", "template": "sphere", "divisions": [{"block": 0, "direction": 4, "count": 2}]}),
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "remove", "blocks": [0, 1, 2, 3, 4, 5, 6]}]}),
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "o_grid", "block": 77}]}),
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "extrude", "faces": [[0, "+w"]], "offset": [0, 0, 1]}]}),
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "merge", "distance": -1}]}),
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "split", "block": 0, "direction": "w", "at": [0.5]}]}),
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "split", "block": 0, "direction": 4, "at": [0.5]}]}),
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "split", "block": 0, "direction": "x", "at": [1.5]}]}),
json!({"body": "Ball", "template": "sphere", "operations": [{"op": "split", "block": 99, "direction": 1, "at": [0.5]}]}),
json!({"body": "Ball", "template": "blocks"}),
json!({"body": "Ball", "template": "blocks", "blocks": {"vertices": [[0, 0, 0]], "blocks": [[0, 0, 0, 0, 0, 0, 0, 0]]}}),
json!({"body": "Ball", "grid": {"cells": [0, 1, 1]}}),
json!({"body": "Ball", "grid": {"cells": [2, 2, 2], "carve": [{"q": [0, 1]}]}}),
json!({"body": "Ball", "template": "sphere", "unknown": 1}),
] {
assert!(s.execute("mesh.hex.project", &bad).is_err(), "{bad}");
assert_eq!(s.model, model, "{bad}");
}
// A preview lists the topology without meshing.
let r = s.execute("mesh.hex.project", &json!({"body": "Ball", "template": "sphere", "preview": true})).unwrap();
assert_eq!(r["topology"].as_array().unwrap().len(), 7);
assert_eq!(s.model, model);
}
fn cup_session() -> (crate::tests::TempDir, Session) {
session(json!([
{"command": "solid.cylinder", "params": {"radius": 20, "height": 30, "body_name": "Cup"}},
{"command": "solid.cylinder", "params": {"base": [0, 0, 4], "radius": 16, "height": 27, "operation": "cut", "targets": ["Cup"]}},
{"command": "solid.box", "params": {"length": 11, "width": 6, "height": 16, "corner": [19, -3, 8], "operation": "new", "body_name": "Handle"}},
{"command": "solid.box", "params": {"length": 8, "width": 8, "height": 8, "corner": [18, -4, 12], "operation": "cut", "targets": ["Handle"]}},
{"command": "solid.combine", "params": {"target": "Cup", "tools": ["Handle"], "operation": "join"}}
]))
}
fn cup_params(size: f64, inset: Value) -> Value {
json!({"body": "Cup", "template": "butterfly", "center": [0, 0, 0], "axis": [0, 0, 1], "radius": 20, "levels": [0, 4, 6, 14, 18, 26, 30], "size": size,
"operations": [
{"op": "o_grid", "block": 0, "open": [1, 2], "inset": 0.6},
{"op": "split", "block": 14, "direction": "y", "at": [0.36, 0.64]},
{"op": "o_grid", "block": 54, "open": ["+x"], "inset": inset},
{"op": "o_grid", "block": 62, "open": ["+x"], "inset": inset},
{"op": "extrude", "faces": [[54, "+x"]], "offset": [12, 0, 0]},
{"op": "extrude", "faces": [[84, "+x"]], "offset": [4, 0, 0]},
{"op": "extrude", "faces": [[62, "+x"]], "offset": [12, 0, 0]},
{"op": "extrude", "faces": [[86, "+x"]], "offset": [4, 0, 0]},
{"op": "connect", "from": [85, "+z"], "to": [87, "-z"], "layers": 2},
{"op": "remove", "blocks": [1, 2, 3, 4, 5, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45]}
]})
}
/// A cup with a handle: a double O-grid section (the floor's butterfly inside the wall's), the
/// cavity carved, the handle a square bar extruded from O-grids in the wall and closed by a
/// connection. The wall's +x petals are split in three around the handle first, so each arm's
/// footprint sits in a block face with even margins round it (#399).
fn cup_with_a_handle_at(size: f64) {
let (_d, mut s) = cup_session();
let r = s.execute("mesh.hex.project", &cup_params(size, json!(0.5))).unwrap();
assert_thin_layers_hold(&mut s, &r);
let (v, body) = (r["volume"].as_f64().unwrap(), r["body_volume"].as_f64().unwrap());
assert!((v - body).abs() < 0.01 * body, "{r}");
assert!(r["max_gap"].as_f64().unwrap() < 1e-6, "{r}");
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["valid"].as_u64(), Some(s.model.elements().len() as u64), "{q}");
}
#[test]
fn cup_with_a_handle() {
cup_with_a_handle_at(2.0);
}
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn cup_with_a_handle_refined() {
cup_with_a_handle_at(1.0);
cup_with_a_handle_at(0.75);
}
/// Every element valid, the scaled Jacobian at least 0.5 and no edge ratio above 10 (#418).
fn assert_thin_layers_hold(s: &mut Session, r: &Value) {
assert!(sj(r) >= 0.5, "{r}");
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["valid"].as_u64(), Some(s.model.elements().len() as u64), "{q}");
assert!(q["metrics"]["scaled_jacobian"]["min"].as_f64().unwrap() >= 0.5, "{q}");
assert!(q["metrics"]["edge_ratio"]["max"].as_f64().unwrap() <= 10.0, "{q}");
}
/// Without the split, the ring block under the upper arm is squeezed between the petal's edge
/// and the window-top plane: the mesher refuses the mesh and names the worst element's block.
#[test]
fn squeezed_junction_elements_are_reported_not_meshed() {
let (_d, mut s) = cup_session();
let mut p = cup_params(2.0, json!([0.5, 0.3, 0.3]));
p["operations"] = json!([
{"op": "o_grid", "block": 0, "open": [1, 2], "inset": 0.6},
{"op": "o_grid", "block": 14, "open": ["+x"], "inset": [0.5, 0.3, 0.3]},
{"op": "o_grid", "block": 22, "open": ["+x"], "inset": [0.5, 0.3, 0.3]},
{"op": "extrude", "faces": [[14, "+x"]], "offset": [12, 0, 0]},
{"op": "extrude", "faces": [[44, "+x"]], "offset": [4, 0, 0]},
{"op": "extrude", "faces": [[22, "+x"]], "offset": [12, 0, 0]},
{"op": "extrude", "faces": [[46, "+x"]], "offset": [4, 0, 0]},
{"op": "connect", "from": [45, "+z"], "to": [47, "-z"], "layers": 2},
{"op": "remove", "blocks": [1, 2, 3, 4, 5]}
]);
// Smoothing lifts its worst elements just over the default floor (0.21): the squeeze is
// reported at any floor an analyst would accept.
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}");
assert_eq!(s.model, model);
}
/// The cup without its handle: the floor's O-grid inside the wall's butterfly, cavity carved.
/// The wall (4 mm) shares its radial divisions with the floor's longer radial edges; counted
/// from the longest, the wall was cut into slivers that sheared at the cavity's rim, and
/// smoothing collapsed their edges (edge ratio 172 at size 1, #418).
fn cup_floor_double_o_grid_at(size: f64) {
let (_d, mut s) = session(json!([
{"command": "solid.cylinder", "params": {"radius": 20, "height": 30, "body_name": "Cup"}},
{"command": "solid.cylinder", "params": {"base": [0, 0, 4], "radius": 16, "height": 27, "operation": "cut", "targets": ["Cup"]}}
]));
let mut p = cup_params(size, json!(0.5));
p["operations"] = json!([{"op": "o_grid", "block": 0, "open": [1, 2], "inset": 0.6}, {"op": "remove", "blocks": [1, 2, 3, 4, 5]}]);
let r = s.execute("mesh.hex.project", &p).unwrap();
assert_thin_layers_hold(&mut s, &r);
let (v, body) = (r["volume"].as_f64().unwrap(), r["body_volume"].as_f64().unwrap());
assert!((v - body).abs() < 0.02 * body, "{r}");
}
#[test]
fn cup_floor_double_o_grid() {
cup_floor_double_o_grid_at(2.0);
cup_floor_double_o_grid_at(1.0);
}
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn cup_floor_double_o_grid_refined() {
cup_floor_double_o_grid_at(0.75);
}
/// Thin layers like a sealed enclosure's: a 1.6 mm floor slab and 1 mm walls round a pocket.
/// The grid is regular already; smoothing must not even out its graded layers (it took the
/// scaled Jacobian from 1 to 0.5 at size 2.5, #418).
#[test]
fn thin_enclosure_layers() {
let (_d, mut s) = session(json!([
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 12, "body_name": "Case"}},
{"command": "solid.box", "params": {"length": 38, "width": 28, "height": 20, "corner": [1, 1, 1.6], "operation": "cut", "targets": ["Case"]}}
]));
for size in [1.0, 2.5] {
let r = s
.execute(
"mesh.hex.project",
&json!({"body": "Case", "size": size, "grid": {"planes": {"x": [0, 1, 39, 40], "y": [0, 1, 29, 30], "z": [0, 1.6, 12]}, "carve": [{"i": [1, 1], "j": [1, 1], "k": [1, 1]}]}}),
)
.unwrap();
assert_thin_layers_hold(&mut s, &r);
assert!(sj(&r) > 0.99, "{r}");
}
}
/// Two discs stacked as two bodies: the lower meshed fine, the upper meshed against it with its
/// bottom face fixed (#490) takes the lower's top quads as given — the same divisions though its
/// own size is coarser, its nodes there the lower's (joined, not copied), all hex and valid.
#[test]
fn a_fixed_face_takes_the_neighbours_mesh_as_given() {
let (_d, mut s) = session(json!([
{"command": "solid.cylinder", "params": {"radius": 10, "height": 2, "body_name": "Low"}},
{"command": "solid.cylinder", "params": {"base": [0, 0, 2], "radius": 10, "height": 2, "body_name": "High"}}
]));
s.execute("mesh.hex.project", &json!({"body": "Low", "template": "butterfly", "size": 0.75})).unwrap();
let nodes_before = s.model.nodes().len();
let top_quads = |s: &Session, body: &str, z: f64| {
let c = s.model.classification();
s.model
.body_elements(body)
.into_iter()
.flat_map(|e| c.elements().get(&e).map(|t| t.faces.keys().map(|&f| (e, f)).collect::<Vec<_>>()).unwrap_or_default())
.filter(|&(element, face)| {
s.model
.face_nodes(fieldcraft_model::ElementFace { element, face })
.is_some_and(|ns| ns.iter().all(|n| (s.model.node(*n).unwrap().xyz[2] - z).abs() < 1e-9))
})
.count()
};
let shared = top_quads(&s, "Low", 2.0);
let r = s.execute("mesh.hex.project", &json!({"body": "High", "template": "butterfly", "size": 1.5, "fixed": ["F2:bottom"]})).unwrap();
assert!(sj(&r) >= 0.5, "{r}");
// Every node of the lower's top joined: the upper adds only its own.
let on_face = r["fixed_nodes"].as_u64().unwrap() as usize;
assert!(on_face > 0 && r["joined"].as_u64().unwrap() as usize == on_face, "{r}");
assert_eq!(s.model.nodes().len(), nodes_before + r["nodes"].as_u64().unwrap() as usize);
assert_eq!(top_quads(&s, "High", 4.0), shared, "{r}");
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["valid"].as_u64(), Some(s.model.elements().len() as u64), "{q}");
// A face no other mesh covers, or not the body's, is an error that changes nothing.
let before = s.model.clone();
for bad in [json!(["F2:top"]), json!(["nope"])] {
assert!(s.execute("mesh.hex.project", &json!({"body": "High", "template": "butterfly", "size": 1.5, "fixed": bad})).is_err(), "{bad}");
}
assert!(s.model == before);
}
+197 -12
View File
@@ -1,5 +1,5 @@
//! Node relaxation (#398): `mesh.node.relax` moves the nodes inside a sphere of influence (or
//! listed nodes, elements or a set) to fix the worst elements there, nodes on the geometry
//! the nodes on listed CAD faces, or listed nodes or elements) to fix the worst elements there, nodes on the geometry
//! staying on it ([`fieldcraft_mesh::relax`]), and keeps what it did as a relaxation field of the
//! model; `mesh.node.relax.apply` does a field again on the current mesh (after a remesh), and a
//! field marked `reapply` is applied by itself after every remesh of its bodies.
@@ -17,7 +17,7 @@ use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_mesh::relax::{self, Constraints, Falloff, Freedom, RegionQuality, Relaxed};
use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
use fieldcraft_mesh::tet::geometry::{AnalyticSurface, Tessellation};
use fieldcraft_model::geometry::Entity;
use fieldcraft_model::geometry::{Entity, GeometryRef};
use fieldcraft_model::{Model, Relaxation};
use serde_json::{Value, json};
@@ -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) | "nodes": [labels] | "elements": [labels] | "set": "Name", "bodies"?: ["Body1"] (only their nodes), "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)} — 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). 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, and the field kept. 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 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"#,
form: Some(super::forms::NODE_RELAX),
kind: CommandKind::Model,
requires: &[Requirement::Mesh],
@@ -52,6 +52,7 @@ const REMESH: &[&str] = &[
"mesh.hex.sweep",
"mesh.hex.mapped",
"mesh.hex.blocks",
"mesh.plan.apply",
"mesh.hex.octree",
"mesh.cells",
"mesh.block",
@@ -280,8 +281,11 @@ fn design_geometry(s: &Session, nodes: &BTreeSet<u32>) -> Result<DesignGeometry>
fn body_nodes(model: &Model, p: &Value) -> Result<Option<(Vec<String>, BTreeSet<u32>)>> {
let Some(bodies) = super::opt::<Vec<String>>(p, "bodies")? else { return Ok(None) };
let mut nodes = BTreeSet::new();
// A body cut into cells by partitions is meshed as its cells (`Base/a`, `Base/b/a`, …).
let meshed: BTreeSet<String> = model.classification().entities().iter().map(|r| r.body.clone()).collect();
for b in &bodies {
let elements = model.body_elements(b);
let cell = format!("{b}/");
let elements: Vec<u32> = meshed.iter().filter(|m| *m == b || m.starts_with(&cell)).flat_map(|m| model.body_elements(m)).collect();
if elements.is_empty() {
return Err(EngineError::Params(format!("body `{}` has no mesh", b.chars().take(80).collect::<String>())));
}
@@ -302,6 +306,78 @@ fn point(p: &Value, key: &str) -> Result<Option<[f64; 3]>> {
}
}
/// The most faces `faces` may list.
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>> {
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")));
}
if let Some(r) = refs.iter().find(|r| !matches!(r.entity, Entity::Face(_))) {
return Err(EngineError::invalid("faces", format!("`{}`: give {{\"body\", \"face\"}}", r.body.chars().take(80).collect::<String>())));
}
Ok(refs)
}
/// The tags of the faces `refs` select, and the faces with no tagged mesh.
fn face_tags<'a>(model: &Model, refs: &'a [GeometryRef]) -> (BTreeSet<u32>, Vec<&'a GeometryRef>) {
let c = model.classification();
let mut tags = BTreeSet::new();
let mut missing = Vec::new();
for r in refs {
let found = c.tags_selected_by(r);
if found.is_empty() {
missing.push(r);
}
tags.extend(found);
}
(tags, missing)
}
/// The listed faces with no tagged mesh, as `{body, face}` (the reply names them).
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(),
}
}
/// 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>> {
let c = model.classification();
let (tags, missing) = face_tags(model, refs);
if tags.is_empty() {
let names: Vec<String> =
missing.iter().take(5).map(|r| format!("{}/{}", r.body, r.entity.name().unwrap_or_default()).chars().take(120).collect()).collect();
return Err(EngineError::Params(format!("no mesh is tagged with the faces ({}): mesh their bodies, or mesh.tag", names.join(", "))));
}
let mut on = BTreeSet::new();
for (&label, t) in c.elements() {
let Some(e) = model.element(label) else { continue };
if e.kind.dimension() == 2 && t.region.is_some_and(|r| tags.contains(&r)) {
on.extend(e.nodes.iter().copied());
}
for (&face, tag) in &t.faces {
if !tags.contains(tag) {
continue;
}
let Some(local) = e.kind.faces().get(usize::from(face).wrapping_sub(1)) else { continue };
on.extend(local.iter().filter_map(|&i| e.nodes.get(i).copied()));
}
}
let mut out = on.clone();
for e in model.elements().values() {
if e.nodes.iter().any(|n| on.contains(n)) {
out.extend(e.nodes.iter().copied());
}
}
Ok(out)
}
/// The sphere around element `label`: its centroid, and twice its longest corner edge.
fn around_element(model: &Model, label: u32) -> Result<([f64; 3], f64)> {
let e = model.element(label).ok_or_else(|| EngineError::Params(format!("no element {label}")))?;
@@ -343,11 +419,11 @@ pub(crate) fn weights(model: &Model, p: &Value) -> Result<(BTreeMap<u32, f64>, O
.map(Some)?,
None => around.map(|a| a.1),
};
let listed = ["nodes", "elements", "set"].iter().filter(|k| p.get(**k).is_some()).count();
let listed = ["faces", "nodes", "elements"].iter().filter(|k| p.get(**k).is_some()).count();
if let (Some(c), Some(r)) = (center, radius) {
if listed > 0 {
return Err(EngineError::Params(
"give a sphere (`center` and `radius`, or `around_element`) or `nodes`, `elements` or `set`, not both".into(),
"give a sphere (`center` and `radius`, or `around_element`) or `faces`, `nodes` or `elements`, not both".into(),
));
}
return Ok((relax::sphere_weights(model, c, r, strength, falloff, only.as_ref())?, Some((c, r))));
@@ -363,11 +439,8 @@ pub(crate) fn weights(model: &Model, p: &Value) -> Result<(BTreeMap<u32, f64>, O
if p.get("elements").is_some() {
elements.extend(super::labels(p, "elements", fieldcraft_model::MAX_ELEMENTS)?);
}
if let Some(name) = super::opt::<String>(p, "set")? {
let set = model.resolve_set(&name)?;
nodes.extend(set.nodes.iter().copied());
elements.extend(set.elements.iter().copied());
elements.extend(set.faces.iter().map(|f| f.element));
if p.get("faces").is_some() {
nodes.extend(face_nodes(model, &face_refs(p)?)?);
}
for e in elements {
let el = model.element(e).ok_or_else(|| EngineError::Params(format!("no element {e}")))?;
@@ -375,7 +448,7 @@ pub(crate) fn weights(model: &Model, p: &Value) -> Result<(BTreeMap<u32, f64>, O
}
if listed == 0 {
return Err(EngineError::Params(
"say which nodes to relax: give one of `center` and `radius`, `around_element`, `nodes`, `elements` or `set`".into(),
"say which nodes to relax: give one of `center` and `radius`, `around_element`, `faces`, `nodes` or `elements`".into(),
));
}
if let Some(only) = &only {
@@ -413,6 +486,10 @@ pub struct Preview {
}
pub fn preview(s: &Session, params: &Value) -> Option<Preview> {
// The strong mode morphs a whole region: too heavy for a live preview.
if params.get("mode").and_then(Value::as_str) == Some("strong") {
return None;
}
let (w, sphere) = weights(&s.model, params).ok()?;
let g = design_geometry(s, &touched(&s.model, &w)).ok()?;
let relaxed = relax::relax(&s.model, &w, iterations(params).ok()?, &g).ok()?;
@@ -427,7 +504,114 @@ pub fn sphere(s: &Session, params: &Value) -> Option<([f64; 3], f64)> {
Some((c, r))
}
/// Default rounds of the strong mode.
const STRONG_ROUNDS: usize = 20;
/// `mesh.node.relax` with `"mode": "strong"`: morph the region round its bad elements
/// ([`relax::morph`]).
fn relax_strong(s: &mut Session, p: &Value) -> Result<Value> {
let only = body_nodes(&s.model, p)?;
let below = super::optional_number(p, "below", relax::MorphOptions::default().below)?;
if !(below > 0.0 && below <= 1.0) {
return Err(EngineError::param("below", ProblemKind::NotInRange { min: 0.0, max: 1.0 }));
}
let reach = match p.get("reach") {
None => None,
Some(_) => Some(super::optional_number(p, "reach", 0.0)?)
.filter(|r| *r > 0.0 && r.is_finite())
.ok_or_else(|| EngineError::param("reach", ProblemKind::NotPositive))
.map(Some)?,
};
let rounds = match p.get("iterations") {
None => STRONG_ROUNDS,
Some(v) => v
.as_u64()
.and_then(|i| usize::try_from(i).ok())
.filter(|i| (1..=relax::MAX_ROUNDS).contains(i))
.ok_or_else(|| EngineError::param("iterations", ProblemKind::NotInRange { min: 1.0, max: relax::MAX_ROUNDS as f64 }))?,
};
let listed = ["center", "radius", "around_element", "faces", "nodes", "elements"].iter().any(|k| p.get(*k).is_some());
let nodes: BTreeSet<u32> = if listed {
weights(&s.model, p)?.0.into_keys().collect()
} else if let Some((_, n)) = &only {
n.clone()
} else {
s.model.nodes().keys().copied().collect()
};
let nodes: BTreeSet<u32> = relax::uniform_weights(&s.model, &nodes, 1.0)?.into_keys().collect();
if nodes.is_empty() {
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 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()),
};
let bodies = only.map(|(b, _)| b).unwrap_or_default();
let reapply = super::opt::<bool>(p, "reapply")?.unwrap_or(false);
if !r.positions.is_empty() {
let cells = relax::field_cells(&s.model, &Relaxed { positions: r.positions.clone(), ..Relaxed::default() });
s.model.set_relaxation(&field_name, Relaxation { bodies, cells, reapply })?;
}
for (n, x) in &r.positions {
s.model.set_node(*n, *x)?;
}
let summary = format!(
"strong: moved {} node{} (largest {:.3e}) in {} round{}; scaled Jacobian min {:.3} → {:.3}, mean {:.3} → {:.3}{}",
r.moved,
if r.moved == 1 { "" } else { "s" },
r.max_displacement,
r.rounds.len(),
if r.rounds.len() == 1 { "" } else { "s" },
r.before.min_quality,
r.after.min_quality,
r.mean_before,
r.mean_after,
if r.positions.is_empty() {
format!("; nothing below {below} could be improved, nothing moved")
} else {
format!("; kept as field {field_name}")
}
);
let rounds: Vec<Value> = r
.rounds
.iter()
.map(|k| {
json!({"bad_elements": k.bad_elements, "bad_nodes": k.bad_nodes, "region_nodes": k.region_nodes, "reach": k.reach,
"clusters": k.clusters, "kept": k.kept, "min": k.min_quality, "mean": k.mean_quality})
})
.collect();
let mut before = quality_json(&r.before);
let mut after = quality_json(&r.after);
if let (Some(b), Some(a)) = (before.as_object_mut(), after.as_object_mut()) {
b.insert("mean_scaled_jacobian".into(), json!(r.mean_before));
a.insert("mean_scaled_jacobian".into(), json!(r.mean_after));
}
Ok(json!({
"mode": "strong",
"moved": r.moved,
"nodes_changed": r.positions.len(),
"max_displacement": r.max_displacement,
"iterations": r.rounds.len(),
"rounds": rounds,
"before": before,
"after": after,
"field": (!r.positions.is_empty()).then_some(field_name),
"faces_without_mesh": unmeshed_faces(&s.model, p),
"summary": summary,
}))
}
fn relax_nodes(s: &mut Session, p: &Value) -> Result<Value> {
match super::opt::<String>(p, "mode")?.as_deref() {
None | Some("guarded") => {}
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()));
}
let (w, sphere) = weights(&s.model, p)?;
if w.is_empty() {
return Err(EngineError::Params("no mesh nodes in the region to relax".into()));
@@ -469,6 +653,7 @@ fn relax_nodes(s: &mut Session, p: &Value) -> Result<Value> {
"before": quality_json(&r.before),
"after": quality_json(&r.after),
"field": (!r.positions.is_empty()).then_some(field_name),
"faces_without_mesh": unmeshed_faces(&s.model, p),
"summary": summary,
}))
}
+165
View File
@@ -192,3 +192,168 @@ fn hostile_parameters_never_panic() {
assert!(s.execute("mesh.node.relax.apply", &json!({"field": "nope"})).is_err());
assert!(s.execute("mesh.node.relax.apply", &json!({})).is_err());
}
/// A 20 mm box with an R2 fillet along its edge at x = z = 20 (axis x = z = 18, along y), tet
/// meshed; its fillet faces' persistent names.
fn filleted_box() -> (crate::tests::TempDir, Session, Vec<String>) {
let script = json!({"commands": [
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20, "body_name": "B"}},
{"command": "solid.fillet", "params": {"edges": [[20, 10, 20]], "radius": 2}},
]});
let bytes = fieldcraft_geometry::script_design(&script.to_string()).unwrap();
let (dir, path) = crate::tests::design_file(&bytes);
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path})).unwrap();
let features = s.execute("geometry.features", &json!({"body": "B"})).unwrap();
let fillet = features["bodies"][0]["features"].as_array().unwrap().iter().find(|f| f["type"] == "fillet").unwrap().clone();
let faces: Vec<String> = fillet["faces"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
assert!(!faces.is_empty(), "{fillet}");
s.execute("mesh.tet.generate", &json!({"size": 1.5})).unwrap();
(dir, s, faces)
}
fn fillet_radius(p: [f64; 3]) -> f64 {
((p[0] - 18.0).powi(2) + (p[2] - 18.0).powi(2)).sqrt()
}
/// #471: relaxing by CAD face. Relaxing the fillet faces of the tet mesh raises the worst
/// element there, inverts none, keeps the fillet's nodes on its cylinder
/// and moves nothing away from the fillet's elements. Faces that are not meshed are refused.
#[test]
fn relaxing_fillet_faces_improves_them() {
let (_d, mut s, faces) = filleted_box();
let perturbed = s.model.clone();
let refs: Vec<Value> = faces.iter().map(|f| json!({"body": "B", "face": f})).collect();
let r = s.execute("mesh.node.relax", &json!({"faces": refs, "iterations": 10})).unwrap();
let (before, after) = (r["before"]["min_scaled_jacobian"].as_f64().unwrap(), r["after"]["min_scaled_jacobian"].as_f64().unwrap());
assert!(after > before, "{r}");
assert_eq!(r["after"]["invalid"], 0, "{r}");
assert_eq!(invalid(&mut s), 0);
for (n, p) in s.model.nodes() {
let old = perturbed.node(*n).unwrap().xyz;
if old[0] > 18.0 && old[2] > 18.0 && (fillet_radius(old) - 2.0).abs() < 1e-6 {
assert!((fillet_radius(p.xyz) - 2.0).abs() < 1e-6, "node {n} left the fillet");
}
if old != p.xyz {
assert!(fillet_radius(old) < 6.0, "node {n} far from the fillet moved");
}
}
// Faces: required objects, known and meshed; not with a sphere.
for bad in [
json!({"faces": []}),
json!({"faces": "F1"}),
json!({"faces": [{"body": "B", "face": "Nope"}]}),
json!({"faces": [{"body": "B", "face": "Nope"}, {"body": "B", "face": "Nope2"}]}),
json!({"faces": [{"body": "Nope", "face": faces[0]}]}),
json!({"faces": [{"body": "B"}]}),
json!({"faces": refs, "center": [0, 0, 0], "radius": 1}),
] {
assert!(s.execute("mesh.node.relax", &bad).is_err(), "{bad}");
}
// A listed face with no mesh is named, the others relaxed.
let mut some = refs.clone();
some.push(json!({"body": "B", "face": "Nope"}));
let r = s.execute("mesh.node.relax", &json!({"faces": some})).unwrap();
assert_eq!(r["faces_without_mesh"], json!([{"body": "B", "face": "Nope"}]), "{r}");
// Elements by label are accepted too.
let e = *s.model.elements().keys().next().unwrap();
s.execute("mesh.node.relax", &json!({"elements": [e]})).unwrap();
assert!(s.execute("mesh.node.relax", &json!({"set": "All"})).is_err());
}
/// Whether a node of the 20 mm box lies on two of its planes (an edge or a vertex of the box,
/// or a tangent line of its fillet).
fn on_box_edge(p: [f64; 3]) -> bool {
p.iter().filter(|c| c.abs() < 1e-6 || (**c - 20.0).abs() < 1e-6).count() >= 2
}
/// Strong mode on the whole filleted box: the smallest scaled Jacobian does not drop, the mean rises,
/// nothing inverts, the fillet's nodes stay on its cylinder, the plane faces' nodes on their
/// planes, and the nodes on the box's edges and the fillet's tangent lines do not move. The
/// reply carries each round's region and quality.
#[test]
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();
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}");
assert!(a["mean_scaled_jacobian"].as_f64().unwrap() > b["mean_scaled_jacobian"].as_f64().unwrap(), "{r}");
assert_eq!(a["invalid"], 0, "{r}");
assert_eq!(invalid(&mut s), 0);
let rounds = r["rounds"].as_array().unwrap();
assert!(!rounds.is_empty() && rounds.iter().all(|k| k["region_nodes"].as_u64() >= k["bad_nodes"].as_u64() && k["min"].is_number()), "{r}");
for (n, p) in s.model.nodes() {
let (old, new) = (before.node(*n).unwrap().xyz, p.xyz);
if on_box_edge(old) {
assert_eq!(old, new, "node {n} on an edge moved");
}
if old[0] > 18.0 && old[2] > 18.0 && (fillet_radius(old) - 2.0).abs() < 1e-6 {
assert!((fillet_radius(new) - 2.0).abs() < 1e-6, "node {n} left the fillet");
}
for k in 0..3 {
for plane in [0.0, 20.0] {
if (old[k] - plane).abs() < 1e-9 {
assert!((new[k] - plane).abs() < 1e-6, "node {n} left the plane {k} = {plane}");
}
}
}
}
}
/// Strong mode on a distorted hex plate: over the guarded mode's sphere it lifts the smallest
/// scaled Jacobian past the threshold, close to the guarded mode (which also slides the edge nodes) (with nodes on edges held, where the guarded mode slides
/// them); by quality threshold (the elements below 0.5, morphed 4 mm round) it untangles
/// every element. Nothing inverts, nodes on the hole stay on its cylinder and the caps' on their
/// planes, nodes on the hole's edges do not move.
#[test]
fn strong_relaxation_untangles_a_distorted_plate() {
let mut s = plate();
s.execute("mesh.hex.generate", &json!({"size": 2.0})).unwrap();
perturb(&mut s, ON_HOLE, 6.0, 0.35);
let perturbed = s.model.clone();
let sphere = json!({"center": ON_HOLE, "radius": 7.0, "iterations": 20});
let guarded = s.execute("mesh.node.relax", &sphere).unwrap();
let min = |r: &Value, at: &str| r[at]["min_scaled_jacobian"].as_f64().unwrap();
for strong in [json!({"mode": "strong", "center": ON_HOLE, "radius": 7.0}), json!({"mode": "strong", "below": 0.5, "reach": 4.0})] {
s.model = perturbed.clone();
let r = s.execute("mesh.node.relax", &strong).unwrap();
assert!(min(&r, "after") > strong["below"].as_f64().unwrap_or(0.7) && min(&r, "before") < 0.0, "{strong}: {r}");
// Close to the guarded mode, which also slides the hole's edge nodes.
assert!(strong.get("below").is_some() || min(&r, "after") > min(&guarded, "after") - 0.05, "strong {r}\nguarded {guarded}");
assert_eq!(r["after"]["invalid"], 0, "{r}");
assert_eq!(invalid(&mut s), 0);
check_on_geometry(&perturbed, &s.model);
for (n, p) in s.model.nodes() {
let old = perturbed.node(*n).unwrap().xyz;
if on_faces(old).len() >= 2 {
assert_eq!(old, p.xyz, "node {n} on the hole's edge moved");
}
}
}
}
#[test]
fn strong_mode_parameters_are_checked() {
let mut s = plate();
s.execute("mesh.hex.generate", &json!({"size": 4.0})).unwrap();
for p in [
json!({"mode": "wild", "nodes": [1]}),
json!({"mode": "strong", "below": 0}),
json!({"mode": "strong", "below": 1.5}),
json!({"mode": "strong", "below": 0.5, "reach": 0}),
json!({"mode": "strong", "below": 0.5, "reach": -1}),
json!({"mode": "strong", "bodies": ["nope"]}),
json!({"mode": "strong", "nodes": [1], "iterations": 0}),
json!({"mode": "strong", "nodes": [1], "iterations": 201}),
json!({"below": 0.5}),
json!({"nodes": [1], "reach": 1}),
json!({"nodes": [1], "mode": "strong", "layers": 1}),
] {
assert!(s.execute("mesh.node.relax", &p).is_err(), "{p}");
}
// Nothing below the threshold: nothing moves.
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "below": 0.01})).unwrap();
assert_eq!(r["moved"], 0, "{r}");
}
+35 -1
View File
@@ -46,6 +46,17 @@ pub(super) fn clear<'a>(model: &mut Model, bodies: impl IntoIterator<Item = &'a
}
impl Cleared {
/// Add what `other` removed (a body in both counts once, as `other` reports it).
pub(super) fn extend(&mut self, other: Cleared) {
self.bodies.retain(|(b, _, _)| !other.bodies.iter().any(|(o, _, _)| o == b));
self.bodies.extend(other.bodies);
self.removed.elements.extend(other.removed.elements);
self.removed.nodes.extend(other.removed.nodes);
self.stale_sets.extend(other.stale_sets);
self.stale_sets.sort();
self.stale_sets.dedup();
}
/// [`Cleared::report`] when anything was removed; `reply` as it is otherwise.
pub(super) fn report_if_any(self, s: &Session, reply: Value) -> Value {
if self.bodies.is_empty() { reply } else { self.report(s, reply) }
@@ -128,8 +139,11 @@ pub static MESHERS: &[(&str, Targets)] = &[
("mesh.hex.mapped", body_or_all),
("mesh.hex.octree", body_or_all),
("mesh.hex.blocks", listed_or_all),
("mesh.hex.project", body_or_all),
("mesh.cells", listed_or_all),
("mesh.plan.apply", super::plan::plan_bodies),
("mesh.planar", super::mesh::planar_body),
("mesh.hex.extrude", super::extrude::extrude_body),
("mesh.mirror", super::mirror::image_names),
];
@@ -171,7 +185,8 @@ fn made_kind(command: &str, params: &Value) -> Option<ElementKind> {
"mesh.tet.generate" => {
Some(if params.get("order").and_then(Value::as_str) == Some("quadratic") { ElementKind::Tet10 } else { ElementKind::Tet4 })
}
"mesh.hex.generate" | "mesh.hex.sweep" | "mesh.hex.mapped" | "mesh.hex.blocks" => {
"mesh.hex.project" => Some(ElementKind::Hex8),
"mesh.hex.generate" | "mesh.hex.sweep" | "mesh.hex.mapped" | "mesh.hex.blocks" | "mesh.plan.apply" | "mesh.hex.extrude" => {
Some(if params.get("order").and_then(Value::as_i64) == Some(2) { ElementKind::Hex20 } else { ElementKind::Hex8 })
}
_ => None,
@@ -302,6 +317,16 @@ mod tests {
let tet_other = json!({"command": "mesh.tet.generate", "params": {"body": "Box.2", "size": 5}});
let hex_box = json!({"command": "mesh.hex.generate", "params": {"body": "Box.1", "size": 2.5}});
let square = json!([[0, 0], [10, 0], [10, 10], [0, 10]]);
// Box.1's bottom and top faces, for the extrusion between them.
let (_probe_dir, probe) = two_boxes();
let face = |nz: f64| {
let b = probe.design.as_deref().and_then(|d| d.body("Box.1")).unwrap();
let surfaces = b.face_surfaces().unwrap();
let i =
surfaces.iter().position(|f| matches!(f, Some(fieldcraft_geometry::FaceSurface::Plane { normal, .. }) if normal[2] == nz)).unwrap();
json!({"body": "Box.1", "face": b.faces()[i].name})
};
let extrude = json!({"source": [face(-1.0)], "to_surface": [face(1.0)], "direction": [0, 0, 1], "layers": 4, "size": 2.5});
let cases: Vec<(&str, Vec<Value>, Value, &str, &str)> = vec![
("mesh.tet.generate", vec![tet_other.clone()], json!({"body": "Box.1", "size": 4}), "Box.1", "Box.2"),
("mesh.hex.generate", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"),
@@ -309,7 +334,16 @@ mod tests {
("mesh.hex.mapped", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"),
("mesh.hex.octree", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"),
("mesh.hex.blocks", vec![tet_other.clone()], json!({"bodies": ["Box.1"], "size": 2.5}), "Box.1", "Box.2"),
("mesh.hex.project", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5, "merge": 0}), "Box.1", "Box.2"),
("mesh.cells", vec![tet_other.clone()], json!({"bodies": ["Box.1"], "size": 2.5, "default": "hex"}), "Box.1", "Box.2"),
(
"mesh.plan.apply",
vec![tet_other.clone(), json!({"command": "mesh.plan", "params": {"body": "Box.1", "size": 2.5}})],
json!({"plan": "Plan-Box_1"}),
"Box.1",
"Box.2",
),
("mesh.hex.extrude", vec![tet_other.clone()], extrude, "Box.1", "Box.2"),
("mesh.planar", vec![tet_other.clone()], json!({"polygon": square, "size": 2.5, "name": "Box.1"}), "Box.1", "Box.2"),
// The image's tets (meshed before) are replaced by the mirrored hexahedra.
(
+420 -7
View File
@@ -70,6 +70,16 @@ pub const REACTIONS: CommandSpec = CommandSpec {
run: reactions,
};
pub const EXPORT_VTU: CommandSpec = CommandSpec {
id: "results.export.vtu",
label: "Export VTU",
params: r#"{"step": "Load", "path": "out.vtu" | "out.pvd", "mode": 1, "frames": "all|last", "encoding": "ascii|binary"} — writes a solved step as VTK XML unstructured grids for ParaView: the mesh (points in node label order, cells in element label order, arrays `node_label` and `element_label`), the step's nodal fields as point data and per-element fields as cell data. Static: displacement, rotation (with shells or beams), reaction, stress (6 components in VTK order XX, YY, ZZ, XY, YZ, XZ), von_mises, temperature (coupled steps); nonlinear static: displacement per frame, the final fields on the last; frequency: mode_shape, with mode, frequency and eigenvalue as field data; explicit: displacement and velocity per frame, cell stress, plastic_strain, damage, eroded; heat: temperature per frame, heat_flux on the last. Point array `has_result` is 0 at nodes the step has no values for (written as 0, as are values that are not finite). A `.vtu` path writes one state (the last frame, or `mode`, default 1); a `.pvd` path writes every frame or mode (`frames: "last"` only the last) as `out_0000.vtu`, … beside it and the collection listing each with its time (load factor, mode number). `encoding` ascii (default) or binary (inline base64). Fluid steps are not supported yet. Replies {files, bytes, points, cells}"#,
form: Some(super::forms::EXPORT_VTU),
kind: CommandKind::Session,
requires: &[Requirement::Result],
run: export_vtu,
};
pub const EXPORT_CSV: CommandSpec = CommandSpec {
id: "results.export.csv",
label: "Export Results (CSV)",
@@ -102,7 +112,7 @@ fn solved_list(s: &Session) -> String {
/// The steps a command works on.
#[derive(Clone, Copy, PartialEq)]
enum Want {
pub(super) enum Want {
Any,
Frequency,
/// Steps with frames: explicit dynamics and nonlinear static (one per converged increment).
@@ -128,7 +138,7 @@ impl Want {
/// The step a command works on: `step`, else the step shown, else the first solved one
/// (current results first) of the kind it wants.
fn step_for(s: &Session, p: &Value, want: Want) -> Result<String> {
pub(super) fn step_for(s: &Session, p: &Value, want: Want) -> Result<String> {
if let Some(name) = opt::<String>(p, "step")? {
if s.latest_result(&name).is_none() {
return Err(EngineError::Params(format!("step `{name}` has no result; {}", solved_list(s))));
@@ -379,6 +389,251 @@ fn reactions(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"step": step, "stale": !current, "about": about, "sets": per_set, "total": total, "applied": r.applied_total}))
}
/// Most files one `results.export.vtu` writes.
pub const MAX_VTU_FILES: usize = 10_000;
/// Most bytes one `results.export.vtu` writes, over all its files.
pub const MAX_VTU_BYTES: u64 = 8 << 30;
/// One state of a result written as a `.vtu`: the end of a linear step, a mode (from 1) or a
/// frame (from 0).
#[derive(Clone, Copy, Debug, PartialEq)]
enum State {
Final,
Mode(usize),
Frame(usize),
}
fn export_vtu(s: &mut Session, p: &Value) -> Result<Value> {
use fieldcraft_io::vtk::{self, Encoding};
let step = step_for(s, p, Want::Any)?;
let mode: Option<usize> = opt(p, "mode")?;
let last_only = match opt::<String>(p, "frames")?.as_deref() {
None | Some("all") => false,
Some("last") => true,
Some(_) => return Err(EngineError::invalid("frames", "must be \"all\" or \"last\"")),
};
let encoding = match opt::<String>(p, "encoding")?.as_deref() {
None | Some("ascii") => Encoding::Ascii,
Some("binary") => Encoding::Base64,
Some(_) => return Err(EngineError::invalid("encoding", "must be \"ascii\" or \"binary\"")),
};
let path = super::file_param(s, p, "path", fieldcraft_io::roots::Access::Write)?;
let target = std::path::Path::new(&path);
let series = match target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).as_deref() {
Some("vtu") => false,
Some("pvd") => true,
_ => return Err(EngineError::Params("`path` must end in .vtu (one file) or .pvd (a series of .vtu files)".into())),
};
let r = match s.latest_result(&step) {
Some((_, false)) => return Err(EngineError::Params(format!("the model changed since step `{step}` was solved; solve it again"))),
Some((r, true)) => std::sync::Arc::clone(r),
None => return Err(EngineError::Params(format!("step `{step}` has no result; solve it first"))),
};
let frames = |n: usize, what: &str| -> Result<Vec<State>> {
let last = n.checked_sub(1).ok_or_else(|| EngineError::Params(format!("step `{step}` has no {what}")))?;
Ok(if series && !last_only { (0..n).map(State::Frame).collect() } else { vec![State::Frame(last)] })
};
if mode.is_some() && !matches!(r.kind(), Kind::Frequency(_)) {
return Err(EngineError::invalid("mode", "is for frequency and buckling steps"));
}
let states: Vec<State> = match r.kind() {
Kind::Fluid(_) => return Err(EngineError::Params("fluid results are per cell; not supported yet".into())),
Kind::Static(st) => match &st.nonlinear {
Some(h) if !h.frames.is_empty() => frames(h.frames.len(), "frames")?,
_ => vec![State::Final],
},
Kind::Frequency(f) => match mode {
Some(m) if m == 0 || m > f.modes.len() => {
return Err(EngineError::invalid("mode", format!("step `{step}` has modes 1 to {}", f.modes.len())));
}
Some(m) => vec![State::Mode(m)],
None if series && !last_only => (1..=f.modes.len()).map(State::Mode).collect(),
None if f.modes.is_empty() => return Err(EngineError::Params(format!("step `{step}` has no modes"))),
None => vec![State::Mode(1)],
},
Kind::Explicit(e) => frames(e.frames.len(), "field frames")?,
Kind::Heat(h) => frames(h.frames.len(), "temperatures")?,
};
if states.len() > MAX_VTU_FILES {
return Err(EngineError::Params(format!("step `{step}` has {} frames; at most {MAX_VTU_FILES} files are written", states.len())));
}
let model = &s.model;
let (nodes, elements) = vtk::points_and_cells(model);
let build = |state: State| -> Result<(f64, String)> {
let (time, fields, data) = vtu_fields(&r, state, &nodes, &elements);
let data: Vec<(&str, f64)> = data.iter().map(|(n, v)| (*n, *v)).collect();
Ok((time, vtk::write_vtu(model, &fields, &data, encoding)?))
};
// The last state has the most fields: its size times the count bounds the output before
// anything is written.
let Some(&last) = states.last() else { return Err(EngineError::Params(format!("step `{step}` has nothing to write"))) };
let sample = build(last)?;
let estimate = (sample.1.len() as u64).saturating_mul(states.len() as u64);
if estimate > MAX_VTU_BYTES {
return Err(EngineError::Params(format!(
"the export would write about {estimate} bytes; at most {MAX_VTU_BYTES} (8 GiB) are written: choose `frames: \"last\"` or a single .vtu"
)));
}
let mut written: Vec<std::path::PathBuf> = Vec::new();
let mut bytes = 0usize;
let result = (|| -> Result<Vec<String>> {
if !series {
fieldcraft_io::fs::write_atomic(target, sample.1.as_bytes(), false)?;
written.push(target.to_path_buf());
bytes += sample.1.len();
return Ok(vec![path.clone()]);
}
let stem = target.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let dir = target.parent().unwrap_or(std::path::Path::new(""));
let mut entries: Vec<(f64, String)> = Vec::with_capacity(states.len());
let mut files = Vec::with_capacity(states.len() + 1);
for (k, &state) in states.iter().enumerate() {
let name = format!("{stem}_{k:04}.vtu");
let file = dir.join(&name);
let built = if state == last { None } else { Some(build(state)?) };
let (time, text) = built.as_ref().map_or((sample.0, sample.1.as_str()), |(t, x)| (*t, x.as_str()));
fieldcraft_io::fs::write_atomic(&file, text.as_bytes(), false)?;
written.push(file.clone());
bytes += text.len();
entries.push((time, name));
files.push(file.to_string_lossy().into_owned());
}
let list: Vec<(f64, &str)> = entries.iter().map(|(t, n)| (*t, n.as_str())).collect();
let collection = vtk::pvd(&list);
fieldcraft_io::fs::write_atomic(target, collection.as_bytes(), false)?;
bytes += collection.len();
files.push(path.clone());
Ok(files)
})();
match result {
Ok(files) => Ok(json!({"files": files, "bytes": bytes, "points": nodes.len(), "cells": elements.len()})),
Err(e) => {
// Leave nothing half written behind.
for f in &written {
let _ = std::fs::remove_file(f);
}
Err(e)
}
}
}
/// A stress (xx, yy, zz, xy, xz, yz) in VTK's symmetric tensor order (XX, YY, ZZ, XY, YZ, XZ).
fn vtk_tensor(t: &[f64; 6]) -> [f64; 6] {
[t[0], t[1], t[2], t[3], t[5], t[4]]
}
/// Values given per label (`labels` sorted, as a result's nodes and elements are) spread over
/// `order` (the points' or cells' labels): `N` components each, 0 where a label has none.
fn spread<const N: usize>(order: &[u32], labels: &[u32], values: &[[f64; N]]) -> Vec<f64> {
let mut out = vec![0.0; order.len() * N];
for (label, v) in labels.iter().zip(values) {
if let Ok(i) = order.binary_search(label)
&& let Some(slot) = out.get_mut(i * N..(i + 1) * N)
{
slot.copy_from_slice(v);
}
}
out
}
fn scalars(values: &[f64]) -> Vec<[f64; 1]> {
values.iter().map(|v| [*v]).collect()
}
/// The fields of one state of a result, its time (load factor, mode number) and its field data.
fn vtu_fields(
r: &crate::StepResult,
state: State,
nodes: &[u32],
elements: &[u32],
) -> (f64, Vec<fieldcraft_io::vtk::Field>, Vec<(&'static str, f64)>) {
use fieldcraft_io::vtk::{Field, Location};
let mut fields = Vec::new();
let mut data = Vec::new();
let mut add = |name: &str, location: Location, components: usize, values: Vec<f64>, integer: bool| {
fields.push(Field { name: name.into(), location, components, values, integer });
};
let on = |labels: &[u32]| -> Vec<f64> { nodes.iter().map(|n| if labels.binary_search(n).is_ok() { 1.0 } else { 0.0 }).collect() };
add("has_result", Location::Point, 1, on(r.nodes()), true);
let point = Location::Point;
let cell = Location::Cell;
let time = match (r.kind(), state) {
(Kind::Static(st), _) => {
let frame = match (state, &st.nonlinear) {
(State::Frame(k), Some(h)) => h.frames.get(k).map(|f| (f.load_factor, f.displacement.as_slice(), k + 1 == h.frames.len())),
_ => None,
};
let (time, displacement, last) = frame.unwrap_or((0.0, st.displacement.as_slice(), true));
add("displacement", point, 3, spread(nodes, &st.nodes, displacement), false);
if last {
if !st.shells.is_empty() || !st.beams.is_empty() {
add("rotation", point, 3, spread(nodes, &st.nodes, &st.rotation), false);
}
add("reaction", point, 3, spread(nodes, &st.nodes, &st.reaction), false);
if !st.stress.is_empty() {
let stress: Vec<[f64; 6]> = st.stress.iter().map(vtk_tensor).collect();
add("stress", point, 6, spread(nodes, &st.nodes, &stress), false);
add("von_mises", point, 1, spread(nodes, &st.nodes, &scalars(&st.von_mises)), false);
}
if let Some(t) = &st.temperature {
add("temperature", point, 1, spread(nodes, &st.nodes, &scalars(t)), false);
}
}
if st.nonlinear.is_some() {
data.push(("load_factor", time));
}
time
}
(Kind::Frequency(f), State::Mode(m)) => {
if let Some(mode) = m.checked_sub(1).and_then(|k| f.modes.get(k)) {
add("mode_shape", point, 3, spread(nodes, &f.nodes, &mode.shape), false);
data.extend([("mode", m as f64), ("frequency", mode.frequency), ("eigenvalue", mode.eigenvalue)]);
}
m as f64
}
(Kind::Explicit(e), State::Frame(k)) => {
let Some(f) = e.frames.get(k) else { return (0.0, fields, data) };
if !f.displacement.is_empty() {
add("displacement", point, 3, spread(nodes, &e.nodes, &f.displacement), false);
}
if !f.velocity.is_empty() {
add("velocity", point, 3, spread(nodes, &e.nodes, &f.velocity), false);
}
if !f.stress.is_empty() {
let stress: Vec<[f64; 6]> = f.stress.iter().map(vtk_tensor).collect();
add("stress", cell, 6, spread(elements, &e.elements, &stress), false);
}
if !f.plastic_strain.is_empty() {
add("plastic_strain", cell, 1, spread(elements, &e.elements, &scalars(&f.plastic_strain)), false);
}
if !f.damage.is_empty() {
add("damage", cell, 1, spread(elements, &e.elements, &scalars(&f.damage)), false);
}
if !f.failed.is_empty() {
let eroded: Vec<[f64; 1]> = f.failed.iter().map(|&b| [if b { 1.0 } else { 0.0 }]).collect();
add("eroded", cell, 1, spread(elements, &e.elements, &eroded), true);
}
data.push(("time", f.time));
f.time
}
(Kind::Heat(h), State::Frame(k)) => {
let time = h.times.get(k).copied().unwrap_or(0.0);
if let Some(t) = h.frames.get(k) {
add("temperature", point, 1, spread(nodes, &h.nodes, &scalars(t)), false);
}
if k + 1 == h.frames.len() && !h.flux.is_empty() {
add("heat_flux", point, 3, spread(nodes, &h.nodes, &h.flux), false);
}
data.push(("time", time));
time
}
_ => 0.0,
};
(time, fields, data)
}
fn export_csv(s: &mut Session, p: &Value) -> Result<Value> {
let what: String = super::req(p, "what")?;
let path = super::file_param(s, p, "path", fieldcraft_io::roots::Access::Write)?;
@@ -405,13 +660,13 @@ fn export_csv(s: &mut Session, p: &Value) -> Result<Value> {
}
/// A table of cells (a number, text, or empty) under a header, written as CSV.
struct Table {
header: Vec<String>,
rows: Vec<Vec<Cell>>,
pub(super) struct Table {
pub(super) header: Vec<String>,
pub(super) rows: Vec<Vec<Cell>>,
}
#[derive(Clone, Debug, PartialEq)]
enum Cell {
pub(super) enum Cell {
Number(f64),
Text(String),
Empty,
@@ -420,7 +675,7 @@ enum Cell {
impl Table {
/// RFC 4180: comma-separated, CRLF line ends, a field with a comma, quote or line break in
/// quotes (its quotes doubled); numbers in Rust's shortest round-trip form.
fn csv(&self) -> String {
pub(super) fn csv(&self) -> String {
let field = |s: &str| if s.contains([',', '"', '\r', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
let mut out = String::new();
let line = |cells: Vec<String>, out: &mut String| {
@@ -549,6 +804,164 @@ mod tests {
use super::*;
use crate::cmd::solve::tests::cantilever;
/// The numbers of the first `DataArray` called `name` in a written `.vtu`.
fn vtu_array(xml: &str, name: &str) -> Vec<f64> {
let at = xml.find(&format!("Name=\"{name}\"")).unwrap_or_else(|| panic!("no array {name}"));
let start = at + xml[at..].find('>').unwrap() + 1;
let end = start + xml[start..].find("</DataArray>").unwrap();
xml[start..end].split_whitespace().map(|t| t.parse().unwrap()).collect()
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("fieldcraft-vtu-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn export_vtu_writes_a_static_step_and_its_modes() {
let dir = scratch("static");
let out = dir.join("out.vtu").to_string_lossy().into_owned();
let mut s = Session::default();
s.execute("file.sample", &json!({"name": "cantilever"})).unwrap();
let e = s.execute("results.export.vtu", &json!({"step": "Load", "path": out})).unwrap_err().to_string();
assert!(e.contains("Needs a solved step"), "{e}");
s.execute("solve.static", &json!({"step": "Load"})).unwrap();
let reply = s.execute("results.export.vtu", &json!({"step": "Load", "path": out})).unwrap();
let xml = std::fs::read_to_string(&out).unwrap();
assert_eq!(reply["points"], s.model.nodes().len());
assert_eq!(reply["cells"], s.model.elements().len());
assert_eq!(reply["bytes"], xml.len());
assert!(xml.contains(&format!("NumberOfPoints=\"{}\"", s.model.nodes().len())));
// The tip node's displacement is the probe's, at the node's point.
let labels = vtu_array(&xml, "node_label");
let u = vtu_array(&xml, "displacement");
let r = s.result("Load").unwrap().clone();
let Kind::Static(st) = r.kind() else { panic!() };
let (k, tip) = st.displacement.iter().enumerate().max_by(|a, b| a.1[2].abs().total_cmp(&b.1[2].abs())).unwrap();
let tip_label = st.nodes[k];
let probe = s.execute("solve.probe", &json!({"step": "Load", "nodes": [tip_label]})).unwrap();
let i = labels.iter().position(|&l| l == f64::from(tip_label)).unwrap();
assert_eq!(&u[3 * i..3 * i + 3], tip.as_slice());
assert_eq!(
probe["nodes"][0]["displacement"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()),
Some(tip.to_vec()),
"{probe}"
);
// Stress components in VTK order: XY at 3, YZ at 4, XZ at 5.
let stress = vtu_array(&xml, "stress");
let t = st.stress[k];
assert_eq!(&stress[6 * i..6 * i + 6], [t[0], t[1], t[2], t[3], t[5], t[4]].as_slice());
assert_eq!(vtu_array(&xml, "has_result").iter().filter(|&&h| h == 1.0).count(), st.nodes.len());
// Deterministic.
s.execute("results.export.vtu", &json!({"step": "Load", "path": out})).unwrap();
assert_eq!(std::fs::read_to_string(&out).unwrap(), xml);
// Modes: one file per mode with its frequency, and a collection.
s.execute("solve.frequency", &json!({"step": "Modes"})).unwrap();
let pvd = dir.join("modes.pvd").to_string_lossy().into_owned();
let reply = s.execute("results.export.vtu", &json!({"step": "Modes", "path": pvd})).unwrap();
let files = reply["files"].as_array().unwrap();
assert_eq!(files.len(), 7, "six modes and the collection: {reply}");
let mode2 = std::fs::read_to_string(dir.join("modes_0001.vtu")).unwrap();
assert_eq!(vtu_array(&mode2, "mode"), [2.0]);
let collection = std::fs::read_to_string(&pvd).unwrap();
assert!(collection.contains("timestep=\"2.0\" part=\"0\" file=\"modes_0001.vtu\""), "{collection}");
let one = dir.join("mode3.vtu").to_string_lossy().into_owned();
s.execute("results.export.vtu", &json!({"step": "Modes", "path": one, "mode": 3, "encoding": "binary"})).unwrap();
assert!(std::fs::read_to_string(&one).unwrap().contains("format=\"binary\""));
// Refusals write nothing.
let none = dir.join("none.vtu").to_string_lossy().into_owned();
for (p, why) in [
(json!({"step": "Modes", "path": none, "mode": 0}), "modes 1 to 6"),
(json!({"step": "Modes", "path": none, "mode": 1_000_000_000}), "modes 1 to 6"),
(json!({"step": "Load", "path": none, "mode": 1}), "frequency"),
(json!({"step": "Load", "path": dir.join("x.vtk").to_string_lossy()}), ".vtu"),
(json!({"step": "Load", "path": none, "frames": 3}), "frames"),
(json!({"step": "Load", "path": none, "encoding": "hex"}), "encoding"),
(json!({"step": "Nope", "path": none}), "has no result"),
] {
let e = s.execute("results.export.vtu", &p).unwrap_err().to_string();
assert!(e.contains(why), "{p}: {e}");
}
s.execute("mesh.node.add", &json!({"label": 9999, "xyz": [50, 50, 50]})).unwrap();
let e = s.execute("results.export.vtu", &json!({"step": "Load", "path": none})).unwrap_err().to_string();
assert!(e.contains("the model changed since step `Load` was solved"), "{e}");
assert!(!dir.join("none.vtu").exists() && !dir.join("x.vtk").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn export_vtu_writes_nonlinear_frames_and_heat() {
let dir = scratch("nl-heat");
let mut s = Session::default();
cantilever(&mut s);
s.run_script(&json!([
{"command": "model.section.edit", "params": {"name": "bar", "set": "all", "material": "Mat", "kind": {"type": "solid"}, "formulation": {}}},
{"command": "model.step.create", "params": {"name": "Large", "procedure": {"type": "nonlinear_static", "initial_increment": 0.25},
"boundary_conditions": ["clamp"], "loads": ["push"]}},
{"command": "model.material.edit", "params": {"name": "Mat", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0}, "density": 1, "thermal": {"conductivity": 2}}},
{"command": "model.boundary_condition.create", "params": {"name": "Heater", "type": "temperature", "target": {"set": "root"}, "value": 100}},
{"command": "model.set.create", "params": {"name": "skin", "faces": (1..=6).map(|f| json!({"element": 10, "face": f})).collect::<Vec<_>>()}},
{"command": "model.load.create", "params": {"name": "Air", "type": "convection", "set": "skin", "film": 0.5, "sink": 20}},
{"command": "model.step.create", "params": {"name": "Therm", "procedure": {"type": "heat_transfer"}, "boundary_conditions": ["Heater"], "loads": ["Air"]}},
]))
.unwrap();
s.execute("solve.nonlinear", &json!({"step": "Large"})).unwrap();
s.execute("solve.heat", &json!({"step": "Therm"})).unwrap();
let pvd = dir.join("large.pvd").to_string_lossy().into_owned();
let reply = s.execute("results.export.vtu", &json!({"step": "Large", "path": pvd})).unwrap();
let Kind::Static(st) = s.result("Large").unwrap().kind() else { panic!() };
let h = st.nonlinear.as_ref().unwrap();
assert_eq!(reply["files"].as_array().unwrap().len(), h.frames.len() + 1);
let first = std::fs::read_to_string(dir.join("large_0000.vtu")).unwrap();
let last = std::fs::read_to_string(dir.join(format!("large_{:04}.vtu", h.frames.len() - 1))).unwrap();
assert!(first.contains("Name=\"displacement\"") && !first.contains("Name=\"stress\""), "final stresses on the last frame only");
assert!(last.contains("Name=\"stress\"") && last.contains("Name=\"von_mises\""));
assert_eq!(vtu_array(&last, "load_factor"), [h.frames.last().unwrap().load_factor]);
let heat = dir.join("heat.vtu").to_string_lossy().into_owned();
s.execute("results.export.vtu", &json!({"step": "Therm", "path": heat})).unwrap();
let xml = std::fs::read_to_string(&heat).unwrap();
let t = vtu_array(&xml, "temperature");
assert!(t.contains(&100.0), "the heater's temperature");
assert_eq!(vtu_array(&xml, "heat_flux").len(), 3 * s.model.nodes().len());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn export_vtu_writes_explicit_frames_as_a_series() {
let dir = scratch("impact");
let mut s = Session::default();
s.execute("file.sample", &json!({"name": "impact"})).unwrap();
s.execute("solve.explicit", &json!({})).unwrap();
let pvd = dir.join("impact.pvd").to_string_lossy().into_owned();
let reply = s.execute("results.export.vtu", &json!({"path": pvd})).unwrap();
let Kind::Explicit(e) = s.result("Impact").unwrap().kind() else { panic!() };
let frames = e.frames.len();
assert!(frames > 2);
assert_eq!(reply["files"].as_array().unwrap().len(), frames + 1);
let collection = std::fs::read_to_string(&pvd).unwrap();
let times: Vec<f64> =
collection.match_indices("timestep=\"").map(|(i, m)| collection[i + m.len()..].split('"').next().unwrap().parse().unwrap()).collect();
assert_eq!(times.len(), frames);
assert!(times.windows(2).all(|w| w[0] < w[1]), "{times:?}");
let last = std::fs::read_to_string(dir.join(format!("impact_{:04}.vtu", frames - 1))).unwrap();
let cell = &last[last.find("<CellData>").unwrap()..last.find("</CellData>").unwrap()];
assert!(cell.contains("Name=\"stress\" NumberOfComponents=\"6\""), "{cell}");
// A .vtu takes the last frame; `frames: "last"` makes a one-entry series.
let one = dir.join("last.vtu").to_string_lossy().into_owned();
s.execute("results.export.vtu", &json!({"path": one})).unwrap();
assert_eq!(std::fs::read_to_string(&one).unwrap(), last);
let lastp = dir.join("last.pvd").to_string_lossy().into_owned();
assert_eq!(s.execute("results.export.vtu", &json!({"path": lastp, "frames": "last"})).unwrap()["files"].as_array().unwrap().len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
fn solved() -> Session {
let mut s = Session::default();
cantilever(&mut s);
+186
View File
@@ -0,0 +1,186 @@
//! Stitching imported surfaces (`geometry.stitch`): their gaps under a tolerance closed for
//! meshing ([`fieldcraft_geometry::stitch`]).
//!
//! A stitch is part of the model (`fieldcraft_model::virtual_faces::Stitch`), naming the surface
//! by name. The surfaces keep what was read ([`TaggedSurface::original`]); [`restitch`] stitches
//! each again from it by the model's stitches, in order, whenever a surface is imported or the
//! model is opened, and after every stitch or delete, so the meshers always take the stitched
//! surface. Undo restores the surfaces with the model.
use std::sync::Arc;
use fieldcraft_geometry::tagged_mesh::TaggedSurface;
use fieldcraft_model::Stitch;
use serde_json::{Value, json};
use crate::requirement::Requirement;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const STITCH: CommandSpec = CommandSpec {
id: "geometry.stitch",
label: "Stitch Surface",
params: r#"{"name": "Gaps", "surface": "Mesh1" (an imported surface, by name), "tolerance": 0.05 (mm)} — closes the gaps of an imported surface under the tolerance, for meshing: points on open edges closer than it become one point (triangles that collapse go), and a point on an open edge within it of the inside of another open edge splits that edge's triangle (a T-junction). Edges that are not open are never touched, so a closed surface is unchanged; triangles keep their face tags. Kept in the model and applied again, in order, whenever the surface is imported or the model opened (the file is not changed). Replies the open edges before and after (`open_before`, `open_after`: 0 means closed), the points `merged`, the triangles `split` and `removed`; the surface loses its mesh (`replaced`). A surface the session lacks is an error. One undo step"#,
form: Some(super::forms::STITCH),
kind: CommandKind::Model,
requires: &[Requirement::Geometry],
run: stitch,
};
pub const STITCH_DELETE: CommandSpec = CommandSpec {
id: "geometry.stitch.delete",
label: "Delete Stitch",
params: r#"{"name": "Gaps"} — removes a stitch: the surface is stitched again by the ones left (as read when none); it loses its mesh (`replaced`)"#,
form: Some(super::forms::STITCH_DELETE),
kind: CommandKind::Model,
requires: &[Requirement::Stitch],
run: delete,
};
/// The name a surface goes by (and its mesh is tagged with): its own, or `Surface<index>`.
pub(crate) fn surface_name(index: usize, t: &TaggedSurface) -> String {
if t.name.trim().is_empty() { format!("Surface{index}") } else { t.name.clone() }
}
/// Stitch the session's surfaces again from what was read, by the model's stitches. Replies each
/// stitch's report by name, and the stitches whose surface is not there or that failed (with why).
pub(crate) fn restitch(s: &mut Session) -> (Vec<(String, Value)>, Vec<(String, String)>) {
let mut reports = Vec::new();
let mut failed = Vec::new();
let stitches = s.model.stitches().to_vec();
for st in stitches.iter().filter(|st| !s.surfaces.iter().enumerate().any(|(i, t)| surface_name(i, t) == st.surface)) {
failed.push((st.name.clone(), format!("no surface `{}`", st.surface)));
}
for (i, slot) in s.surfaces.iter_mut().enumerate() {
let name = surface_name(i, slot);
let mine: Vec<&Stitch> = stitches.iter().filter(|st| st.surface == name).collect();
if mine.is_empty() && slot.original.is_none() {
continue;
}
let raw = slot.original.clone().unwrap_or_else(|| Arc::new(slot.mesh.clone()));
let mut mesh = raw.as_ref().clone();
for st in mine {
match fieldcraft_geometry::stitch::stitch(&mesh, st.tolerance) {
Ok((m, r)) => {
mesh = m;
let report =
json!({"open_before": r.open_before, "open_after": r.open_after, "merged": r.merged, "split": r.split, "removed": r.removed});
reports.push((st.name.clone(), report));
}
Err(e) => failed.push((st.name.clone(), e.to_string())),
}
}
let original = (!s.model.stitches().iter().all(|st| st.surface != name)).then_some(raw);
*slot = Arc::new(TaggedSurface { name: slot.name.clone(), mesh, faces: slot.faces, original });
}
(reports, failed)
}
fn stitch(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?.to_string();
let surface: String = super::req(p, "surface")?;
let tolerance: f64 = super::req(p, "tolerance")?;
s.model.add_stitch(Stitch { name: name.clone(), surface: surface.clone(), tolerance })?;
let (reports, failed) = restitch(s);
if let Some((_, why)) = failed.iter().find(|(n, _)| *n == name) {
return Err(EngineError::Params(format!("stitch `{name}`: {why}")));
}
let mut reply = reports.into_iter().find(|(n, _)| *n == name).map(|(_, r)| r).unwrap_or_else(|| json!({}));
if let Some(o) = reply.as_object_mut() {
o.insert("name".into(), json!(name));
o.insert("surface".into(), json!(surface));
}
let cleared = super::replace::clear(&mut s.model, [surface.as_str()]);
Ok(cleared.report_if_any(s, reply))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?;
let removed = s.model.remove_stitch(name)?;
restitch(s);
let cleared = super::replace::clear(&mut s.model, [removed.surface.as_str()]);
Ok(cleared.report_if_any(s, json!({"deleted": name, "surface": removed.surface})))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
/// A unit cube in a 3MF whose top face is raised 0.01 mm: open along the top's rim.
fn gapped_cube(dir: &std::path::Path) -> String {
let m = fieldcraft_geometry::stitch::test_cube(0.01, false);
let vertices: String = m.positions.iter().map(|p| format!(r#"<vertex x="{}" y="{}" z="{}"/>"#, p[0], p[1], p[2])).collect();
let triangles: String = m
.triangles
.iter()
.zip(&m.tri_face)
.map(|(t, f)| format!(r#"<triangle v1="{}" v2="{}" v3="{}" ft:face="{f}"/>"#, t[0], t[1], t[2]))
.collect();
let model = format!(
r#"<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:ft="{}"><resources><object id="1" name="Cube" ft:faces="6"><mesh><vertices>{vertices}</vertices><triangles>{triangles}</triangles></mesh></object></resources><build><item objectid="1"/></build></model>"#,
fieldcraft_geometry::tagged_mesh::FACE_TAGS_NS
);
let path = dir.join("cube.3mf");
std::fs::write(&path, super::super::geometry::tests::package(&model)).unwrap();
path.to_str().unwrap().to_string()
}
/// The gapped cube does not mesh; stitched under a tolerance over the gap it does; the stitch
/// is saved and applied again when the model is opened; undo and delete bring the gap back.
#[test]
fn a_stitched_surface_meshes_and_the_stitch_is_replayed() {
let dir = std::env::temp_dir().join(format!("fieldcraft-stitch-{}-{:?}", std::process::id(), std::thread::current().id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::default();
s.execute("geometry.import.mesh", &json!({"path": gapped_cube(&dir)})).unwrap();
let tet = json!({"surface": 0, "size": 0.25});
assert!(s.execute("mesh.tet.generate", &tet).is_err(), "open: no volume to fill");
assert!(
s.execute("geometry.stitch", &json!({"name": "Small", "surface": "Cube", "tolerance": 0.001})).unwrap()["open_after"].as_u64() > Some(0)
);
let r = s.execute("geometry.stitch", &json!({"name": "Gaps", "surface": "Cube", "tolerance": 0.05})).unwrap();
assert_eq!((r["open_after"].as_u64(), r["merged"].as_u64()), (Some(0), Some(4)), "{r}");
s.execute("mesh.tet.generate", &tet).unwrap();
assert!(!s.model.elements().is_empty());
let file = dir.join("stitched.fieldcraft");
s.execute("file.save", &json!({"path": file.to_str().unwrap()})).unwrap();
let mut t = Session::default();
t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
assert_eq!(t.surfaces[0].mesh, s.surfaces[0].mesh);
assert!(t.surfaces[0].original.is_some());
// Undo the mesh and the stitch: the gap is back.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.execute("mesh.tet.generate", &tet).is_err());
s.execute("edit.redo", &json!({})).unwrap();
s.execute("mesh.tet.generate", &tet).unwrap();
let r = s.execute("geometry.stitch.delete", &json!({"name": "Gaps"})).unwrap();
assert!(r["replaced"].is_array(), "{r}");
assert!(s.execute("mesh.tet.generate", &tet).is_err());
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn bad_stitches_change_nothing() {
let dir = std::env::temp_dir().join(format!("fieldcraft-stitch-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::default();
s.execute("geometry.import.mesh", &json!({"path": gapped_cube(&dir)})).unwrap();
let journal = s.journal.len();
for p in [
json!({}),
json!({"name": "A", "surface": "Cube"}),
json!({"name": "A", "surface": "Nope", "tolerance": 0.1}),
json!({"name": "A", "surface": "Cube", "tolerance": 0}),
json!({"name": "A", "surface": "Cube", "tolerance": "x"}),
json!({"name": "1A", "surface": "Cube", "tolerance": 0.1}),
] {
assert!(s.execute("geometry.stitch", &p).is_err(), "{p}");
}
assert!(s.execute("geometry.stitch.delete", &json!({"name": "A"})).is_err());
assert_eq!(s.journal.len(), journal);
assert!(s.model.stitches().is_empty() && s.surfaces[0].original.is_none());
std::fs::remove_dir_all(dir).ok();
}
}
+119 -14
View File
@@ -2,7 +2,8 @@
//! symmetric part can be solved as a half.
//!
//! The condition holds the displacement normal to the plane, and the rotations about the two
//! axes in it, at zero (`type: "symmetry"`). Its set is given, or made of the faces of the open
//! axes in it, at zero (`type: "symmetry"`): on a plane normal to X, Y or Z those are global
//! degrees of freedom; on any other plane they are linear constraints along its normal. Its set is given, or made of the faces of the open
//! design's bodies that lie on the plane (the cut faces of a partition by that datum, `partition:
//! <name>:a|b`), so it follows the geometry through a remesh.
@@ -16,7 +17,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const SYMMETRY: CommandSpec = CommandSpec {
id: "model.boundary_condition.symmetry",
label: "Symmetry Plane",
params: r#"{"name": "Sym", "datum": "Mid" (a datum plane normal to X, Y or Z), "set"?: "cut" (default: a new set of the same name holding every face of the open design's bodies on the plane, e.g. a partition's cut faces), "bodies"?: ["Part/a"] (only their faces)} — a symmetry boundary condition on the plane: zero displacement normal to it and zero rotation about the axes in it, so half a symmetric part (loaded symmetrically, with half the load) is solved as a half. Add it to the steps (model.step.activate). Planes not normal to X, Y or Z are not supported yet"#,
params: r#"{"name": "Sym", "datum": "Mid" (a datum plane, any orientation), "set"?: "cut" (default: a new set of the same name holding every face of the open design's bodies on the plane, e.g. a partition's cut faces), "bodies"?: ["Part/a"] (only their faces)} — a symmetry boundary condition on the plane: zero displacement normal to it and zero rotation about the axes in it, so half a symmetric part (loaded symmetrically, with half the load) is solved as a half. Add it to the steps (model.step.activate). Reports the plane as "x", "y", "z" or its unit normal"#,
form: Some(super::forms::SYMMETRY),
kind: CommandKind::Model,
requires: &[Requirement::DatumPlane],
@@ -30,10 +31,10 @@ fn symmetry(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?.to_string();
let datum: String = super::req(p, "datum")?;
let (point, normal) = super::datum::datum_plane_of(&s.model, &datum)?;
let axis = (0..3).find(|&k| (normal[k].abs() - 1.0).abs() < 1e-9).ok_or_else(|| {
EngineError::Params(format!("not supported yet: symmetry about a plane not normal to X, Y or Z (datum `{datum}` has normal {normal:?})"))
})?;
let plane = [Symmetry::X, Symmetry::Y, Symmetry::Z][axis];
let unusable = || EngineError::Params(format!("datum `{datum}` has no usable normal ({normal:?})"));
let plane = Symmetry::from_normal(normal).ok_or_else(unusable)?;
let n = plane.unit_normal().ok_or_else(unusable)?;
let along = |x: [f64; 3]| (0..3).map(|k| x[k] * n[k]).sum::<f64>();
let set = match p.get("set") {
Some(_) => super::req::<String>(p, "set")?,
None => {
@@ -48,8 +49,9 @@ fn symmetry(s: &mut Session, p: &Value) -> Result<Value> {
let tol = ON_PLANE * b.size()?.max(1.0);
for (f, surface) in b.faces().iter().zip(b.face_surfaces()?) {
if let Some(FaceSurface::Plane { normal: m, point: q }) = surface {
let parallel = (m[axis].abs() - 1.0).abs() < 1e-9;
if parallel && (q[axis] - point[axis]).abs() < tol {
let length = m.iter().map(|c| c * c).sum::<f64>().sqrt();
let parallel = (along(m).abs() - length).abs() < 1e-9 * length;
if parallel && (along(q) - along(point)).abs() < tol {
faces.push(GeometryRef::face(b.name.as_str(), f.name.as_str())?);
}
}
@@ -64,8 +66,11 @@ fn symmetry(s: &mut Session, p: &Value) -> Result<Value> {
};
s.model.add_boundary_condition(&name, BoundaryCondition::Symmetry { target: Target::Set(set.clone()), plane })?;
let faces = s.model.set(&set).map_or(0, |x| x.geometry.len());
let axis_name = ["x", "y", "z"].get(axis).copied().unwrap_or("x");
Ok(json!({"name": name, "set": set, "plane": axis_name, "faces": faces}))
let reported = match plane.axis() {
Some(k) => json!(["x", "y", "z"].get(k).copied().unwrap_or("x")),
None => json!(n),
};
Ok(json!({"name": name, "set": set, "plane": reported, "faces": faces}))
}
#[cfg(test)]
@@ -137,17 +142,117 @@ mod tests {
assert!((2.0 * halved.1 - full.1).abs() < 1e-9 * full.1.abs(), "reaction {} vs {}", halved.1, full.1);
}
/// A 20 × 20 × 2 plate (0..20 in x and y), cut on its diagonal plane x = y (datum `Diag`,
/// normal (1, −1, 0)) into `Plate/a` and `Plate/b`, the half below the diagonal (y < x) meshed
/// with quadratic tets.
fn plate() -> (crate::tests::TempDir, Session, String) {
let commands = json!({"commands": [
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 2, "body_name": "Plate"}},
]});
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
let (dir, path) = crate::tests::design_file(&bytes);
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path})).unwrap();
s.execute("geometry.datum", &json!({"name": "Diag", "by": {"type": "point_normal", "point": [0, 0, 0], "normal": [1, -1, 0]}})).unwrap();
s.execute("geometry.partition", &json!({"name": "Cut", "datum": "Diag"})).unwrap();
let design = s.design.as_ref().unwrap();
let below = design
.bodies
.iter()
.find(|b| {
let c = b.measure().unwrap().centroid;
c[0] > c[1]
})
.unwrap()
.name
.clone();
s.execute("mesh.tet.generate", &json!({"body": below, "size": 2, "order": "quadratic"})).unwrap();
(dir, s, below)
}
/// Clamp the faces at y = 0 (and x = 0 when `whole`), press the top faces, solve; the
/// displacement of every node of the half below the diagonal, by label.
fn press(s: &mut Session, below: &str, whole: bool, extra: &[&str]) -> std::collections::BTreeMap<u64, [f64; 3]> {
let bodies: Vec<String> = s.design.as_ref().unwrap().bodies.iter().map(|b| b.name.clone()).collect();
let mut clamp = vec![json!({"body": below, "face": face(s, below, [10.0, 0.0, 1.0])})];
let mut top = vec![json!({"body": below, "face": face(s, below, [15.0, 5.0, 2.0])})];
if whole {
let above = bodies.iter().find(|b| b.as_str() != below).unwrap();
clamp.push(json!({"body": above, "face": face(s, above, [0.0, 10.0, 1.0])}));
top.push(json!({"body": above, "face": face(s, above, [5.0, 15.0, 2.0])}));
}
let all: Vec<Value> = bodies.iter().filter(|b| whole || b.as_str() == below).map(|b| json!({"body": b})).collect();
let mut bcs = vec!["Clamp".to_string()];
bcs.extend(extra.iter().map(|x| x.to_string()));
let script = json!([
{"command": "model.set.create", "params": {"name": "all", "geometry": all}},
{"command": "model.set.create", "params": {"name": "clamped", "geometry": clamp}},
{"command": "model.set.create", "params": {"name": "top", "geometry": top}},
{"command": "model.set.create", "params": {"name": "half", "geometry": [{"body": below}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "Plate", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "Clamp", "type": "fixed", "target": {"set": "clamped"}}},
{"command": "model.load.create", "params": {"name": "Press", "type": "pressure", "set": "top", "magnitude": 0.1}},
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": bcs, "loads": ["Press"]}},
]);
s.run_script(&script).unwrap();
s.execute("solve.static", &json!({})).unwrap();
let nodes = s.model.resolve_set("half").unwrap().nodes;
let probe = s.execute("solve.probe", &json!({"step": "Load", "nodes": nodes})).unwrap();
probe["nodes"]
.as_array()
.unwrap()
.iter()
.map(|n| (n["node"].as_u64().unwrap(), [0, 1, 2].map(|k| n["displacement"][k].as_f64().unwrap())))
.collect()
}
/// The half of the plate below its diagonal, with the inclined symmetry plane (found on the
/// cut face) and its half of the pressure, deflects as the whole plate, meshed as that half
/// mirrored across the diagonal (conforming there, so the whole is the same mesh twice).
#[test]
fn inclined_planes_and_hostile_params_change_nothing() {
fn a_half_cut_on_an_inclined_plane_matches_the_whole() {
let (_d1, mut whole, below) = plate();
let r = whole.execute("mesh.mirror", &json!({"plane": "Diag", "bodies": [below]})).unwrap();
let on_plane = r["on_plane"].as_u64().unwrap();
let q = whole.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["valid"].as_u64().unwrap() as usize, whole.model.elements().len(), "{q}");
assert!((q["volume"].as_f64().unwrap() - 800.0).abs() < 1e-9 * 800.0, "{q}");
let full = press(&mut whole, &below, true, &[]);
let (_d2, mut half, below) = plate();
let nodes = half.model.nodes().len() as u64;
assert_eq!(whole.model.nodes().len() as u64, 2 * nodes - on_plane, "the mirror shares the plane's nodes");
let r = half.execute("model.boundary_condition.symmetry", &json!({"name": "Sym", "datum": "Diag", "bodies": [below]})).unwrap();
let h = 1.0 / 2f64.sqrt();
assert_eq!(r["faces"], json!(1), "{r}");
let n: Vec<f64> = r["plane"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!((n[0] - h).abs() < 1e-15 && (n[1] + h).abs() < 1e-15 && n[2] == 0.0, "{r}");
assert_eq!(half.model.resolve_set("Sym").unwrap().nodes.len() as u64, on_plane);
let halved = press(&mut half, &below, false, &["Sym"]);
assert_eq!(full.len(), halved.len());
let size = full.values().fold(0.0f64, |m, u| m.max(u.iter().map(|c| c * c).sum::<f64>().sqrt()));
let worst = halved.iter().map(|(l, u)| (0..3).map(|k| (u[k] - full[l][k]).powi(2)).sum::<f64>().sqrt()).fold(0.0f64, f64::max);
eprintln!("half vs whole plate on the inclined plane: largest deflection {size:.6e}, difference {:.1e}", worst / size);
assert!(size > 1e-4 && worst < 1e-9 * size, "{worst} of {size}");
// On the plane the nodes move along it: n · u = 0.
for l in half.model.resolve_set("Sym").unwrap().nodes {
let u = halved[&u64::from(l)];
assert!((u[0] - u[1]).abs() * h < 1e-12 * size, "node {l}: {u:?}");
}
}
#[test]
fn hostile_params_change_nothing() {
let (_d, mut s) = bar(true);
s.execute("geometry.datum", &json!({"name": "Tilt", "by": {"type": "point_normal", "point": [0, 0, 0], "normal": [1, 1, 0]}})).unwrap();
s.execute("geometry.datum", &json!({"name": "Out", "by": {"type": "offset", "plane": "xy", "offset": 100}})).unwrap();
s.execute("geometry.datum", &json!({"name": "Tilt", "by": {"type": "point_normal", "point": [100, 0, 0], "normal": [1, 1, 0]}})).unwrap();
s.execute("geometry.datum.axis", &json!({"name": "Ax", "by": {"type": "principal", "axis": "x"}})).unwrap();
let model = s.model.clone();
let e = s.execute("model.boundary_condition.symmetry", &json!({"name": "S", "datum": "Tilt"})).unwrap_err();
assert!(e.to_string().contains("not supported yet"), "{e}");
for p in [
json!({"name": "S", "datum": "Out"}),
json!({"name": "S", "datum": "Tilt"}),
json!({"name": "S", "datum": "Ax"}),
json!({"name": "S", "datum": "Nope"}),
json!({"name": "S", "datum": "Mid", "set": "nope"}),
+623
View File
@@ -0,0 +1,623 @@
//! Virtual topology: faces merged for meshing only (`geometry.faces.merge`), and tiny or sliver
//! faces absorbed into a neighbour (`geometry.face.absorb`, a merge that keeps the neighbour's
//! name).
//!
//! A face merge is part of the model (`fieldcraft_model::virtual_faces`); the geometry layer
//! applies it to the open design's bodies (`fieldcraft_geometry::virtual_faces`), after the
//! partitions, whenever the design is opened or cut again ([`super::datum::partitioned`]), so the
//! meshers, the viewport and the checks all see the merged face. The B-rep is not changed.
use fieldcraft_geometry::virtual_faces::merged_face_name;
use fieldcraft_model::FaceMerge;
use fieldcraft_model::geometry::{Entity, GeometryRef};
use serde_json::{Value, json};
use crate::requirement::Requirement;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const FACES_MERGE: CommandSpec = CommandSpec {
id: "geometry.faces.merge",
label: "Merge Faces",
params: r#"{"name": "Fillet", "faces": [{"body": "Base", "face": "F3:blend#0"}, {"body": "Base", "face": "F3:blend#1"}]} — makes two or more adjacent faces of one body one face for meshing only (virtual topology): the meshers, the checks and the viewport take them as one face `merge:<name>`, without the edges between them (an edge they leave in pieces along a neighbour is one edge), on the surface they share (none when they lie on different surfaces: the meshers keep to the tessellation). The design's B-rep is not changed. A face name also selects the pieces a partition cut it into, and may be a face an earlier merge made. The model keeps the merge and applies it again, by persistent face names, whenever the design is opened (after the partitions); one that no longer applies (a face gone, faces that no longer touch) is listed with the design's errors. The body loses its mesh (`replaced`; remesh it); sets naming the merged faces no longer resolve (`stale_sets`: name `merge:<name>` instead). Faces on several bodies, faces that do not touch along an edge, or a face the body lacks are an error. One undo step"#,
form: Some(super::forms::FACES_MERGE),
kind: CommandKind::Model,
requires: &[Requirement::Design],
run: merge,
};
pub const FACES_MERGE_DELETE: CommandSpec = CommandSpec {
id: "geometry.faces.merge.delete",
label: "Delete Face Merge",
params: r#"{"name": "Fillet"} — removes a face merge: its faces are separate faces again (a later merge that names `merge:<name>` no longer applies and is listed with the design's errors); the body loses its mesh (`replaced`)"#,
form: Some(super::forms::FACES_MERGE_DELETE),
kind: CommandKind::Model,
requires: &[Requirement::FaceMerge],
run: delete,
};
pub const FACE_ABSORB: CommandSpec = CommandSpec {
id: "geometry.face.absorb",
label: "Absorb Faces",
params: r#"{"name": "Sliver", "face": {"body": "Base", "face": "F4:side#2"}, "into"?: "F1:+z" (default: the neighbour it shares the longest edges with)} | {"name"?: "Absorb", "from_check": true} — absorbs a tiny or sliver face into a neighbour for meshing only (virtual topology, as geometry.faces.merge): the meshers, the checks and the viewport take the two as one face that keeps the neighbour's name, so sets on the neighbour still select it and the tiny face no longer drives the mesh. The B-rep is not changed. `from_check`: every small and sliver face the last geometry.check found (and marked) on the open design, each into its best neighbour, as `<name>-1`, `<name>-2`, … in the check's order (a face already absorbed by an earlier one is `skipped`). Kept in the model and applied again by persistent names whenever the design is opened (delete one with geometry.faces.merge.delete); the bodies lose their meshes (`replaced`); sets naming an absorbed face are `stale_sets`. Replies `absorbed` ([{name, body, face, into}]) and `skipped`. One undo step"#,
form: Some(super::forms::FACE_ABSORB),
kind: CommandKind::Model,
requires: &[Requirement::Design],
run: absorb,
};
pub const FACE_SPLIT: CommandSpec = CommandSpec {
id: "geometry.face.split",
label: "Split Face",
params: r#"{"name": "Band", "face": {"body": "Base", "face": "F1:+z"}, "datum": "Mid" (a datum plane)} — splits a face along the curve where a datum plane crosses it, for meshing only (virtual topology; partitions cut whole bodies): the meshers, the checks and the viewport take it as two faces on its surface, `split:<name>:a` on the plane's negative side and `split:<name>:b` on the other, with the cut as an edge between them (a row of nodes along it; seed it like any edge), and the face's edges cut where the plane crosses them. The B-rep is not changed. Applied after the face merges, by persistent names, whenever the design is opened (a merge cannot name a split face); moving the datum moves the cut, and the datum cannot be deleted while a split uses it. The body loses its mesh (`replaced`); sets naming the face no longer resolve (`stale_sets`). A plane that does not cross the face is an error. Delete with geometry.face.split.delete. One undo step"#,
form: Some(super::forms::FACE_SPLIT),
kind: CommandKind::Model,
requires: &[Requirement::Design, Requirement::DatumPlane],
run: split,
};
pub const FACE_SPLIT_DELETE: CommandSpec = CommandSpec {
id: "geometry.face.split.delete",
label: "Delete Face Split",
params: r#"{"name": "Band"} — removes a face split: the face is one face again; the body loses its mesh (`replaced`)"#,
form: Some(super::forms::FACE_SPLIT_DELETE),
kind: CommandKind::Model,
requires: &[Requirement::FaceSplit],
run: split_delete,
};
/// Most faces one merge lists.
const MAX_FACES: usize = fieldcraft_model::virtual_faces::MAX_FACE_MERGE_FACES;
/// The body and the face names of `faces` (`[{body, face}]`, all on one body).
fn faces_of(p: &Value) -> Result<(String, Vec<String>)> {
let refs: Vec<GeometryRef> = super::req(p, "faces")?;
if !(2..=MAX_FACES).contains(&refs.len()) {
return Err(EngineError::invalid("faces", format!("2 to {MAX_FACES} faces of one body")));
}
let body = refs.first().map(|r| r.body.clone()).unwrap_or_default();
let mut names = Vec::with_capacity(refs.len());
for r in &refs {
match &r.entity {
Entity::Face(f) if r.body == body => names.push(f.clone()),
Entity::Face(_) => return Err(EngineError::invalid("faces", "faces of one body: a merge makes one face of one body")),
_ => {
return Err(EngineError::invalid("faces", format!("`{}`: give {{\"body\", \"face\"}}", r.body.chars().take(80).collect::<String>())));
}
}
}
Ok((body, names))
}
/// The open design's sets that name a face of `body` it no longer has.
fn stale_sets(s: &Session, body: &str) -> Vec<String> {
let Some(shape) = s.design.as_deref().and_then(|d| d.body(body)) else { return Vec::new() };
let gone = |r: &GeometryRef| r.body == body && matches!(&r.entity, Entity::Face(f) if shape.faces_named(f).is_empty());
s.model.sets().iter().filter(|(_, set)| set.geometry.iter().any(gone)).map(|(n, _)| n.clone()).collect()
}
/// Apply the model's partitions, merges and splits to the design again, failing with the error of
/// one of `features` (`merge <name>`, `split <name>`) when it has one; the meshes of `bodies` go
/// (their faces changed).
fn reapply(s: &mut Session, features: &[String], bodies: &[String], reply: Value) -> Result<Value> {
let mut cleared = super::datum::recut(s)?;
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
let errors: Vec<String> =
design.errors.iter().filter(|e| features.contains(&e.feature)).map(|e| format!("{}: {}", e.feature, e.message)).collect();
if !errors.is_empty() {
return Err(EngineError::Params(errors.join("; ")));
}
cleared.extend(super::replace::clear(&mut s.model, bodies.iter().map(String::as_str)));
let stale: Vec<String> = bodies.iter().flat_map(|b| stale_sets(s, b)).collect();
let mut reply = cleared.report_if_any(s, reply);
if !stale.is_empty()
&& let Some(o) = reply.as_object_mut()
{
let mut all: Vec<String> = o.get("stale_sets").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
all.extend(stale);
all.sort();
all.dedup();
o.insert("stale_sets".into(), json!(all));
}
Ok(reply)
}
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?.to_string();
let (body, faces) = faces_of(p)?;
if s.design.as_deref().and_then(|d| d.body(&body)).is_none() {
return Err(EngineError::Params(format!("the open design has no body `{}`", body.chars().take(200).collect::<String>())));
}
s.model.add_face_merge(FaceMerge { name: name.clone(), body: body.clone(), faces: faces.clone(), into: None })?;
let face = merged_face_name(&name);
let reply = json!({"name": name, "body": body, "face": face});
let mut reply = reapply(s, &[format!("merge {name}")], std::slice::from_ref(&body), reply)?;
let shape = s.design.as_deref().and_then(|d| d.body(&body));
let brep = shape.and_then(|b| b.face_index(&face)).and_then(|f| shape?.merged_faces().into_iter().find(|(g, _)| *g == f)).map_or(0, |(_, n)| n);
if let Some(o) = reply.as_object_mut() {
o.insert("brep_faces".into(), json!(brep));
}
Ok(reply)
}
/// The faces to absorb, by body and name: the one named, or the small and sliver faces of the
/// last geometry.check.
fn to_absorb(s: &Session, p: &Value) -> Result<Vec<(String, String)>> {
let from_check: bool = super::opt(p, "from_check")?.unwrap_or(false);
match (p.get("face"), from_check) {
(Some(_), false) => {
let r: GeometryRef = super::req(p, "face")?;
match r.entity {
Entity::Face(f) => Ok(vec![(r.body, f)]),
_ => Err(EngineError::invalid("face", "give {\"body\", \"face\"}")),
}
}
(None, true) => {
let marks = crate::view::geometry_marks(s)
.ok_or_else(|| EngineError::Params("`from_check`: run geometry.check (with marks) on the open design first".into()))?;
let mut faces: Vec<(String, String)> = Vec::new();
for f in marks.marks.iter().filter_map(|m| m.small.clone()) {
if !faces.contains(&f) {
faces.push(f);
}
}
if faces.is_empty() {
return Err(EngineError::Params("`from_check`: the last geometry.check found no small or sliver face".into()));
}
Ok(faces)
}
_ => Err(EngineError::Params("give either `face` or `from_check: true`".into())),
}
}
fn absorb(s: &mut Session, p: &Value) -> Result<Value> {
let faces = to_absorb(s, p)?;
let one = faces.len() == 1 && p.get("face").is_some();
let name = match p.get("name") {
None if !one => "Absorb".to_string(),
_ => super::name(p)?.to_string(),
};
let into: Option<String> = super::opt(p, "into")?;
if into.is_some() && !one {
return Err(EngineError::invalid("into", "goes with `face`"));
}
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
// The bodies as each absorption leaves them, to find the next one's neighbour.
let mut shapes: Vec<fieldcraft_geometry::Shape> = Vec::new();
let (mut absorbed, mut skipped, mut merges) = (Vec::new(), Vec::new(), Vec::new());
for (k, (body, face)) in faces.into_iter().enumerate() {
if !shapes.iter().any(|b| b.name == body) {
let shape = design
.body(&body)
.ok_or_else(|| EngineError::Params(format!("the open design has no body `{}`", body.chars().take(200).collect::<String>())))?;
shapes.push(shape.clone());
}
let Some(shape) = shapes.iter_mut().find(|b| b.name == body) else { continue };
let target = match &into {
Some(i) => Ok(i.clone()),
None => shape.absorbing_neighbour(&face),
};
let merged = target.map_err(EngineError::from).and_then(|t| Ok((shape.merge_faces(&t, &[face.clone(), t.clone()])?, t)));
let (next, target) = match merged {
Ok(m) => m,
Err(e) if !one => {
skipped.push(json!({"body": body, "face": face, "reason": e.to_string()}));
continue;
}
Err(e) => return Err(e),
};
*shape = next;
let merge = if one { name.clone() } else { format!("{name}-{}", k + 1) };
s.model.add_face_merge(FaceMerge {
name: merge.clone(),
body: body.clone(),
faces: vec![face.clone(), target.clone()],
into: Some(target.clone()),
})?;
absorbed.push(json!({"name": merge, "body": body, "face": face, "into": target}));
merges.push(merge);
}
if merges.is_empty() {
return Err(EngineError::Params(format!("no face could be absorbed: {}", json!(skipped))));
}
let bodies: Vec<String> = shapes.into_iter().map(|b| b.name).collect();
let features: Vec<String> = merges.iter().map(|m| format!("merge {m}")).collect();
reapply(s, &features, &bodies, json!({"absorbed": absorbed, "skipped": skipped}))
}
fn split(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?.to_string();
let r: GeometryRef = super::req(p, "face")?;
let Entity::Face(face) = r.entity else { return Err(EngineError::invalid("face", "give {\"body\", \"face\"}")) };
let datum: String = super::req(p, "datum")?;
super::datum::datum_plane_of(&s.model, &datum)?;
s.model.add_face_split(fieldcraft_model::FaceSplit { name: name.clone(), body: r.body.clone(), face: face.clone(), datum })?;
let feature = format!("split:{name}");
let faces = [format!("{feature}:a"), format!("{feature}:b")];
let reply = json!({"name": name, "body": r.body, "faces": faces});
reapply(s, &[format!("split {name}")], std::slice::from_ref(&r.body), reply)
}
fn split_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?;
let removed = s.model.remove_face_split(name)?;
let mut cleared = super::datum::recut(s)?;
cleared.extend(super::replace::clear(&mut s.model, [removed.body.as_str()]));
Ok(cleared.report_if_any(s, json!({"deleted": name, "body": removed.body})))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?;
let removed = s.model.remove_face_merge(name)?;
let mut cleared = super::datum::recut(s)?;
cleared.extend(super::replace::clear(&mut s.model, [removed.body.as_str()]));
Ok(cleared.report_if_any(s, json!({"deleted": name, "body": removed.body})))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use fieldcraft_geometry::FaceSurface;
use serde_json::json;
use crate::Session;
/// The plate's hole halves (two half-cylinders), by name.
fn halves(s: &Session) -> Vec<String> {
let b = &s.design.as_ref().unwrap().bodies[0];
b.faces()
.iter()
.zip(b.face_surfaces().unwrap())
.filter(|(_, x)| matches!(x, Some(FaceSurface::Cylinder { .. })))
.map(|(f, _)| f.name.clone())
.collect()
}
fn merge_hole(s: &mut Session) -> serde_json::Value {
let body = s.design.as_ref().unwrap().bodies[0].name.clone();
let faces: Vec<_> = halves(s).into_iter().map(|f| json!({"body": body, "face": f})).collect();
s.execute("geometry.faces.merge", &json!({"name": "Hole", "faces": faces})).unwrap()
}
fn faces(s: &Session) -> Vec<String> {
s.design.as_ref().unwrap().bodies[0].faces().iter().map(|f| f.name.clone()).collect()
}
/// A merge makes the hole one face of the design; undo and redo take it out and put it back;
/// it is saved with the model and applied again when the model and its design are opened.
#[test]
fn a_merge_is_one_face_undone_saved_and_replayed() {
let (mut s, dir) = super::super::datum::tests::plate();
let before = faces(&s);
assert_eq!(before.len(), 8);
let r = merge_hole(&mut s);
assert_eq!((r["face"].as_str(), r["brep_faces"].as_u64()), (Some("merge:Hole"), Some(2)), "{r}");
assert_eq!(faces(&s).len(), 7);
assert!(faces(&s).contains(&"merge:Hole".to_string()));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(faces(&s), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(faces(&s).len(), 7);
// Saved, and replayed on open.
let file = dir.join("merged.fieldcraft");
s.execute("file.save", &json!({"path": file.to_str().unwrap()})).unwrap();
let mut t = Session::default();
t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
assert_eq!(t.model.face_merges().len(), 1);
assert_eq!(faces(&t), faces(&s));
// Opening the design again (after an edit of it) replays the merge.
let design = s.sources.design.as_ref().unwrap().path().to_string();
s.execute("geometry.open", &json!({"path": design})).unwrap();
assert_eq!(faces(&s).len(), 7);
// Deleting it brings the halves back.
s.execute("geometry.faces.merge.delete", &json!({"name": "Hole"})).unwrap();
assert_eq!(faces(&s), before);
std::fs::remove_dir_all(dir).ok();
}
/// The mesh nodes on the seams between the plate's hole halves (the B-rep edges between them).
fn nodes_on_seams(s: &Session, seams: &[Vec<[f64; 3]>]) -> usize {
let on = |p: [f64; 3]| {
seams.iter().any(|line| {
line.windows(2).any(|w| {
let (a, b) = (w[0], w[1]);
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let ap = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];
let l2 = ab.iter().map(|x| x * x).sum::<f64>();
let t = (ab.iter().zip(&ap).map(|(x, y)| x * y).sum::<f64>() / l2).clamp(0.0, 1.0);
(0..3).map(|k| (ap[k] - t * ab[k]).powi(2)).sum::<f64>().sqrt() < 1e-6
})
})
};
s.model.nodes().values().filter(|n| on(n.xyz)).count()
}
/// The sample plate made square (30 × 30 × 10, the Ø10 hole in the middle): no layout of the
/// cross-section has a reason to cut along the seams.
fn square_plate() -> (Session, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("fieldcraft-merge-{}-{:?}", std::process::id(), std::thread::current().id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("square.solidcraft");
let plate = fieldcraft_geometry::sample::Plate { width: 30.0, ..Default::default() };
std::fs::write(&path, fieldcraft_geometry::sample::plate_design(&plate).unwrap()).unwrap();
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap();
(s, dir)
}
/// A mesher takes the merged face as one: its boundary element faces are tagged with it, the
/// halves are gone from the tags, and the seams between the halves (no longer edges) carry
/// `expected(session, seams)` nodes.
fn mesher_meshes_a_merged_face_as_one(command: &str, params: serde_json::Value, expected: impl Fn(&Session, &[Vec<[f64; 3]>]) -> usize) {
let (mut s, dir) = square_plate();
let hole = halves(&s);
let b = &s.design.as_ref().unwrap().bodies[0];
let seams: Vec<Vec<[f64; 3]>> = b
.edges()
.unwrap()
.into_iter()
.filter(|e| e.faces.len() == 2 && e.faces[0] != e.faces[1] && e.faces.iter().all(|&f| hole.contains(&b.faces()[f as usize].name)))
.map(|e| e.points)
.collect();
assert_eq!(seams.len(), 2);
s.execute(command, &params).unwrap();
let before = nodes_on_seams(&s, &seams);
assert!(before >= 6, "{command}: a node row on each seam before the merge: {before}");
let r = merge_hole(&mut s);
assert!(r["replaced"].as_array().is_some_and(|a| !a.is_empty()), "the mesh goes: {r}");
assert!(s.model.elements().is_empty());
s.execute(command, &params).unwrap();
// The face, edge and vertex names the elements and nodes are tagged with.
let c = s.model.classification();
let used = c.elements().values().flat_map(|t| t.faces.values().chain(t.edges.values())).chain(c.nodes().values());
let tagged: BTreeSet<String> = used.filter_map(|&t| c.entity(t)?.entity.name().map(str::to_string)).collect();
assert!(tagged.contains("merge:Hole"), "{tagged:?}");
assert!(!tagged.iter().any(|n| hole.iter().any(|h| n.split(['|', '~']).any(|p| p == h))), "{tagged:?}");
let after = nodes_on_seams(&s, &seams);
let expected = expected(&s, &seams);
assert_eq!(after, expected, "{command}: nodes on the seams after the merge (before {before})");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn the_tet_mesher_meshes_a_merged_face_as_one() {
// Before: the surface mesh keeps the seams as feature lines; after: none.
mesher_meshes_a_merged_face_as_one("mesh.tet.generate", json!({"size": 4}), |_, _| 0);
}
#[test]
fn the_hex_mesher_meshes_a_merged_face_as_one() {
// Before, the swept O-grid round the hole had a node column on each seam, and after the
// merge one on the seam where the closed rim's chain started (#512). The rim's start is
// no curve end now: the seams are lines like any other round the hole. The square plate's
// layout (cuts from its corners) still puts nodes in the middle of the arcs between the
// cuts, which on this plate are where the seams lie and also a quarter turn round: the
// seams carry as many nodes as those turned lines, no more and no fewer.
let turned = |s: &Session, seams: &[Vec<[f64; 3]>]| {
let quarter: Vec<Vec<[f64; 3]>> = seams.iter().map(|l| l.iter().map(|p| [30.0 - p[1], p[0], p[2]]).collect()).collect();
nodes_on_seams(s, &quarter)
};
mesher_meshes_a_merged_face_as_one("mesh.hex.generate", json!({"size": 2.5}), turned);
}
/// The hex-meshing plan sees the merged hole as one face: the hole's feature and its ring
/// project onto `merge:Hole` (still a cylinder), not the halves.
#[test]
fn the_plan_sees_a_merged_face_as_one() {
let (mut s, dir) = super::super::datum::tests::plate();
let body = s.design.as_ref().unwrap().bodies[0].name.clone();
let hole = halves(&s);
let before = s.execute("mesh.plan", &json!({"body": body})).unwrap();
let ring = |r: &serde_json::Value| r["rings"][0]["faces"].clone();
let mut halves_sorted = hole.clone();
halves_sorted.sort();
let mut got: Vec<String> = serde_json::from_value(ring(&before)).unwrap();
got.sort();
assert_eq!(got, halves_sorted, "{before}");
merge_hole(&mut s);
let after = s.execute("mesh.plan", &json!({"body": body})).unwrap();
assert_eq!(ring(&after), json!(["merge:Hole"]), "{after}");
let features = after["features"].as_array().unwrap();
assert!(features.iter().any(|f| f["faces"] == json!(["merge:Hole"])), "{after}");
assert!(!after.to_string().contains(&hole[0]), "{after}");
std::fs::remove_dir_all(dir).ok();
}
/// The viewport draws a merged face in one colour: the hole's triangles, and only those.
#[test]
fn a_merged_face_is_drawn_in_one_colour() {
let (mut s, dir) = super::super::datum::tests::plate();
let p = crate::view::Palette::LIGHT;
let merged = |s: &Session| crate::view::scene(s, &p).0.tris.into_iter().filter(|t| t.color == p.merged).collect::<Vec<_>>();
assert!(merged(&s).is_empty());
merge_hole(&mut s);
let tris = merged(&s);
assert!(!tris.is_empty());
// The hole: Ø10 about (15, 15).
for t in &tris {
assert!(t.p.iter().all(|v| ((v.x - 15.0).hypot(v.y - 15.0) - 5.0).abs() < 1e-3), "{t:?}");
}
std::fs::remove_dir_all(dir).ok();
}
/// Sets naming a merged face go stale (and are named); bad merges change nothing.
#[test]
fn bad_merges_change_nothing() {
let (mut s, dir) = super::super::datum::tests::plate();
let body = s.design.as_ref().unwrap().bodies[0].name.clone();
let names = faces(&s);
let hole = halves(&s);
s.execute("model.set.create", &json!({"name": "Bore", "geometry": [{"body": body, "face": hole[0]}]})).unwrap();
let journal = s.journal.len();
let f = |n: &str| json!({"body": body, "face": n});
for p in [
json!({}),
json!({"name": "M"}),
json!({"name": "M", "faces": [f(&hole[0])]}),
json!({"name": "M", "faces": [f(&hole[0]), {"body": "Other", "face": hole[1]}]}),
json!({"name": "M", "faces": [{"body": "Other", "face": "a"}, {"body": "Other", "face": "b"}]}),
json!({"name": "M", "faces": [f(&hole[0]), f("nope")]}),
json!({"name": "M", "faces": [f(&hole[0]), {"body": body}]}),
json!({"name": "M", "faces": [f(&hole[0]), f(&hole[0])]}),
json!({"name": "1M", "faces": [f(&hole[0]), f(&hole[1])]}),
json!({"name": "M", "faces": "x"}),
] {
assert!(s.execute("geometry.faces.merge", &p).is_err(), "{p}");
}
// Top and bottom do not touch.
let b = &s.design.as_ref().unwrap().bodies[0];
let caps: Vec<String> = b
.faces()
.iter()
.zip(b.face_surfaces().unwrap())
.filter(|(_, x)| matches!(x, Some(FaceSurface::Plane { normal, .. }) if normal[2].abs() > 0.5))
.map(|(f, _)| f.name.clone())
.collect();
let e = s.execute("geometry.faces.merge", &json!({"name": "Caps", "faces": [f(&caps[0]), f(&caps[1])]})).unwrap_err();
assert!(e.to_string().contains("does not touch"), "{e}");
assert!(s.execute("geometry.faces.merge.delete", &json!({"name": "Caps"})).is_err());
assert_eq!((s.journal.len(), faces(&s)), (journal, names));
assert!(s.model.face_merges().is_empty());
let r = merge_hole(&mut s);
assert_eq!(r["stale_sets"], json!(["Bore"]), "{r}");
std::fs::remove_dir_all(dir).ok();
}
/// A block with a 0.4 mm step on its top: the riser is a sliver at size 5.
fn stepped() -> (crate::tests::TempDir, Session) {
super::super::geometry::tests::design(json!([
{"command": "solid.box", "params": {"length": 40, "width": 20, "height": 10, "body_name": "Base"}},
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10.4, "operation": "join", "targets": ["Base"]}},
]))
}
fn small_faces(s: &mut Session) -> usize {
let r = s.execute("geometry.check", &json!({"size": 5})).unwrap();
r["findings"].as_array().unwrap().iter().filter(|f| matches!(f["kind"].as_str(), Some("small_face" | "sliver_face"))).count()
}
/// `from_check` absorbs every sliver the check found into its best neighbour, which keeps its
/// name; the check then finds none, the tet mesher tags no element face with the sliver, and
/// undo brings it back.
#[test]
fn absorbing_the_check_findings_clears_them() {
let (_dir, mut s) = stepped();
let before = small_faces(&mut s);
assert!(before > 0);
let start = faces(&s);
assert!(s.execute("geometry.face.absorb", &json!({"from_check": true, "into": "x"})).is_err());
let r = s.execute("geometry.face.absorb", &json!({"from_check": true})).unwrap();
let absorbed = r["absorbed"].as_array().unwrap();
assert_eq!(absorbed.len(), before, "{r}");
assert_eq!(absorbed[0]["name"], "Absorb-1");
let (gone, into) = (absorbed[0]["face"].as_str().unwrap().to_string(), absorbed[0]["into"].as_str().unwrap().to_string());
assert!(!faces(&s).contains(&gone) && faces(&s).contains(&into));
assert_eq!(faces(&s).len(), start.len() - before);
assert_eq!(small_faces(&mut s), 0);
s.execute("mesh.tet.generate", &json!({"size": 2})).unwrap();
let c = s.model.classification();
let used: BTreeSet<String> =
c.elements().values().flat_map(|t| t.faces.values()).filter_map(|&t| c.entity(t)?.entity.name().map(str::to_string)).collect();
assert!(used.contains(&into) && !used.contains(&gone), "{used:?}");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(faces(&s), start);
}
/// One face into a named neighbour, or its best one; bad requests change nothing.
#[test]
fn absorbing_one_face() {
let (_dir, mut s) = stepped();
let r = s.execute("geometry.check", &json!({"size": 5})).unwrap();
let sliver =
r["findings"].as_array().unwrap().iter().find(|f| f["kind"] == "sliver_face" || f["kind"] == "small_face").unwrap()["faces"][0].clone();
let face = json!({"body": "Base", "face": sliver});
let journal = s.journal.len();
for p in [
json!({"name": "A"}),
json!({"name": "A", "face": face, "from_check": true}),
json!({"face": face}),
json!({"name": "A", "face": {"body": "Base", "face": "nope"}}),
json!({"name": "A", "face": face, "into": "nope"}),
json!({"name": "A", "face": {"body": "Nobody", "face": "x"}}),
json!({"name": "A", "face": face, "from_check": "yes"}),
] {
assert!(s.execute("geometry.face.absorb", &p).is_err(), "{p}");
}
assert_eq!(s.journal.len(), journal);
let r = s.execute("geometry.face.absorb", &json!({"name": "Riser", "face": face})).unwrap();
assert_eq!(r["absorbed"][0]["name"], "Riser", "{r}");
assert!(s.model.face_merges()[0].into.is_some());
}
/// The plate's top face (z = 10), by name.
fn top(s: &Session) -> String {
let b = &s.design.as_ref().unwrap().bodies[0];
b.faces()
.iter()
.zip(b.face_surfaces().unwrap())
.find(|(_, x)| matches!(x, Some(FaceSurface::Plane { normal, point }) if normal[2] > 0.5 && point[2] > 9.0))
.unwrap()
.0
.name
.clone()
}
/// Mesh nodes on the top along x = `x`.
fn nodes_along(s: &Session, x: f64) -> usize {
s.model.nodes().values().filter(|n| (n.xyz[0] - x).abs() < 1e-6 && (n.xyz[2] - 10.0).abs() < 1e-6).count()
}
/// Splitting the top along a datum plane gives two faces the tet mesher tags, with a row of
/// nodes along the cut; the split follows the datum, holds it, is saved and replayed, and
/// undoes.
#[test]
fn a_split_face_meshes_with_a_node_row_along_the_cut() {
let (mut s, dir) = super::super::datum::tests::plate();
let body = s.design.as_ref().unwrap().bodies[0].name.clone();
let face = json!({"body": body, "face": top(&s)});
s.execute("geometry.datum", &json!({"name": "Cut", "by": {"type": "offset", "plane": "yz", "offset": 30.3}})).unwrap();
let mesh = json!({"size": 3});
s.execute("mesh.tet.generate", &mesh).unwrap();
let before = nodes_along(&s, 30.3);
let r = s.execute("geometry.face.split", &json!({"name": "Band", "face": face, "datum": "Cut"})).unwrap();
assert_eq!(r["faces"], json!(["split:Band:a", "split:Band:b"]), "{r}");
assert!(r["replaced"].is_array(), "{r}");
assert_eq!(faces(&s).len(), 9);
s.execute("mesh.tet.generate", &mesh).unwrap();
let after = nodes_along(&s, 30.3);
assert!(after >= 10 && after > before, "a node row along the 30 mm cut: {before} -> {after}");
let c = s.model.classification();
let used: BTreeSet<String> =
c.elements().values().flat_map(|t| t.faces.values()).filter_map(|&t| c.entity(t)?.entity.name().map(str::to_string)).collect();
assert!(used.contains("split:Band:a") && used.contains("split:Band:b"), "{used:?}");
// The datum holds; moving it moves the cut.
assert!(s.execute("geometry.datum.delete", &json!({"name": "Cut"})).is_err());
s.execute("geometry.datum.edit", &json!({"name": "Cut", "by": {"type": "offset", "plane": "yz", "offset": 35.5}})).unwrap();
s.execute("mesh.tet.generate", &mesh).unwrap();
assert!(nodes_along(&s, 35.5) >= 10);
// Saved and replayed.
let file = dir.join("split.fieldcraft");
s.execute("file.save", &json!({"path": file.to_str().unwrap()})).unwrap();
let mut t = Session::default();
t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
assert_eq!(faces(&t), faces(&s));
// Bad splits change nothing; delete brings the face back.
let journal = s.journal.len();
for p in [
json!({"name": "X", "face": face, "datum": "nope"}),
json!({"name": "X", "face": {"body": body, "face": "nope"}, "datum": "Cut"}),
json!({"name": "X", "face": {"body": body}, "datum": "Cut"}),
json!({"name": "Band", "face": face, "datum": "Cut"}),
json!({"name": "X", "face": {"body": body, "face": "split:Band:b"}, "datum": "Cut"}),
] {
assert!(s.execute("geometry.face.split", &p).is_err(), "{p}");
}
assert_eq!(s.journal.len(), journal);
s.execute("geometry.face.split.delete", &json!({"name": "Band"})).unwrap();
assert_eq!(faces(&s).len(), 8);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(faces(&s).len(), 9);
std::fs::remove_dir_all(dir).ok();
}
}
+490
View File
@@ -0,0 +1,490 @@
//! `results.xy`: one quantity of a solved step against its natural axis — a nodal field per
//! frame against the load factor (nonlinear static) or time (explicit, transient heat), a mode
//! shape's value or the frequencies against the mode number, and an explicit step's histories
//! (energies, momentum, constraint force, history nodes) against time. Read-only; with `path`
//! the series is also written as a two-column CSV.
use serde_json::{Value, json};
use super::opt;
use super::results::{Cell, Table, Want, step_for};
use crate::requirement::Requirement;
use crate::results::{Field, Kind, Pick, Quantity, field_values, with_unit};
use crate::schema::ProblemKind;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, StepResult};
pub const XY: CommandSpec = CommandSpec {
id: "results.xy",
label: "XY Plot",
params: r#"{"step": "Load", "y": {"field": "displacement_z", "node": 21}, "x": "auto", "every": 1, "path": "tip.csv"} — a quantity of a solved step against its natural axis: `x` "load_factor" (nonlinear static), "time" (explicit, transient heat), "mode" (frequency and buckling) or "auto" (the step's own, the default). `y` is {"field": <a contour field id>, "node": <label>} (a nodal field per frame or mode: on nonlinear static steps the displacements, whose frames start at load factor 0; on heat steps the temperature), {"energy": "kinetic|internal|hourglass|viscous|external|constraint|contact|friction|plastic|eroded|added_kinetic|balance"}, {"history": "constraint_force_x|_y|_z|momentum_x|_y|_z|increment"}, {"history_node": 21, "quantity": "displacement|displacement_x|…|velocity_z"} (explicit histories), "frequency" (frequency steps) or "load_factor" (buckling steps). Reactions per increment are not stored by the nonlinear solver, so a reaction cannot be plotted against the load factor; an explicit step's `constraint_force` history is its reaction against time. At most 100000 points: `every` keeps one in so many (by default enough to fit). A stale result is refused. With `path` the series is written as CSV (a header line, then x,y rows). Replies {step, x: {label, values}, y: {label, values}, points, every, frames?} (`frames`: the field frame of each point, for results.frame)"#,
form: Some(super::forms::RESULTS_XY),
kind: CommandKind::Session,
requires: &[Requirement::Result],
run: xy,
};
/// Most points a series holds.
pub const MAX_POINTS: usize = 100_000;
/// What a series is plotted against.
#[derive(Clone, Copy, PartialEq, Debug)]
enum Axis {
LoadFactor,
Time,
Mode,
}
impl Axis {
fn id(self) -> &'static str {
match self {
Axis::LoadFactor => "load_factor",
Axis::Time => "time",
Axis::Mode => "mode",
}
}
fn parse(id: &str) -> Option<Axis> {
[Axis::LoadFactor, Axis::Time, Axis::Mode].into_iter().find(|a| a.id() == id)
}
fn label(self, s: &Session) -> String {
match self {
Axis::LoadFactor => "Load factor".into(),
Axis::Time => with_unit("Time", Quantity::Time.unit(s.model.units()).as_deref()),
Axis::Mode => "Mode".into(),
}
}
}
/// The energies of an explicit step (`y: {"energy": …}`).
pub const ENERGIES: [&str; 12] =
["kinetic", "internal", "hourglass", "viscous", "external", "constraint", "contact", "friction", "plastic", "eroded", "added_kinetic", "balance"];
/// The other histories of an explicit step (`y: {"history": …}`).
pub const HISTORIES: [&str; 7] =
["constraint_force_x", "constraint_force_y", "constraint_force_z", "momentum_x", "momentum_y", "momentum_z", "increment"];
/// What a history node keeps (`y: {"history_node": n, "quantity": …}`).
pub const NODE_QUANTITIES: [&str; 8] =
["displacement", "displacement_x", "displacement_y", "displacement_z", "velocity", "velocity_x", "velocity_y", "velocity_z"];
/// A series: its labels, its x values and a function giving the point at an index.
struct Series<'a> {
x_label: String,
y_label: String,
len: usize,
point: Box<dyn Fn(usize) -> Result<(f64, f64)> + 'a>,
/// Whether point `i` is field frame `i` (results.frame can show it).
frames: bool,
}
fn y_error(why: impl Into<String>) -> EngineError {
EngineError::invalid("y", why)
}
fn magnitude(v: &[f64; 3]) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
/// A vector's component or magnitude by suffix: `""` the magnitude, `"_x"`… a component.
fn component(v: &[f64; 3], suffix: &str) -> Option<f64> {
match suffix {
"" => Some(magnitude(v)),
"_x" => Some(v[0]),
"_y" => Some(v[1]),
"_z" => Some(v[2]),
_ => None,
}
}
/// A node label from `y.node` or `y.history_node`.
fn node_label(y: &Value, key: &str) -> Result<u32> {
let v = y.get(key).ok_or_else(|| y_error(format!("needs `{key}`: a node label")))?;
v.as_u64()
.and_then(|n| u32::try_from(n).ok())
.filter(|n| *n > 0)
.ok_or_else(|| y_error(format!("`{key}` must be a node label from 1 to {}", u32::MAX)))
}
/// The fields a step plots per frame or mode at a node.
pub fn plottable(r: &StepResult) -> Vec<Field> {
let shapes = [Field::Displacement, Field::DisplacementX, Field::DisplacementY, Field::DisplacementZ];
match r.kind() {
// Nonlinear frames hold displacements only; the other fields are the final state's.
Kind::Static(_) | Kind::Frequency(_) => shapes.to_vec(),
Kind::Explicit(_) => Field::available(r).collect(),
Kind::Heat(_) => vec![Field::Temperature],
Kind::Fluid(_) => Vec::new(),
}
}
fn ids(list: &[&str]) -> String {
list.join(", ")
}
/// The series `y` asks for on step `step`'s result `r`, against `axis`.
fn series<'a>(s: &'a Session, step: &str, r: &'a StepResult, axis: Axis, y: &Value) -> Result<Series<'a>> {
let x_label = axis.label(s);
let units = s.model.units();
let not_explicit = || y_error(format!("step `{step}` is not an explicit dynamic step; it has no histories"));
if let Some(name) = y.as_str() {
let Kind::Frequency(f) = r.kind() else {
return Err(y_error(format!("`{name}` needs a frequency or buckling step; `{step}` is not one")));
};
let buckling = f.buckling.is_some();
let (y_label, field): (String, fn(&fieldcraft_solver::Mode) -> f64) = match (name, buckling) {
("frequency", false) => (with_unit("Frequency", Some("cycles per time unit")), |m| m.frequency),
("load_factor", true) => ("Buckling load factor".into(), |m| m.eigenvalue),
("frequency", true) => return Err(y_error(format!("step `{step}` is a buckling step: its modes are load factors (y: \"load_factor\")"))),
("load_factor", false) => return Err(y_error(format!("step `{step}` is a frequency step: y: \"frequency\""))),
_ => {
return Err(y_error(format!(
"`{name}` is not a series; use \"frequency\", \"load_factor\" or an object ({{\"field\", \"node\"}}, …)"
)));
}
};
return Ok(Series {
x_label,
y_label,
len: f.modes.len(),
point: Box::new(move |i| Ok((i as f64 + 1.0, f.modes.get(i).map_or(f64::NAN, field)))),
frames: false,
});
}
if !y.is_object() {
return Err(y_error(
"must be an object ({\"field\", \"node\"}, {\"energy\"}, {\"history\"} or {\"history_node\", \"quantity\"}) or \"frequency\"",
));
}
if let Some(id) = y.get("field") {
let available = plottable(r);
let list = || available.iter().map(|f| f.id()).collect::<Vec<_>>().join(", ");
let field = id.as_str().and_then(Field::parse).filter(|f| available.contains(f)).ok_or_else(|| {
if matches!(r.kind(), Kind::Fluid(_)) {
y_error(format!("step `{step}` is a fluid step: its fields are per cell (solve.probe)"))
} else {
y_error(format!("`field` must be one step `{step}` has per {}: {}", if axis == Axis::Mode { "mode" } else { "frame" }, list()))
}
})?;
let label = node_label(y, "node")?;
let at = r.nodes().iter().position(|n| *n == label).ok_or_else(|| y_error(format!("node {label} has no result in step `{step}`")))?;
let model = &s.model;
let value = move |pick: Pick| -> Result<f64> { Ok(field_values(model, r, field, pick)?.get(at).copied().unwrap_or(f64::NAN)) };
let y_label = format!("{} at node {label}", field.title(r, units));
let (len, frames, x): (usize, bool, Box<dyn Fn(usize) -> f64 + 'a>) = match r.kind() {
Kind::Static(st) => {
let h = st.nonlinear.as_ref().ok_or_else(|| {
y_error(format!("step `{step}` is a linear static step: one state, nothing to plot against (a nonlinear static step keeps a frame per increment)"))
})?;
(h.frames.len(), true, Box::new(move |i| h.frames.get(i).map_or(f64::NAN, |f| f.load_factor)))
}
Kind::Explicit(e) => (e.frames.len(), true, Box::new(move |i| e.frames.get(i).map_or(f64::NAN, |f| f.time))),
Kind::Heat(h) => (h.frames.len(), true, Box::new(move |i| h.times.get(i).copied().unwrap_or(f64::NAN))),
Kind::Frequency(f) => (f.modes.len(), false, Box::new(|i| i as f64 + 1.0)),
Kind::Fluid(_) => return Err(y_error(format!("step `{step}` is a fluid step: its fields are per cell (solve.probe)"))),
};
let point = move |i: usize| -> Result<(f64, f64)> {
let pick = if frames { Pick { mode: 1, frame: Some(i) } } else { Pick { mode: i + 1, frame: None } };
Ok((x(i), value(pick)?))
};
return Ok(Series { x_label, y_label, len, point: Box::new(point), frames });
}
if y.get("reaction").is_some() {
return Err(y_error(
"reactions are not stored per increment, so they cannot be plotted against the load factor (results.reactions gives the final sums; an explicit step's {\"history\": \"constraint_force_z\"} is its reaction against time)",
));
}
let Kind::Explicit(e) = r.kind() else { return Err(not_explicit()) };
let h = &e.history;
let time = move |i: usize| h.time.get(i).copied().unwrap_or(f64::NAN);
if let Some(name) = y.get("energy") {
let name = name.as_str().filter(|n| ENERGIES.contains(n)).ok_or_else(|| y_error(format!("`energy` must be one of {}", ids(&ENERGIES))))?;
let pick: fn(&fieldcraft_explicit::Energies) -> f64 = match name {
"kinetic" => |e| e.kinetic,
"internal" => |e| e.internal,
"hourglass" => |e| e.hourglass,
"viscous" => |e| e.viscous,
"external" => |e| e.external,
"constraint" => |e| e.constraint,
"contact" => |e| e.contact,
"friction" => |e| e.friction,
"plastic" => |e| e.plastic,
"eroded" => |e| e.eroded,
"added_kinetic" => |e| e.added_kinetic,
_ => |e| e.balance,
};
let label = format!("{} energy", name.replace('_', " "));
let label = label.get(..1).map(str::to_uppercase).unwrap_or_default() + label.get(1..).unwrap_or_default();
return Ok(Series {
x_label,
y_label: with_unit(&label, Quantity::Energy.unit(units).as_deref()),
len: h.time.len(),
point: Box::new(move |i| Ok((time(i), h.energies.get(i).map_or(f64::NAN, pick)))),
frames: false,
});
}
if let Some(name) = y.get("history") {
let name = name.as_str().filter(|n| HISTORIES.contains(n)).ok_or_else(|| y_error(format!("`history` must be one of {}", ids(&HISTORIES))))?;
let axis_of = |suffix: &str| match suffix {
"x" => 0,
"y" => 1,
_ => 2,
};
let (y_label, value): (String, Box<dyn Fn(usize) -> f64 + 'a>) = if let Some(c) = name.strip_prefix("constraint_force_") {
let k = axis_of(c);
(
with_unit(&format!("Constraint force {}", c.to_uppercase()), Quantity::Force.unit(units).as_deref()),
Box::new(move |i| h.constraint_force.get(i).map_or(f64::NAN, |v| v[k])),
)
} else if let Some(c) = name.strip_prefix("momentum_") {
let k = axis_of(c);
(format!("Momentum {}", c.to_uppercase()), Box::new(move |i| h.momentum.get(i).map_or(f64::NAN, |v| v[k])))
} else {
(with_unit("Time increment", Quantity::Time.unit(units).as_deref()), Box::new(move |i| h.increment.get(i).copied().unwrap_or(f64::NAN)))
};
return Ok(Series { x_label, y_label, len: h.time.len(), point: Box::new(move |i| Ok((time(i), value(i)))), frames: false });
}
if y.get("history_node").is_some() {
let label = node_label(y, "history_node")?;
let k = h.nodes.iter().position(|n| *n == label).ok_or_else(|| {
let listed: Vec<String> = h.nodes.iter().take(super::LISTED).map(u32::to_string).collect();
y_error(format!(
"node {label} has no history in step `{step}`; {}",
if listed.is_empty() { "the step keeps no node histories".to_string() } else { format!("history nodes: {}", listed.join(", ")) }
))
})?;
let quantity = y
.get("quantity")
.and_then(Value::as_str)
.filter(|q| NODE_QUANTITIES.contains(q))
.ok_or_else(|| y_error(format!("`quantity` must be one of {}", ids(&NODE_QUANTITIES))))?;
let (values, suffix, title, q) = match quantity.strip_prefix("displacement") {
Some(suffix) => (&h.displacement, suffix, "Displacement", Quantity::Length),
None => (&h.velocity, quantity.trim_start_matches("velocity"), "Velocity", Quantity::Velocity),
};
let name =
if suffix.is_empty() { format!("{title} magnitude") } else { format!("{title} {}", suffix.trim_start_matches('_').to_uppercase()) };
let suffix = suffix.to_string();
return Ok(Series {
x_label,
y_label: format!("{} at node {label}", with_unit(&name, q.unit(units).as_deref())),
len: h.time.len(),
point: Box::new(move |i| Ok((time(i), values.get(i).and_then(|row| row.get(k)).and_then(|v| component(v, &suffix)).unwrap_or(f64::NAN)))),
frames: false,
});
}
Err(y_error("must have one of `field` (with `node`), `energy`, `history` or `history_node` (with `quantity`)"))
}
/// The step's own axis.
fn natural(step: &str, r: &StepResult) -> Result<Axis> {
match r.kind() {
Kind::Static(st) if st.nonlinear.is_some() => Ok(Axis::LoadFactor),
Kind::Static(_) => Err(EngineError::Params(format!(
"step `{step}` is a linear static step: one state, nothing to plot against (a nonlinear static step keeps a frame per increment)"
))),
Kind::Explicit(_) | Kind::Heat(_) => Ok(Axis::Time),
Kind::Frequency(_) => Ok(Axis::Mode),
Kind::Fluid(_) => Err(EngineError::Params(format!("step `{step}` is a fluid step: it has no series to plot yet"))),
}
}
fn xy(s: &mut Session, p: &Value) -> Result<Value> {
let step = step_for(s, p, Want::Any)?;
let (r, current) = s.latest_result(&step).ok_or_else(|| EngineError::Params(format!("step `{step}` has no result")))?;
let axis = match opt::<String>(p, "x")?.as_deref() {
None | Some("auto") => None,
Some(id) => Some(Axis::parse(id).ok_or_else(|| EngineError::invalid("x", "must be \"auto\", \"load_factor\", \"time\" or \"mode\""))?),
};
let y = p.get("y").filter(|v| !v.is_null()).ok_or_else(|| EngineError::param("y", ProblemKind::Required))?;
if !current {
return Err(EngineError::Params(format!("the result of step `{step}` is stale (the model changed since it was solved); solve it again")));
}
let own = natural(&step, r)?;
if let Some(a) = axis.filter(|a| *a != own) {
return Err(EngineError::invalid("x", format!("step `{step}` is plotted against {}, not {}", own.id(), a.id())));
}
let every = match opt::<u64>(p, "every")? {
Some(0) => return Err(EngineError::param("every", ProblemKind::Below(1.0))),
Some(k) => Some(usize::try_from(k).unwrap_or(usize::MAX)),
None => None,
};
let path = match p.get("path") {
Some(_) => Some(super::file_param(s, p, "path", fieldcraft_io::roots::Access::Write)?),
None => None,
};
let series = series(s, &step, r, own, y)?;
let every = every.unwrap_or_else(|| series.len.div_ceil(MAX_POINTS).max(1));
if series.len.div_ceil(every) > MAX_POINTS {
return Err(EngineError::invalid("every", format!("{} points in steps of {every} are more than {MAX_POINTS}; raise `every`", series.len)));
}
let picked: Vec<usize> = (0..series.len).step_by(every).collect();
let (mut xs, mut ys) = (Vec::with_capacity(picked.len()), Vec::with_capacity(picked.len()));
for &i in &picked {
let (x, y) = (series.point)(i)?;
xs.push(x);
ys.push(y);
}
let mut out = json!({
"step": step,
"x": {"axis": own.id(), "label": series.x_label, "values": xs},
"y": {"label": series.y_label, "values": ys},
"points": picked.len(),
"every": every,
});
if series.frames {
out["frames"] = json!(picked);
}
if let Some(path) = path {
let cell = |v: f64| if v.is_finite() { Cell::Number(v) } else { Cell::Empty };
let table = Table { header: vec![series.x_label, series.y_label], rows: xs.iter().zip(&ys).map(|(x, y)| vec![cell(*x), cell(*y)]).collect() };
fieldcraft_io::fs::write_atomic(std::path::Path::new(&path), table.csv().as_bytes(), false)?;
out["path"] = json!(path);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cmd::solve::tests::cantilever;
fn values(r: &Value, axis: &str) -> Vec<f64> {
r[axis]["values"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect()
}
fn impact() -> Session {
let mut s = Session::default();
s.run_script(&crate::sample::impact()).unwrap();
s.execute("solve.explicit", &json!({})).unwrap();
s
}
/// Energies and constraint forces are solve.history's numbers, sample for sample.
#[test]
fn explicit_series_are_the_histories() {
let mut s = impact();
let history = s.execute("solve.history", &json!({"step": "Impact"})).unwrap();
let r = s.execute("results.xy", &json!({"step": "Impact", "y": {"energy": "kinetic"}})).unwrap();
let time: Vec<f64> = history["time"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
let kinetic: Vec<f64> = history["energies"].as_array().unwrap().iter().map(|e| e["kinetic"].as_f64().unwrap()).collect();
assert_eq!(values(&r, "x"), time);
assert_eq!(values(&r, "y"), kinetic);
assert_eq!(r["x"]["axis"], "time");
assert!(r.get("frames").is_none(), "history samples are not field frames");
let r = s.execute("results.xy", &json!({"step": "Impact", "x": "time", "y": {"history": "constraint_force_x"}})).unwrap();
let fx: Vec<f64> = history["constraint_force"].as_array().unwrap().iter().map(|f| f[0].as_f64().unwrap()).collect();
assert_eq!(values(&r, "y"), fx);
// A nodal field per field frame, with the frames for results.frame.
let r = s.execute("results.xy", &json!({"step": "Impact", "y": {"field": "velocity", "node": 1}})).unwrap();
assert_eq!(r["points"], 11);
assert_eq!(r["frames"].as_array().unwrap().len(), 11);
assert!((values(&r, "y")[0] - 10_000.0).abs() < 1e-6, "every node starts at 10 m/s: {r}");
// `every` strides.
let r = s.execute("results.xy", &json!({"step": "Impact", "y": {"energy": "balance"}, "every": 2})).unwrap();
assert_eq!(r["points"].as_u64().unwrap() as usize, time.len().div_ceil(2));
}
/// The frequency series is the solve's frequencies against modes 1 to n.
#[test]
fn frequencies_against_the_mode_number() {
let mut s = Session::default();
cantilever(&mut s);
let summary = s.execute("solve.frequency", &json!({})).unwrap();
let r = s.execute("results.xy", &json!({"step": "Modes", "y": "frequency"})).unwrap();
let expected: Vec<f64> = summary["modes"].as_array().unwrap().iter().map(|m| m["frequency"].as_f64().unwrap()).collect();
assert_eq!(values(&r, "y"), expected);
assert_eq!(values(&r, "x"), (1..=expected.len()).map(|m| m as f64).collect::<Vec<_>>());
assert!(s.execute("results.xy", &json!({"step": "Modes", "y": "load_factor"})).is_err());
assert!(s.execute("results.xy", &json!({"step": "Modes", "x": "time", "y": "frequency"})).is_err());
}
/// A small tip load on the nonlinear cantilever stays linear: the tip deflection grows in
/// proportion to the load factor, from 0, to the linear static answer (P L³ / 3 E I plus
/// shear), and its last point is solve.probe's at the last frame. The CSV holds the series.
#[test]
fn tip_deflection_against_the_load_factor() {
let mut s = Session::default();
cantilever(&mut s);
s.run_script(&json!([
{"command": "model.section.edit", "params": {"name": "bar", "set": "all", "material": "Mat", "kind": {"type": "solid"}, "formulation": {}}},
{"command": "model.load.edit", "params": {"name": "push", "type": "force", "target": {"set": "tip"}, "vector": [0, 0, -0.001]}},
{"command": "model.step.create", "params": {"name": "Large", "procedure": {"type": "nonlinear_static", "initial_increment": 0.25},
"boundary_conditions": ["clamp"], "loads": ["push"]}},
]))
.unwrap();
s.execute("solve.static", &json!({"step": "Bend"})).unwrap();
let linear = -s.execute("solve.probe", &json!({"step": "Bend", "nodes": [11]})).unwrap()["nodes"][0]["displacement"][2].as_f64().unwrap();
s.execute("solve.nonlinear", &json!({"step": "Large"})).unwrap();
let path = std::env::temp_dir().join(format!("fieldcraft-xy-{}.csv", std::process::id())).to_string_lossy().into_owned();
let r = s.execute("results.xy", &json!({"step": "Large", "y": {"field": "displacement_z", "node": 11}, "path": path})).unwrap();
let (x, y) = (values(&r, "x"), values(&r, "y"));
assert_eq!(r["x"]["axis"], "load_factor");
assert_eq!((x[0], y[0]), (0.0, 0.0), "the undeformed state first");
assert!((x.last().unwrap() - 1.0).abs() < 1e-12, "{r}");
for (l, d) in x.iter().zip(&y).skip(1) {
assert!((-d / l - linear).abs() / linear < 1e-3, "λ {l}: {d} against {linear} λ");
}
let frames = r["frames"].as_array().unwrap().len();
let probe = s.execute("solve.probe", &json!({"step": "Large", "nodes": [11], "frame": frames - 1})).unwrap();
assert_eq!(*y.last().unwrap(), probe["nodes"][0]["displacement"][2].as_f64().unwrap());
let csv = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(lines[0], "Load factor,Displacement Z at node 11");
assert_eq!(lines.len(), x.len() + 1);
assert_eq!(lines.last().unwrap().split(',').nth(1).unwrap().parse::<f64>().unwrap(), *y.last().unwrap());
let _ = std::fs::remove_file(&path);
// Stresses are the final state's only; a linear step has one state; reactions are not stored.
assert!(s.execute("results.xy", &json!({"step": "Large", "y": {"field": "von_mises", "node": 11}})).is_err());
assert!(s.execute("results.xy", &json!({"step": "Bend", "y": {"field": "displacement_z", "node": 11}})).is_err());
let e = s.execute("results.xy", &json!({"step": "Large", "y": {"reaction": "root"}})).unwrap_err().to_string();
assert!(e.contains("not stored per increment"), "{e}");
// A stale result is refused.
s.execute("mesh.node.add", &json!({"label": 99_999, "xyz": [0, 0, 0]})).unwrap();
let e = s.execute("results.xy", &json!({"step": "Large", "y": {"field": "displacement_z", "node": 11}})).unwrap_err().to_string();
assert!(e.contains("stale"), "{e}");
}
#[test]
fn xy_hostile_params_never_panic() {
let mut modes = Session::default();
cantilever(&mut modes);
modes.execute("solve.frequency", &json!({"step": "Modes"})).unwrap();
let hostile = [
json!(null),
json!([]),
json!({}),
json!({"y": null}),
json!({"y": 7}),
json!({"y": []}),
json!({"y": "nope"}),
json!({"step": "Nope", "y": "frequency"}),
json!({"step": "Impact", "y": {"field": "nope", "node": 1}}),
json!({"step": "Impact", "y": {"field": "velocity", "node": 0}}),
json!({"step": "Impact", "y": {"field": "velocity", "node": 4_294_967_296_u64}}),
json!({"step": "Impact", "y": {"field": "velocity", "node": -1}}),
json!({"step": "Impact", "y": {"field": "velocity", "node": f64::NAN}}),
json!({"step": "Impact", "y": {"field": "velocity"}}),
json!({"step": "Impact", "y": {"energy": 3}}),
json!({"step": "Impact", "y": {"history": "nope"}}),
json!({"step": "Impact", "y": {"history_node": 1e300, "quantity": "velocity"}}),
json!({"step": "Impact", "y": {"history_node": 1, "quantity": "nope"}}),
json!({"step": "Impact", "x": "load_factor", "y": {"energy": "kinetic"}}),
json!({"step": "Impact", "x": 7, "y": {"energy": "kinetic"}}),
json!({"step": "Impact", "y": {"energy": "kinetic"}, "every": 0}),
json!({"step": "Impact", "y": {"energy": "kinetic"}, "every": 1e300}),
json!({"step": "Impact", "y": {"energy": "kinetic"}, "path": ""}),
json!({"step": "Impact", "y": {"energy": "kinetic"}, "path": "/no/such/dir/x.csv"}),
json!({"step": "Modes", "y": {"energy": "kinetic"}}),
json!({"step": "Modes", "y": {"field": "von_mises", "node": 1}}),
];
for s in [impact(), modes] {
for p in &hostile {
let mut t = s.clone();
assert!(t.execute("results.xy", p).is_err(), "{p}");
assert_eq!(t.model, s.model);
}
}
let mut empty = Session::default();
let e = empty.execute("results.xy", &json!({"y": "frequency"})).unwrap_err().to_string();
assert!(e.contains("solve"), "{e}");
}
}
+189 -16
View File
@@ -19,8 +19,16 @@
//! written again; at most [`MAX_NEW_PER_SESSION`] new reports per run, [`MAX_REPORTS`]
//! reports, [`MAX_TOTAL_BYTES`] on disk, nothing older than [`MAX_AGE_DAYS`] days.
//!
//! - **Startup steps** ([`phase`]): the journal keeps the step the app is in while it gets its
//! window and graphics device up (window, graphics instance, adapter, device, shader
//! compiles, first frame, [`RUNNING`]), so a crash inside a graphics driver still says where
//! it happened, and the renderer ([`set_environment`]) once the adapter is picked.
//! - **Native crashes** (Windows): the app's exception filter writes what it caught (exception
//! code, faulting module and offset) and a minidump next to the journal ([`native_paths`]);
//! the next start puts both into the unclean exit's report.
//!
//! Reports are folders `crashes/<time>-<signature>/` holding `report.json`, and when known
//! `model.fieldcraft`, `replay.json` and `log.txt`. Nothing is ever sent anywhere.
//! `model.fieldcraft`, `replay.json`, `log.txt` and `minidump.dmp`. Nothing is ever sent anywhere.
//!
//! Adapted from SolidCraft's `crates/engine/src/crash.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
@@ -61,6 +69,8 @@ const LOG_TAIL_BYTES: usize = 64 << 10;
pub const FORMAT: &str = "fieldcraft-crash/1";
/// The model file in a report folder.
pub const MODEL_FILE: &str = "model.fieldcraft";
/// The minidump of a native crash in a report folder (Windows).
pub const MINIDUMP_FILE: &str = "minidump.dmp";
/// What the app is (shown in every report).
#[derive(Clone, Debug, Default)]
@@ -112,8 +122,13 @@ struct State {
known: Mutex<HashMap<u64, (u64, String)>>,
written: AtomicUsize,
started: u64,
/// When the app started, in milliseconds (for how far into the start a step came).
started_ms: u64,
}
/// The startup step that ends startup: the app is up and drawing.
pub const RUNNING: &str = "running";
static STATE: OnceLock<State> = OnceLock::new();
/// The hook is writing a report (a panic inside it must not write another).
static IN_HOOK: AtomicBool = AtomicBool::new(false);
@@ -147,6 +162,10 @@ fn now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
fn now_ms() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
/// `secs` since 1970 as (year, month, day, hour, minute, second) in UTC.
fn civil(secs: u64) -> (i64, i64, i64, u64, u64, u64) {
let days = i64::try_from(secs / 86_400).unwrap_or(0);
@@ -304,10 +323,12 @@ fn frames(bt: &str) -> Vec<String> {
pub fn install(dir: PathBuf, build: Build) -> Vec<Summary> {
let _ = fs::create_dir_all(dir.join("sessions"));
let started = now();
let started_ms = now_ms();
for path in dead_journals(&dir) {
unclean_exit(&dir, &build, &path);
remove_journal(&path);
}
remove_stray_native_files(&dir);
prune(&dir, started);
// Reports the user has been told about (by id).
let seen_file = dir.join(".seen");
@@ -320,7 +341,10 @@ pub fn install(dir: PathBuf, build: Build) -> Vec<Summary> {
let _ = fs::write(&seen_file, all.iter().map(|s| s.id.as_str()).collect::<Vec<_>>().join("\n"));
let known = all.into_iter().filter_map(|s| sig_of(&s.id).map(|sig| (sig, (0, s.id)))).collect();
let journal = Journal::open(&dir, &build, started);
if STATE.set(State { dir, build, journal: Mutex::new(journal), known: Mutex::new(known), written: AtomicUsize::new(0), started }).is_ok() {
if STATE
.set(State { dir, build, journal: Mutex::new(journal), known: Mutex::new(known), written: AtomicUsize::new(0), started, started_ms })
.is_ok()
{
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if !hook(info) {
@@ -348,6 +372,59 @@ pub fn activity(what: &str) {
}
}
/// The app is about to start a step of getting up (`"window"`, `"device creation"`, …;
/// [`RUNNING`] once it draws): the journal keeps the latest, so a crash nothing catches (inside a
/// graphics driver, say) is still reported with the step it happened in.
pub fn phase(step: &str) {
let Some(s) = STATE.get() else { return };
if let Ok(mut j) = s.journal.lock() {
if j.phase_step.as_deref() == Some(step) {
return;
}
let line = json!({ "phase": limited(step, 200), "ms": now_ms().saturating_sub(s.started_ms) }).to_string();
j.write(&line);
j.phase = Some(line);
j.phase_step = Some(step.to_string());
}
}
/// The renderer, GPU and driver, once known (one short line): reports show it as
/// `system.environment`.
pub fn set_environment(text: &str) {
let Some(s) = STATE.get() else { return };
if let Ok(mut j) = s.journal.lock() {
let line = json!({ "environment": limited(text, 500) }).to_string();
j.write(&line);
j.environment = Some(line);
}
}
/// Where a native crash handler writes what it caught for this run: a small JSON file
/// (`code`, `name`, `address`, `module`, `offset`, `detail`, `thread`) and a minidump. The next
/// start reports both with this run's unclean exit. `None` before [`install`] or without a journal.
pub fn native_paths() -> Option<(PathBuf, PathBuf)> {
let s = STATE.get()?;
let j = s.journal.lock().ok()?;
j.path.as_ref().map(|p| native_paths_for(p))
}
fn native_paths_for(journal: &Path) -> (PathBuf, PathBuf) {
(journal.with_extension("native.json"), journal.with_extension("dmp"))
}
/// Native crash files whose journal is gone (their report was written, or never will be).
fn remove_stray_native_files(dir: &Path) {
for path in fs::read_dir(dir.join("sessions")).into_iter().flatten().flatten().map(|e| e.path()).take(200) {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
let stem = name.strip_suffix(".native.json").or_else(|| name.strip_suffix(".dmp"));
if let Some(stem) = stem
&& !path.with_file_name(format!("{stem}.jsonl")).exists()
{
let _ = fs::remove_file(&path);
}
}
}
/// The app is closing normally: its journal goes, and repeat counts are written.
pub fn end_session() {
let Some(s) = STATE.get() else { return };
@@ -514,6 +591,7 @@ pub(crate) fn command_done(s: &Session, journaled: bool, changed: bool, id: &str
base: json!(if crashed { "the model as it was when the command started" } else { "the model after the command" }),
notes: notes.clone(),
fields: serde_json::Map::new(),
attach: Vec::new(),
log_pid: Some(std::process::id()),
};
write_report(state, kind, Some(&p), extra);
@@ -571,6 +649,10 @@ struct Journal {
/// The latest activity's line and text (kept across rebases).
activity: Option<String>,
activity_text: Option<String>,
/// The latest startup step's line and name, and the renderer's line (kept across rebases).
phase: Option<String>,
phase_step: Option<String>,
environment: Option<String>,
}
impl Journal {
@@ -590,6 +672,9 @@ impl Journal {
running_at: None,
activity: None,
activity_text: None,
phase: None,
phase_step: None,
environment: None,
};
j.rebase(&json!("new"), 0);
j.document = None;
@@ -621,7 +706,7 @@ impl Journal {
let h = self.header.clone();
self.write(&h);
self.record(&json!({ "base": base }));
if let Some(line) = self.activity.clone() {
for line in [self.activity.clone(), self.phase.clone(), self.environment.clone()].into_iter().flatten() {
self.write(&line);
}
}
@@ -680,6 +765,10 @@ struct JournalRead {
reported: bool,
/// The latest activity outside commands.
activity: Option<String>,
/// The latest startup step and how many milliseconds after the start it began.
phase: Option<(String, u64)>,
/// The renderer, once known.
environment: Option<String>,
}
// Read through the lock-owning file object: on Windows an independently opened handle cannot
@@ -713,6 +802,14 @@ fn parse_journal(text: &str) -> JournalRead {
r.activity = Some(a.to_string());
continue;
}
if let Some(p) = v.get("phase").and_then(Value::as_str) {
r.phase = Some((p.to_string(), v.get("ms").and_then(Value::as_u64).unwrap_or(0)));
continue;
}
if let Some(e) = v.get("environment").and_then(Value::as_str) {
r.environment = Some(e.to_string());
continue;
}
r.reported = v.get("reported").is_some();
if let Some(b) = v.get("base") {
r.base = b.clone();
@@ -766,6 +863,8 @@ fn dead_journals(dir: &Path) -> Vec<PathBuf> {
fn unclean_exit(dir: &Path, build: &Build, journal: &Path) {
let j = read_journal(journal);
let (native_file, dump_file) = native_paths_for(journal);
let native = fs::read(&native_file).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()).filter(Value::is_object);
if j.reported {
return;
}
@@ -773,17 +872,33 @@ fn unclean_exit(dir: &Path, build: &Build, journal: &Path) {
let when = j.header.get("start").and_then(Value::as_str).unwrap_or("?").to_string();
let pid = j.header.get("pid").and_then(Value::as_u64).and_then(|p| u32::try_from(p).ok());
let doing = j.in_flight.as_ref().and_then(|c| c.get("command")).and_then(Value::as_str).map(str::to_string);
let during = match (&doing, &j.activity) {
(Some(c), _) => format!("while running `{c}`"),
(None, Some(a)) => format!("between commands (last: {a})"),
(None, None) => "between commands".to_string(),
// A run that never got to drawing died while starting up: say at which step.
let starting = j.phase.as_ref().filter(|(p, _)| p != RUNNING).cloned();
let during = match (&starting, &doing, &j.activity) {
(Some((p, ms)), ..) => format!("during startup ({p}, {:.1} s after launch)", *ms as f64 / 1000.0),
(None, Some(c), _) => format!("while running `{c}`"),
(None, None, Some(a)) => format!("between commands (last: {a})"),
(None, None, None) => "between commands".to_string(),
};
let p = Panic {
message: format!("FieldCraft {was} (started {when}) ended without a clean exit {during}"),
location: format!("unclean exit in {}", doing.as_deref().unwrap_or("(between commands)")),
thread: "(unknown)".into(),
backtrace: Vec::new(),
signature: signature("unclean exit", doing.as_deref().or(j.activity.as_deref()).unwrap_or("idle")),
// What the run was doing, for the signature: the step, the command or the last activity.
let what = starting
.as_ref()
.map(|(p, _)| format!("startup: {p}"))
.or_else(|| doing.clone())
.or_else(|| j.activity.clone())
.unwrap_or_else(|| "idle".into());
let p = match &native {
Some(n) => native_panic(n, &was, &when, &during, &what),
None => Panic {
message: format!("FieldCraft {was} (started {when}) ended without a clean exit {during}"),
location: match (&starting, &doing) {
(Some((p, _)), _) => format!("unclean exit during startup: {p}"),
(None, c) => format!("unclean exit in {}", c.as_deref().unwrap_or("(between commands)")),
},
thread: "(unknown)".into(),
backtrace: Vec::new(),
signature: signature("unclean exit", &what),
},
};
// A temporary state just for this report (the app's own is not set up yet).
let state = State {
@@ -799,10 +914,14 @@ fn unclean_exit(dir: &Path, build: &Build, journal: &Path) {
running_at: None,
activity: None,
activity_text: None,
phase: None,
phase_step: None,
environment: None,
}),
known: Mutex::new(list(dir).into_iter().filter_map(|s| sig_of(&s.id).map(|sig| (sig, (0, s.id)))).collect()),
written: AtomicUsize::new(0),
started: now(),
started_ms: now_ms(),
};
if let Ok(mut k) = state.known.lock()
&& let Some(e) = k.get_mut(&p.signature)
@@ -813,7 +932,39 @@ fn unclean_exit(dir: &Path, build: &Build, journal: &Path) {
bump(dir, &id, n);
return;
}
write_report(&state, "unclean_exit", Some(&p), from_journal(j, pid));
let mut extra = from_journal(j, pid);
if let Some(n) = native {
extra.fields.insert("native".into(), n);
if dump_file.is_file() {
extra.attach.push(("minidump".into(), dump_file, MINIDUMP_FILE.into()));
}
}
write_report(&state, "unclean_exit", Some(&p), extra);
}
/// The report of a crash a native exception filter caught (`native`: what it wrote, untrusted:
/// every field is cut to length and may be missing).
fn native_panic(native: &Value, was: &str, when: &str, during: &str, what: &str) -> Panic {
let field = |k: &str| native.get(k).and_then(Value::as_str).map(|v| limited(v, 300));
let code = field("code").unwrap_or_else(|| "?".into());
let name = field("name").unwrap_or_else(|| "exception".into());
let module = field("module").unwrap_or_else(|| "(unknown module)".into());
let short = module.rsplit(['\\', '/']).next().unwrap_or(&module).to_string();
let offset = field("offset").unwrap_or_else(|| "?".into());
let at = format!("{short}+{offset}");
let mut backtrace = vec![format!("{code} {name}{}", field("detail").map(|d| format!(" ({d})")).unwrap_or_default())];
backtrace.push(format!("at {module}+{offset} (address {})", field("address").unwrap_or_else(|| "?".into())));
if let Some(t) = native.get("thread").and_then(Value::as_u64) {
backtrace.push(format!("on thread {t}"));
}
Panic {
message: format!("FieldCraft {was} (started {when}) crashed with {name} ({code}) in {at} {during}"),
location: format!("native exception in {at}"),
thread: native.get("thread").and_then(Value::as_u64).map_or_else(|| "(unknown)".into(), |t| format!("native thread {t}")),
backtrace,
// The place: the module and the offset into it (signature() ignores the digits).
signature: signature("native exception", &format!("{code} {short} {what}")),
}
}
// ---------------------------------------------------------------------------------------------
@@ -826,8 +977,10 @@ struct Extra {
recent: Vec<Value>,
base: Value,
notes: Vec<String>,
/// More top-level fields for the report (`last_activity`).
/// More top-level fields for the report (`last_activity`, `startup_phase`, `native`).
fields: serde_json::Map<String, Value>,
/// Files moved into the report's folder: (key in `files`, source, name in the folder).
attach: Vec<(String, PathBuf, String)>,
/// The process whose log lines go into the report.
log_pid: Option<u32>,
}
@@ -874,6 +1027,12 @@ fn from_journal(j: JournalRead, log_pid: Option<u32>) -> Extra {
if let Some(a) = j.activity {
fields.insert("last_activity".into(), json!(a));
}
if let Some((p, ms)) = j.phase.filter(|(p, _)| p != RUNNING) {
fields.insert("startup_phase".into(), json!({ "step": p, "ms": ms }));
}
if let Some(e) = j.environment {
fields.insert("environment".into(), json!(e));
}
Extra {
command: j.in_flight,
model,
@@ -885,6 +1044,7 @@ fn from_journal(j: JournalRead, log_pid: Option<u32>) -> Extra {
},
notes,
fields,
attach: Vec::new(),
log_pid,
}
}
@@ -928,6 +1088,12 @@ fn write_report(state: &State, kind: &str, p: Option<&Panic>, extra: Extra) -> O
{
files.insert("log".into(), json!("log.txt"));
}
for (key, from, name) in &extra.attach {
let to = folder.join(name);
if fs::rename(from, &to).is_ok() || fs::copy(from, &to).is_ok() {
files.insert(key.clone(), json!(name));
}
}
let reproduce = files.contains_key("replay").then(|| format!("fieldcraft-cli crashes replay {id}"));
let mut report = json!({
"format": FORMAT,
@@ -953,6 +1119,13 @@ fn write_report(state: &State, kind: &str, p: Option<&Panic>, extra: Extra) -> O
});
if let Some(r) = report.as_object_mut() {
for (k, v) in extra.fields {
// The renderer goes with the system it ran on.
if k == "environment"
&& let Some(system) = r.get_mut("system").and_then(Value::as_object_mut)
{
system.insert(k, v);
continue;
}
r.entry(k).or_insert(v);
}
}
@@ -1076,7 +1249,7 @@ pub fn read(dir: &Path, id: &str) -> std::result::Result<Value, String> {
let folder = dir.join(id);
let mut v = read_report_file(&folder.join("report.json")).ok_or_else(|| format!("no crash report `{id}`"))?;
v["path"] = json!(folder.to_string_lossy());
for (k, f) in [("model_path", MODEL_FILE), ("replay_path", "replay.json"), ("log_path", "log.txt")] {
for (k, f) in [("model_path", MODEL_FILE), ("replay_path", "replay.json"), ("log_path", "log.txt"), ("minidump_path", MINIDUMP_FILE)] {
if folder.join(f).is_file() {
v[k] = json!(folder.join(f).to_string_lossy());
}
+106
View File
@@ -37,6 +37,24 @@ fn child() {
end_session();
return;
}
// Dying while starting up, before any command: in a graphics driver, with or without what
// a native crash handler caught.
if mode == "startup" || mode == "native" {
phase("window");
phase("adapter request");
set_environment("Renderer: Vulkan Test GPU (driver 1.2.3)");
phase("device creation");
if mode == "native" {
let (info, dump) = native_paths().unwrap();
fs::write(
info,
r#"{"code":"0xC0000005","name":"EXCEPTION_ACCESS_VIOLATION","detail":"read of 0x0000000000000010","address":"0x00007FFB12345678","module":"C:\\Windows\\System32\\DriverStore\\igvk64.dll","offset":"0x64EA72","thread":4242}"#,
)
.unwrap();
fs::write(dump, b"MDMP fake").unwrap();
}
std::process::abort();
}
let mut s = Session { crash_journal: true, ..Default::default() };
s.execute("mesh.node.add", &node(1)).unwrap();
s.execute("mesh.node.add", &node(2)).unwrap();
@@ -275,3 +293,91 @@ fn reports_are_pruned_to_the_limits() {
assert_eq!(clear(&d, None), MAX_REPORTS);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_crash_while_starting_up_names_the_step_and_the_renderer() {
let d = fresh_dir("startup");
let (_, ok) = run_child(&d, "startup");
assert!(!ok, "the child aborted");
let (_, ok) = run_child(&d, "idle");
assert!(ok);
let (s, v) = only_report(&d);
assert_eq!(s.kind, "unclean_exit");
assert!(s.message.contains("during startup (device creation"), "{}", s.message);
assert_eq!(v["location"], "unclean exit during startup: device creation");
assert_eq!(v["startup_phase"]["step"], "device creation");
assert_eq!(v["system"]["environment"], "Renderer: Vulkan Test GPU (driver 1.2.3)", "{v}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_native_crash_is_reported_with_the_module_the_step_and_the_minidump() {
let d = fresh_dir("native");
let (_, ok) = run_child(&d, "native");
assert!(!ok, "the child aborted");
let (_, ok) = run_child(&d, "idle");
assert!(ok);
let (s, v) = only_report(&d);
assert_eq!(s.kind, "unclean_exit");
assert!(s.message.contains("EXCEPTION_ACCESS_VIOLATION (0xC0000005) in igvk64.dll+0x64EA72 during startup (device creation"), "{}", s.message);
assert_eq!(v["location"], "native exception in igvk64.dll+0x64EA72");
assert_eq!(v["native"]["thread"], 4242);
let bt: Vec<&str> = v["backtrace"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
assert_eq!(bt[0], "0xC0000005 EXCEPTION_ACCESS_VIOLATION (read of 0x0000000000000010)");
assert!(bt[1].contains("igvk64.dll+0x64EA72"), "{bt:?}");
assert_eq!(fs::read(v["minidump_path"].as_str().unwrap()).unwrap(), b"MDMP fake");
assert_eq!(v["files"]["minidump"], MINIDUMP_FILE);
// Nothing is left behind next to the journals.
let left: Vec<_> = fs::read_dir(d.join("sessions")).unwrap().flatten().map(|e| e.file_name()).collect();
assert!(left.iter().all(|n| n.to_string_lossy().ends_with(".jsonl")), "{left:?}");
// The same crash again is counted, not written again.
let _ = run_child(&d, "native");
let _ = run_child(&d, "idle");
let (s, _) = only_report(&d);
assert_eq!(s.count, 2);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn native_crash_files_without_their_journal_are_removed() {
let d = fresh_dir("stray");
fs::create_dir_all(d.join("sessions")).unwrap();
fs::write(d.join("sessions/20261015-000000-1-0.native.json"), "{}").unwrap();
fs::write(d.join("sessions/20261015-000000-1-0.dmp"), "MDMP").unwrap();
let (_, ok) = run_child(&d, "idle");
assert!(ok);
assert!(fs::read_dir(d.join("sessions")).unwrap().next().is_none());
assert!(list(&d).is_empty());
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_hostile_native_file_still_makes_a_bounded_report() {
// Whatever the filter left (cut short, wrong types, huge strings) is data, never trusted.
for native in [json!({}), json!({"code": 5, "module": ["x"], "thread": "t"}), json!({"code": "x".repeat(100_000), "module": "y".repeat(100_000)})]
{
let p = native_panic(&native, "1.0", "then", "between commands", "idle");
assert!(p.message.len() < 2_000 && p.backtrace.iter().all(|f| f.len() < 1_000), "{}", p.message.len());
assert!(p.location.starts_with("native exception in "));
}
}
#[test]
fn startup_steps_survive_a_rebase_and_running_ends_startup() {
let d = fresh_dir("rebase");
fs::create_dir_all(d.join("sessions")).unwrap();
let mut j = Journal::open(&d, &build(), now());
j.phase = Some(json!({"phase": "first frame", "ms": 5}).to_string());
j.environment = Some(json!({"environment": "Renderer: X"}).to_string());
j.rebase(&json!("new"), 1);
// Read through the journal's own handle, as `read_live_journal` does (Windows).
let r = read_journal_file(j.file.as_ref().unwrap().try_clone().unwrap());
assert_eq!(r.phase, Some(("first frame".to_string(), 5)));
assert_eq!(r.environment.as_deref(), Some("Renderer: X"));
j.write(&json!({"phase": RUNNING, "ms": 9}).to_string());
let r = read_journal_file(j.file.as_ref().unwrap().try_clone().unwrap());
assert_eq!(r.phase, Some((RUNNING.to_string(), 9)));
assert!(from_journal(r, None).fields.get("startup_phase").is_none(), "a running app is past startup");
drop(j);
let _ = fs::remove_dir_all(&d);
}
+13
View File
@@ -295,6 +295,9 @@ pub struct Session {
/// sets it on its own model (and the control channel and the command search while they
/// run); `None` (scratch sessions, the CLI, tests) counts nothing.
pub origin: Option<usage::Origin>,
/// 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>>,
}
impl Session {
@@ -545,6 +548,16 @@ mod tests {
json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": vec!["x".repeat(1000); 2000]}),
json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": {}}),
json!({"path": 7}),
json!({"path": "/no/such/dir/out.vtu", "step": "Nope"}),
json!({"path": "/no/such/dir/out.vtu", "mode": 0}),
json!({"path": "/no/such/dir/out.pvd", "mode": 1_000_000_000}),
json!({"path": "/no/such/dir/out.pvd", "frames": 3, "encoding": []}),
json!({"path": "x\u{0}.vtu"}),
json!({"path": format!("/no/such/{}.vtu", "x".repeat(100_000))}),
json!({"path": "x\u{0}.stl"}),
json!({"path": "/no/such/dir/skin.stl", "set": 7}),
json!({"path": "/no/such/dir/skin.stl", "set": "x".repeat(100_000)}),
json!({"path": "/no/such/dir/skin.stl", "format": ["ascii"]}),
// (`max_points` and `max_passes` keep mesh.tet.generate's quality stage small.)
json!({"body": "Body1", "tolerance": 0.05, "max_points": 3000, "max_passes": 1}),
json!({"body": null, "tolerance": "fine"}),
+21
View File
@@ -32,6 +32,12 @@ pub enum Requirement {
DatumPoint,
/// A partition of the design.
Partition,
/// A face merge.
FaceMerge,
/// A stitch.
Stitch,
/// A face split.
FaceSplit,
/// An edge seed.
Seed,
/// At least one mesh node.
@@ -79,6 +85,9 @@ impl Requirement {
Requirement::DatumAxis => m.datums().values().any(|d| d.axis().is_some()),
Requirement::DatumPoint => m.datums().values().any(|d| d.point().is_some()),
Requirement::Partition => !m.partitions().is_empty(),
Requirement::FaceMerge => !m.face_merges().is_empty(),
Requirement::Stitch => !m.stitches().is_empty(),
Requirement::FaceSplit => !m.face_splits().is_empty(),
Requirement::Seed => !m.seeds().is_empty(),
Requirement::Nodes => !m.nodes().is_empty(),
Requirement::Mesh => has_elements,
@@ -119,6 +128,9 @@ impl Requirement {
Requirement::DatumAxis => "a datum axis",
Requirement::DatumPoint => "a datum point",
Requirement::Partition => "a partition",
Requirement::FaceMerge => "a face merge",
Requirement::Stitch => "a stitch",
Requirement::FaceSplit => "a face split",
Requirement::Seed => "an edge seed",
Requirement::Nodes => "mesh nodes",
Requirement::Mesh | Requirement::Analysable => "a mesh",
@@ -149,6 +161,9 @@ impl Requirement {
Requirement::DatumAxis => "geometry.datum.axis",
Requirement::DatumPoint => "geometry.datum.point",
Requirement::Partition => "geometry.partition",
Requirement::FaceMerge => "geometry.faces.merge",
Requirement::Stitch => "geometry.stitch",
Requirement::FaceSplit => "geometry.face.split",
Requirement::Seed => "mesh.seed.edge",
Requirement::Nodes => "mesh.node.add",
Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate",
@@ -206,6 +221,9 @@ impl crate::schema::Collection {
C::DatumAxis => Requirement::DatumAxis,
C::DatumPoint => Requirement::DatumPoint,
C::Partition => Requirement::Partition,
C::FaceMerge => Requirement::FaceMerge,
C::Stitch => Requirement::Stitch,
C::FaceSplit => Requirement::FaceSplit,
}
}
}
@@ -346,6 +364,9 @@ mod tests {
C::DatumAxis,
C::DatumPoint,
C::Partition,
C::FaceMerge,
C::Stitch,
C::FaceSplit,
];
let empty = Session::default();
for c in all {
+108 -21
View File
@@ -740,32 +740,119 @@ mod tests {
}
}
/// A partition the kernel fails to make is reported as the kernel's failure, never as a plane
/// that does not divide the body (#283): x = 148 plainly crosses the crank's half y > 0, whose
/// cut fails in the kernel's boolean, and the same plane cuts the half y < 0.
/// The crank's half y > 0 divides at x = 110 … 148 and by the diagonal plane through
/// (125, 2) as its mirror half does (#283): the oversized cutting boxes once made the kernel's
/// boolean fail there, reported as a plane that does not divide. Each cut leaves two cells
/// whose volumes add up to the half's (within the measure's tessellation).
#[test]
fn a_kernel_failure_to_partition_is_reported_as_such() {
use fieldcraft_geometry::Cut;
let design = fieldcraft_geometry::open_design(&fieldcraft_geometry::script_design(CRANK_ARM).unwrap()).unwrap();
let cut = |name: &str, point: [f64; 3], normal: [f64; 3], bodies: &[&str]| Cut {
name: name.into(),
point,
normal,
bodies: bodies.iter().map(|b| b.to_string()).collect(),
fn the_crank_half_divides_where_its_mirror_does() {
let mut s = open("crank-mirror");
let volume = |s: &mut Session, body: &str| -> f64 {
let r = s.execute("geometry.bodies", &json!({})).unwrap();
let b = r.as_array().unwrap().iter().find(|b| b["name"] == body).unwrap_or_else(|| panic!("{body}: {r}"));
b["volume"].as_f64().unwrap_or_else(|| panic!("{b}"))
};
let half = cut("Half", [0.0; 3], [0.0, 1.0, 0.0], &[]);
let p = design.partitioned(&[half.clone(), cut("X148", [148.0, 0.0, 0.0], [1.0, 0.0, 0.0], &[CRANK_HALF])]).unwrap();
assert!(p.errors.is_empty(), "{:?}", p.errors);
let p = design.partitioned(&[half, cut("X148", [148.0, 0.0, 0.0], [1.0, 0.0, 0.0], &["CrankArm/b"])]).unwrap();
// Should the kernel learn to make this cut, the cut is all there is to check.
if p.cuts[1].split.is_empty() {
let errors: Vec<&str> = p.errors.iter().filter(|e| e.feature == "partition X148").map(|e| e.message.as_str()).collect();
assert!(matches!(errors.as_slice(), [m] if m.starts_with("the kernel could not cut `CrankArm/b`")), "{errors:?}");
} else {
assert_eq!(p.cuts[1].split, ["CrankArm/b"]);
let half = volume(&mut s, "CrankArm/b");
let (sin, cos) = (32f64.to_radians().sin(), 32f64.to_radians().cos());
for (name, by) in [
("X110", json!({"type": "offset", "plane": "yz", "offset": 110})),
("X148", json!({"type": "offset", "plane": "yz", "offset": 148})),
("X148R", json!({"type": "point_normal", "point": [148, 0, 0], "normal": [-1, 0, 0]})),
("Diag", json!({"type": "point_normal", "point": [125, 2, 0], "normal": [sin, -cos, 0]})),
] {
s.execute("geometry.datum", &json!({"name": name, "by": by})).unwrap();
let r = s.execute("geometry.partition", &json!({"name": name, "datum": name, "bodies": ["CrankArm/b"]})).unwrap();
assert_eq!(r["cells"], json!(["CrankArm/b/a", "CrankArm/b/b"]), "{name}: {r}");
let cells = volume(&mut s, "CrankArm/b/a") + volume(&mut s, "CrankArm/b/b");
assert!((cells - half).abs() < 1e-5 * half, "{name}: {cells} against {half}");
s.execute("geometry.partition.delete", &json!({"name": name})).unwrap();
}
}
/// A cut at x = 130 runs along the pocket's end wall (#427), and the kernel only makes it
/// with its cutting box nudged 1.3 µm and 2 µrad off the plane: on the half y > 0 that left a
/// cell bounded by slivers next to the wall, which `mesh.cells` refused with an open boundary
/// at x = 130. The partition refuses such a cut, saying why, on the whole crank and on the
/// half, and leaves the body whole; the plane moved past the pocket (x = 136) cuts the half
/// into cells that mesh.
#[test]
fn a_cut_the_kernel_can_only_nudge_off_its_plane_is_refused() {
let datum = |name: &str, x: f64| json!({"name": name, "by": {"type": "offset", "plane": "yz", "offset": x}});
let refused = |s: &mut Session, body: &str| {
let e = s.execute("geometry.partition", &json!({"name": "X130", "datum": "X130", "bodies": [body]})).unwrap_err().to_string();
assert!(e.contains(&format!("could not cut `{body}`")) && e.contains("only made off the plane"), "{e}");
};
let names = |s: &mut Session| -> Vec<String> {
let r = s.execute("geometry.bodies", &json!({})).unwrap();
r.as_array().unwrap().iter().filter_map(|b| b["name"].as_str().map(String::from)).collect()
};
let bytes = fieldcraft_geometry::script_design(CRANK_ARM).unwrap();
let (_dir, path) = crate::tests::design_file(&bytes);
let mut whole = Session::default();
whole.execute("geometry.open", &json!({"path": path})).unwrap();
whole.execute("geometry.datum", &datum("X130", 130.0)).unwrap();
refused(&mut whole, "CrankArm");
assert_eq!(names(&mut whole), ["CrankArm"]);
let mut s = open("crank-mirror");
s.execute("geometry.datum", &datum("X130", 130.0)).unwrap();
refused(&mut s, "CrankArm/b");
assert_eq!(names(&mut s), ["CrankArm/a", "CrankArm/b"]);
s.execute("geometry.datum", &datum("X136", 136.0)).unwrap();
let r = s.execute("geometry.partition", &json!({"name": "X136", "datum": "X136", "bodies": ["CrankArm/b"]})).unwrap();
assert_eq!(r["cells"], json!(["CrankArm/b/a", "CrankArm/b/b"]), "{r}");
s.execute("mesh.cells", &json!({"bodies": ["CrankArm/b/a", "CrankArm/b/b"], "default": "extrude", "size": 3})).unwrap();
}
/// The whole crank extruded all-hex (#198): its back pocket's floor is a rounded rectangle,
/// straight sides running tangent into corner arcs, so the region is a disk whose O-grid's
/// middle block follows its 6:1 elongation; a square middle block squashed its elements to a
/// scaled Jacobian of 0.16 (now 0.39 at 2 mm, the worst elements elsewhere at 3 and 5 mm).
#[test]
fn the_crank_pocket_floor_keeps_its_elements_square() {
let bytes = fieldcraft_geometry::script_design(CRANK_ARM).unwrap();
let (_dir, path) = crate::tests::design_file(&bytes);
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path})).unwrap();
for size in [2.0, 3.0, 5.0] {
let r = s.execute("mesh.hex.generate", &json!({"body": "CrankArm", "size": size, "method": "extrude"})).unwrap();
let min = r["bodies"][0]["quality"]["min_scaled_jacobian"].as_f64().unwrap();
assert!(min > 0.35, "{size}: {r}");
}
}
/// The one-to-one sweep and the crank (#444). The arm's profile extruded alone is a
/// one-to-one sweep (one source, one target) and sweeps all-hex as well as extrude does. The
/// crank itself ends at several levels (pocket floor at 8 mm, arm 16, pedal boss 20, spindle
/// boss 22), so no one-to-one sweep takes it: the sweep says so, naming a cap only part of a
/// ring reaches, where it reported a "linking face with no top side"; extrude meshes it.
#[test]
fn the_sweep_takes_the_arm_profile_and_names_the_crank_levels() {
let session = |script: &str| {
let bytes = fieldcraft_geometry::script_design(script).unwrap();
let (dir, path) = crate::tests::design_file(&bytes);
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path})).unwrap();
(dir, s)
};
let quality = |s: &mut Session, method: &str| -> crate::Result<f64> {
let r = s.execute("mesh.hex.generate", &json!({"body": "CrankArm", "size": 3, "method": method}))?;
assert_eq!(r["bodies"][0]["method"], method, "{r}");
Ok(r["bodies"][0]["quality"]["min_scaled_jacobian"].as_f64().unwrap())
};
// The script up to the arm's extrusion: the profile alone.
let end = CRANK_ARM.find("{\"command\": \"solid.cylinder\"").unwrap();
let profile = format!("{}]}}", CRANK_ARM[..end].trim_end().trim_end_matches(','));
let (_dir, mut s) = session(&profile);
let (sweep, extrude) = (quality(&mut s, "sweep").unwrap(), quality(&mut s, "extrude").unwrap());
assert!(sweep > 0.3 && sweep > 0.95 * extrude, "sweep {sweep}, extrude {extrude}");
let (_dir, mut s) = session(CRANK_ARM);
let e = quality(&mut s, "sweep").unwrap_err().to_string();
assert!(e.contains("is parallel to the source and only part of the ring reaches it") && e.contains("method `extrude`"), "{e}");
assert!(quality(&mut s, "extrude").unwrap() > 0.35);
}
/// `file.sample NAME`, checked clean: ready, no errors or warnings.
fn open(name: &str) -> Session {
let mut s = Session::default();
+318 -9
View File
@@ -97,9 +97,11 @@ pub enum Kind {
},
/// A list of mesh labels, nodes or elements (at most [`MAX_LABELS`]).
Labels(LabelOf),
/// A list of points `[x, y, z]`, at most `max`.
/// A list of points `[x, y, z]`, or of number rows with these `columns` (a load path's
/// `[fraction, factor]` pairs), at most `max`.
Points {
dim: Dim,
columns: &'static [&'static str],
max: usize,
},
/// Any of these values, as a list: (the value as JSON text, label), e.g. `("1", "UX")` or
@@ -110,6 +112,34 @@ pub enum Kind {
dim: Dim,
min: Option<f64>,
},
/// One number for every direction, or one per axis (`labels`, e.g. the material axes
/// 1, 2, 3), each at least `min`.
PerAxis {
dim: Dim,
min: Option<f64>,
labels: &'static [&'static str],
},
/// A list of `min_len` to `labels.len()` positive numbers, one per label in order (a block's
/// divisions: 3 in 3D, 2 in 2D); `whole` numbers only when set.
Tuple {
labels: &'static [&'static str],
min_len: usize,
whole: bool,
},
/// A name (`name`, a [`Kind::Ref`] or [`Kind::Choice`] whose key is unused) as a bare
/// string, or an object of `fields` (`object` labels that choice): a mirror plane is a datum
/// plane's name or a point and a normal.
NameOr {
name: &'static Field,
object: &'static str,
fields: &'static [Field],
},
/// A text per item: an object from the names of a collection's items to a string; an item
/// left out takes the command's default.
RefText(Collection),
/// A list of element faces or edges, `{"element", "face"}` or `{"element", "edge"}` (at most
/// [`MAX_LABELS`]).
Sides(Side),
/// Any JSON: what the schema does not describe.
Json,
}
@@ -121,6 +151,23 @@ pub enum LabelOf {
Element,
}
/// What a [`Kind::Sides`] list holds: element faces or element edges.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Face,
Edge,
}
impl Side {
/// The key of the local number in each entry, and its word.
pub fn key(self) -> &'static str {
match self {
Side::Face => "face",
Side::Edge => "edge",
}
}
}
impl LabelOf {
pub fn label(self) -> &'static str {
match self {
@@ -170,6 +217,12 @@ pub enum Collection {
/// A datum point.
DatumPoint,
Partition,
/// A face merge (`geometry.faces.merge`).
FaceMerge,
/// A stitch (`geometry.stitch`).
Stitch,
/// A face split (`geometry.face.split`).
FaceSplit,
}
impl Collection {
@@ -195,6 +248,9 @@ impl Collection {
Collection::DatumAxis => "datum axis",
Collection::DatumPoint => "datum point",
Collection::Partition => "partition",
Collection::FaceMerge => "face merge",
Collection::Stitch => "stitch",
Collection::FaceSplit => "face split",
}
}
}
@@ -224,6 +280,9 @@ impl Session {
Collection::DatumAxis => Box::new(m.datums().iter().filter(|(_, d)| d.axis().is_some()).map(|(n, _)| n.as_str())),
Collection::DatumPoint => Box::new(m.datums().iter().filter(|(_, d)| d.point().is_some()).map(|(n, _)| n.as_str())),
Collection::Partition => Box::new(m.partitions().iter().map(|p| p.name.as_str())),
Collection::FaceMerge => Box::new(m.face_merges().iter().map(|f| f.name.as_str())),
Collection::Stitch => Box::new(m.stitches().iter().map(|f| f.name.as_str())),
Collection::FaceSplit => Box::new(m.face_splits().iter().map(|f| f.name.as_str())),
};
names.take(MAX_NAMES).map(str::to_string).collect()
}
@@ -388,7 +447,11 @@ impl Field {
/// Its physical dimension, for a number or a vector of numbers.
pub fn dim(&self) -> Option<Dim> {
match self.kind {
Kind::Number { dim, .. } | Kind::Vector { dim, .. } | Kind::Points { dim, .. } | Kind::NumberOrAuto { dim, .. } => Some(dim),
Kind::Number { dim, .. }
| Kind::Vector { dim, .. }
| Kind::Points { dim, .. }
| Kind::NumberOrAuto { dim, .. }
| Kind::PerAxis { dim, .. } => Some(dim),
_ => None,
}
}
@@ -489,9 +552,19 @@ pub const fn rows(key: &'static str, label: &'static str, fields: &'static [Fiel
pub const fn labels(key: &'static str, label: &'static str, of: LabelOf) -> Field {
field(key, label, Kind::Labels(of))
}
/// A list of element faces or edges.
pub const fn sides(key: &'static str, label: &'static str, side: Side) -> Field {
field(key, label, Kind::Sides(side))
}
/// The columns of a point, `[x, y, z]`.
pub const XYZ: &[&str] = &["x", "y", "z"];
/// A list of points `[x, y, z]`.
pub const fn points(key: &'static str, label: &'static str, dim: Dim, max: usize) -> Field {
field(key, label, Kind::Points { dim, max })
field(key, label, Kind::Points { dim, columns: XYZ, max })
}
/// A table: a list of number rows with these columns.
pub const fn table(key: &'static str, label: &'static str, dim: Dim, columns: &'static [&'static str], max: usize) -> Field {
field(key, label, Kind::Points { dim, columns, max })
}
/// Any of `options` (each a value as JSON text, and a label), as a list.
pub const fn multi(key: &'static str, label: &'static str, options: &'static [(&'static str, &'static str)]) -> Field {
@@ -501,6 +574,22 @@ pub const fn multi(key: &'static str, label: &'static str, options: &'static [(&
pub const fn number_or_auto(key: &'static str, label: &'static str, dim: Dim, min: Option<f64>) -> Field {
field(key, label, Kind::NumberOrAuto { dim, min })
}
/// One number at least `min`, or one per axis of `labels`.
pub const fn per_axis(key: &'static str, label: &'static str, dim: Dim, min: Option<f64>, labels: &'static [&'static str]) -> Field {
field(key, label, Kind::PerAxis { dim, min, labels })
}
/// `min_len` to `labels.len()` positive numbers, whole ones when `whole`.
pub const fn tuple(key: &'static str, label: &'static str, labels: &'static [&'static str], min_len: usize, whole: bool) -> Field {
field(key, label, Kind::Tuple { labels, min_len, whole })
}
/// A name as `name` gives it, or an object of `fields` (labelled `object`).
pub const fn name_or(key: &'static str, label: &'static str, name: &'static Field, object: &'static str, fields: &'static [Field]) -> Field {
field(key, label, Kind::NameOr { name, object, fields })
}
/// A text per item of `c`.
pub const fn reference_text(key: &'static str, label: &'static str, c: Collection) -> Field {
field(key, label, Kind::RefText(c))
}
pub const fn json(key: &'static str, label: &'static str) -> Field {
field(key, label, Kind::Json)
}
@@ -896,7 +985,7 @@ pub fn field_at(fields: &'static [Field], path: &str) -> Option<&'static Field>
}
field = match field.kind {
Kind::Rows { .. } if segment.parse::<usize>().is_ok() => field,
Kind::Group(inner) | Kind::Rows { fields: inner, .. } => find(inner, segment)?,
Kind::Group(inner) | Kind::Rows { fields: inner, .. } | Kind::NameOr { fields: inner, .. } => find(inner, segment)?,
Kind::Variants { tag, .. } if tag == segment => field,
Kind::Variants { variants, .. } => variants.iter().find_map(|v| find(v.fields, segment))?,
Kind::Either(options) => options.iter().find(|o| o.key == segment)?,
@@ -991,6 +1080,19 @@ fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec<Problem
}
None => Some(ProblemKind::NotAnObject),
},
Kind::NameOr { name, fields, .. } => match v {
Value::String(_) => value_problem(name, v, path, out, depth + 1),
Value::Object(o) => {
check(fields, o, path, out, depth + 1);
None
}
_ => Some(ProblemKind::Invalid(format!("a {} or an object", name.label.to_lowercase()))),
},
Kind::RefText(_) => match v.as_object() {
Some(o) if o.len() <= MAX_NAMES && o.values().all(Value::is_string) => None,
Some(_) => Some(ProblemKind::NotText),
None => Some(ProblemKind::NotAnObject),
},
Kind::Variants { tag, .. } => match v.as_object() {
Some(o) => match o.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) {
Some(var) => {
@@ -1012,18 +1114,35 @@ fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec<Problem
(!ok).then_some(ProblemKind::NotAChoice)
}
Kind::Unit | Kind::Json => None,
Kind::Sides(side) => match v.as_array() {
Some(a)
if a.len() <= MAX_LABELS
&& a.iter().all(|x| {
x.as_object().is_some_and(|o| {
o.len() == 2
&& o.get("element").and_then(Value::as_u64).is_some_and(|n| n >= 1 && n <= u64::from(u32::MAX))
&& o.get(side.key()).and_then(Value::as_u64).is_some_and(|n| n >= 1 && n <= u64::from(u8::MAX))
})
}) =>
{
None
}
_ => Some(ProblemKind::NotAList { max: MAX_LABELS, what: if side == Side::Face { "element faces" } else { "element edges" } }),
},
Kind::Labels(_) => match v.as_array() {
Some(a) if a.len() <= MAX_LABELS && a.iter().all(|x| x.as_u64().is_some_and(|n| n >= 1 && n <= u64::from(u32::MAX))) => None,
_ => Some(ProblemKind::NotAList { max: MAX_LABELS, what: "labels" }),
},
Kind::Points { max, .. } => match v.as_array() {
Kind::Points { columns, max, .. } => match v.as_array() {
Some(a)
if a.len() <= max
&& a.iter().all(|p| p.as_array().is_some_and(|c| c.len() == 3 && c.iter().all(|x| x.as_f64().is_some_and(f64::is_finite)))) =>
&& a.iter().all(|p| {
p.as_array().is_some_and(|c| c.len() == columns.len() && c.iter().all(|x| x.as_f64().is_some_and(f64::is_finite)))
}) =>
{
None
}
_ => Some(ProblemKind::NotAList { max, what: "points [x, y, z]" }),
_ => Some(ProblemKind::NotAList { max, what: if columns == XYZ { "points [x, y, z]" } else { "rows of numbers" } }),
},
Kind::Multi(options) => match v.as_array() {
Some(a) if a.iter().all(|x| options.iter().any(|(o, _)| multi_value(o) == *x)) => None,
@@ -1035,6 +1154,33 @@ fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec<Problem
(_, Some(_)) => Some(ProblemKind::Below(min.unwrap_or(0.0))),
_ => Some(ProblemKind::Invalid("a number or \"auto\"".into())),
},
Kind::PerAxis { min, labels, .. } => {
let values = match v {
Value::Array(a) if a.len() == labels.len() => a.iter().map(|x| x.as_f64().filter(|x| x.is_finite())).collect::<Option<Vec<_>>>(),
Value::Array(_) => None,
_ => v.as_f64().filter(|x| x.is_finite()).map(|x| vec![x]),
};
match values {
Some(xs) if xs.iter().all(|&x| min.is_none_or(|lo| x >= lo)) => None,
Some(_) => Some(ProblemKind::Below(min.unwrap_or(0.0))),
None => Some(ProblemKind::Invalid(format!("a number, or {} numbers (one per axis)", labels.len()))),
}
}
Kind::Tuple { labels, min_len, whole } => match v.as_array() {
Some(a) if (min_len..=labels.len()).contains(&a.len()) => {
let ok = |x: &Value| x.as_f64().is_some_and(|x| x.is_finite() && x > 0.0 && (!whole || x.fract() == 0.0));
(!a.iter().all(ok)).then_some(if whole {
ProblemKind::Invalid("whole numbers of at least 1".into())
} else {
ProblemKind::NotPositive
})
}
_ => Some(ProblemKind::Invalid(if min_len == labels.len() {
format!("{min_len} numbers")
} else {
format!("{min_len} to {} numbers", labels.len())
})),
},
Kind::Rows { fields, max } => match v.as_array() {
Some(a) if a.len() <= max => {
for (i, row) in a.iter().enumerate() {
@@ -1134,6 +1280,10 @@ fn field_schema(f: &Field, depth: usize) -> Value {
"additionalProperties": {"enum": options.iter().map(|(o, _)| *o).collect::<Vec<_>>()},
}),
Kind::Group(fields) if depth <= MAX_DEPTH => object_schema(fields, depth + 1),
Kind::NameOr { name, fields, .. } if depth <= MAX_DEPTH => {
json!({"oneOf": [field_schema(name, depth + 1), object_schema(fields, depth + 1)]})
}
Kind::RefText(c) => json!({"type": "object", "x-references": c.label(), "additionalProperties": {"type": "string"}}),
Kind::Variants { tag, variants } if depth <= MAX_DEPTH => json!({"oneOf": variant_schemas(tag, variants, depth + 1)}),
Kind::Either(options) if depth <= MAX_DEPTH => json!({"oneOf": options.iter().map(|o| match o.kind {
Kind::Unit => json!({"const": o.key}),
@@ -1141,11 +1291,22 @@ fn field_schema(f: &Field, depth: usize) -> Value {
}).collect::<Vec<_>>()}),
Kind::Rows { fields, max } if depth <= MAX_DEPTH => json!({"type": "array", "items": object_schema(fields, depth + 1), "maxItems": max}),
Kind::Unit => json!({"const": f.key}),
Kind::Sides(side) => json!({
"type": "array",
"maxItems": MAX_LABELS,
"items": {
"type": "object",
"properties": {"element": {"type": "integer", "minimum": 1, "maximum": u32::MAX}, side.key(): {"type": "integer", "minimum": 1, "maximum": u8::MAX}},
"required": ["element", side.key()],
"additionalProperties": false,
},
}),
Kind::Labels(of) => {
json!({"type": "array", "items": {"type": "integer", "minimum": 1, "maximum": u32::MAX}, "maxItems": MAX_LABELS, "x-labels": of.label()})
}
Kind::Points { dim, max } => {
let mut item = json!({"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3});
Kind::Points { dim, columns, max } => {
let n = columns.len();
let mut item = json!({"type": "array", "items": {"type": "number"}, "minItems": n, "maxItems": n, "x-columns": columns});
if dim != Dim::None {
item["x-dimension"] = json!(format!("{dim:?}").to_lowercase());
}
@@ -1154,6 +1315,10 @@ fn field_schema(f: &Field, depth: usize) -> Value {
Kind::Multi(options) => {
json!({"type": "array", "items": {"enum": options.iter().map(|(o, _)| multi_value(o)).collect::<Vec<_>>()}, "uniqueItems": true})
}
Kind::Tuple { labels, min_len, whole } => {
let item = if whole { json!({"type": "integer", "minimum": 1}) } else { json!({"type": "number", "exclusiveMinimum": 0}) };
json!({"type": "array", "items": item, "minItems": min_len, "maxItems": labels.len(), "x-labels": labels})
}
Kind::NumberOrAuto { min, .. } => {
let mut n = json!({"type": "number"});
if let Some(lo) = min {
@@ -1161,6 +1326,18 @@ fn field_schema(f: &Field, depth: usize) -> Value {
}
json!({"oneOf": [n, {"const": "auto"}]})
}
Kind::PerAxis { dim, min, labels } => {
let mut n = json!({"type": "number"});
if let Some(lo) = min {
n["minimum"] = json!(lo);
}
let mut s =
json!({"oneOf": [n.clone(), {"type": "array", "items": n, "minItems": labels.len(), "maxItems": labels.len(), "x-labels": labels}]});
if dim != Dim::None {
s["x-dimension"] = json!(format!("{dim:?}").to_lowercase());
}
s
}
_ => json!({}),
};
s["title"] = json!(f.label);
@@ -1246,6 +1423,88 @@ mod tests {
assert_eq!(s["properties"]["scale"]["oneOf"][1]["const"], "auto");
}
/// A table is a list of number rows of its column count: a load path's [fraction, factor].
#[test]
fn table_rows_have_its_columns() {
const F: &[Field] = &[table("path", "Path", Dim::None, &["fraction", "factor"], 3)];
assert!(problems(F, &json!({"path": [[0, 0], [0.5, 1], [1, 1]]})).is_empty());
for bad in [json!({"path": [[0, 0, 0]]}), json!({"path": [[0]]}), json!({"path": [[0, 0], [0, 0], [0, 0], [1, 1]]})] {
assert_eq!(problems(F, &bad).len(), 1, "{bad}");
}
let s = json_schema(F);
assert_eq!(s["properties"]["path"]["items"]["minItems"], 2);
assert_eq!(s["properties"]["path"]["items"]["x-columns"], json!(["fraction", "factor"]));
}
/// A per-axis field takes one number or one per axis, each at least its minimum.
#[test]
fn per_axis_takes_one_number_or_one_per_axis() {
const F: &[Field] = &[per_axis("k", "K", Dim::Conductivity, Some(0.0), &["1", "2", "3"])];
assert!(problems(F, &json!({"k": 50})).is_empty());
assert!(problems(F, &json!({"k": [50, 20, 20.5]})).is_empty());
for bad in [json!({"k": -1}), json!({"k": [1, 2]}), json!({"k": [1, 2, -3]}), json!({"k": "x"}), json!({"k": [1, 2, "3"]})] {
assert_eq!(problems(F, &bad).len(), 1, "{bad}");
}
let s = json_schema(F);
assert_eq!(s["properties"]["k"]["oneOf"][1]["maxItems"], 3);
}
/// A name-or-object field takes the name's kind as a string or its fields as an object; a
/// text per item takes an object of strings.
#[test]
fn name_or_object_and_text_per_item() {
const NAME: Field = reference("", "Datum plane", Collection::DatumPlane);
const OBJ: &[Field] = &[xyz("point", "Point", Dim::Length).req()];
const F: &[Field] = &[name_or("plane", "Plane", &NAME, "Point", OBJ), reference_text("as", "Images", Collection::Body)];
assert!(problems(F, &json!({"plane": "Sym", "as": {"A": "B"}})).is_empty());
assert!(problems(F, &json!({"plane": {"point": [0, 0, 1]}})).is_empty());
for bad in [json!({"plane": 3}), json!({"plane": {}}), json!({"plane": {"point": [0, 0]}}), json!({"as": {"A": 1}}), json!({"as": "B"})] {
assert_eq!(problems(F, &bad).len(), 1, "{bad}");
}
assert_eq!(problems(F, &json!({"plane": {}})).first().map(|p| p.path.as_str()), Some("plane/point"));
let s = json_schema(F);
assert_eq!(s["properties"]["plane"]["oneOf"][0]["type"], "string");
assert_eq!(s["properties"]["as"]["additionalProperties"]["type"], "string");
}
/// A sides list holds element and local face (or edge) objects, many more than a form's rows.
#[test]
fn sides_hold_element_and_local_number() {
const F: &[Field] = &[sides("faces", "Faces", Side::Face), sides("edges", "Edges", Side::Edge)];
let many: Vec<Value> = (1..=5000).map(|e| json!({"element": e, "face": 3})).collect();
assert!(problems(F, &json!({"faces": many, "edges": [{"element": 2, "edge": 12}]})).is_empty());
for bad in [
json!({"faces": [{"element": 0, "face": 1}]}),
json!({"faces": [{"element": 1, "face": 0}]}),
json!({"faces": [{"element": 1, "edge": 1}]}),
json!({"faces": [{"element": 1, "face": 1, "x": 1}]}),
json!({"edges": [3]}),
] {
assert_eq!(problems(F, &bad).len(), 1, "{bad}");
}
assert_eq!(json_schema(F)["properties"]["edges"]["items"]["required"], json!(["element", "edge"]));
}
/// A tuple takes its minimum to its full length of positive numbers, whole ones when asked.
#[test]
fn tuple_takes_two_or_three_positive_numbers() {
const F: &[Field] = &[tuple("n", "N", &["a", "b", "c"], 2, true), tuple("r", "R", &["a", "b", "c"], 2, false)];
assert!(problems(F, &json!({"n": [2, 3, 4], "r": [1.5, 1]})).is_empty());
for bad in [
json!({"n": [2]}),
json!({"n": [1, 2, 3, 4]}),
json!({"n": [1.5, 2]}),
json!({"n": [0, 2]}),
json!({"r": [1, -1]}),
json!({"r": [1, null]}),
] {
assert_eq!(problems(F, &bad).len(), 1, "{bad}");
}
let s = json_schema(F);
assert_eq!(s["properties"]["n"]["minItems"], 2);
assert_eq!(s["properties"]["n"]["items"]["type"], "integer");
}
#[test]
fn prefill_switch_and_check() {
let p = prefill(LOADISH, &json!({}));
@@ -1313,4 +1572,54 @@ mod tests {
let e = s.execute("results.contour", &json!({"field": "displacement", "deformed": true})).unwrap_err().to_string();
assert!(e.contains("`deformed`") && e.contains("field"), "{e}");
}
/// The top-level keys a params doc names (`"key":` and `"key"?:` directly inside its
/// leading `{…}`; keys of nested objects and of the reply described after it are not).
fn doc_keys(doc: &str) -> Vec<&str> {
let mut keys = Vec::new();
let mut depth = 0usize;
let mut chars = doc.char_indices();
while let Some((i, c)) = chars.next() {
match c {
'{' | '[' => depth += 1,
'}' | ']' => {
depth = depth.saturating_sub(1);
if depth == 0 {
break;
}
}
'"' => {
let rest = &doc[i + 1..];
let Some(end) = rest.find('"') else { break };
let word = &rest[..end];
let tail = &rest[end + 1..];
if depth == 1 && (tail.starts_with(':') || tail.starts_with("?:")) && !word.is_empty() {
keys.push(word);
}
for _ in 0..word.chars().count() + 1 {
chars.next();
}
}
_ => {}
}
}
keys
}
/// A command's params doc names only parameters its form describes, so a documented
/// parameter is never refused as unknown (#471: `mesh.node.relax` listed `set`).
#[test]
fn every_documented_parameter_is_accepted() {
let mut wrong = Vec::new();
for spec in crate::commands() {
let Some(form) = spec.form else { continue };
let keys = accepted_keys(form, &Value::Null);
for key in doc_keys(spec.params) {
if !keys.contains(&key) {
wrong.push(format!("{}: `{key}`", spec.id));
}
}
}
assert!(wrong.is_empty(), "documented but not in the form:\n{}", wrong.join("\n"));
}
}
+777 -38
View File
@@ -14,7 +14,7 @@
use fieldcraft_model::ElementKind;
use fieldcraft_render::contour::{bands_color, split_triangle};
use fieldcraft_render::glyphs::{self, GlyphColors, GlyphShow, GlyphView, Glyphs};
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Orientation, Rgb, Scene, SceneLine, SceneTri, Vec3};
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};
@@ -28,9 +28,12 @@ pub struct Palette {
pub hex: Rgb,
pub tet: Rgb,
pub wedge: Rgb,
pub pyramid: Rgb,
pub shell: Rgb,
pub line_element: Rgb,
pub mesh_edge: Rgb,
/// Nodes, when they are shown.
pub node: Rgb,
pub geometry: Rgb,
pub geometry_edge: Rgb,
/// The bodies of a design with several, in turn.
@@ -41,6 +44,10 @@ pub struct Palette {
pub contour: [Rgb; 5],
/// Values above a clamped legend's maximum.
pub contour_over: Rgb,
/// The elements `mesh.quality.mark` marked.
pub marked: Rgb,
/// Faces merged for meshing (`geometry.faces.merge`).
pub merged: Rgb,
/// The analysis setup's symbols.
pub glyphs: GlyphColors,
}
@@ -49,17 +56,19 @@ pub struct Palette {
pub const CONTOUR_OVER: Rgb = Rgb(0x6e, 0x2c, 0x80);
impl Palette {
/// The desktop app's light theme.
/// The desktop app's light theme (its `Tokens::light().palette()`, a test there checks).
pub const LIGHT: Palette = Palette {
background: (Rgb(0xf0, 0xf3, 0xf7), Rgb(0xc8, 0xd0, 0xdc)),
background: (Rgb(240, 243, 247), Rgb(203, 210, 221)),
hex: Rgb(0x78, 0x9c, 0xc0),
tet: Rgb(0x80, 0xb4, 0x8a),
wedge: Rgb(0xc0, 0xaa, 0x78),
pyramid: Rgb(0x8c, 0x80, 0xb0),
shell: Rgb(0xc0, 0x8c, 0x78),
line_element: Rgb(0xc8, 0x8c, 0x1e),
mesh_edge: Rgb(0x1e, 0x23, 0x2a),
geometry: Rgb(0xb4, 0xba, 0xc4),
geometry_edge: Rgb(0x2a, 0x30, 0x3a),
node: Rgb(0xb4, 0x3c, 0x8c),
geometry: Rgb(176, 186, 198),
geometry_edge: Rgb(40, 44, 52),
bodies: [
Rgb(0x8f, 0xb4, 0xd9),
Rgb(0xe0, 0xa4, 0x6c),
@@ -73,6 +82,8 @@ impl Palette {
no_result: Rgb(0x9a, 0xa0, 0xaa),
contour: [Rgb(0x2b, 0x4f, 0xd8), Rgb(0x2f, 0xb8, 0xe0), Rgb(0x3c, 0xc8, 0x64), Rgb(0xf0, 0xd2, 0x3c), Rgb(0xe0, 0x40, 0x2e)],
contour_over: CONTOUR_OVER,
marked: Rgb(214, 54, 54),
merged: Rgb(222, 150, 30),
glyphs: GlyphColors {
constraint: Rgb(0x00, 0x8a, 0xa8),
load: Rgb(0xd0, 0x24, 0x24),
@@ -85,21 +96,116 @@ impl Palette {
},
};
fn element(&self, kind: ElementKind) -> Rgb {
/// The colour elements of this kind are drawn in.
pub fn element(&self, kind: ElementKind) -> Rgb {
match kind {
ElementKind::Hex8 | ElementKind::Hex20 => self.hex,
ElementKind::Tet4 | ElementKind::Tet10 => self.tet,
ElementKind::Wedge6 => self.wedge,
ElementKind::Pyramid5 => self.pyramid,
ElementKind::Tri3 | ElementKind::Tri6 | ElementKind::Quad4 | ElementKind::Quad8 => self.shell,
ElementKind::Rod2 | ElementKind::Rod3 | ElementKind::Beam2 => self.line_element,
}
}
}
/// Mesh edge, geometry edge and line element widths, in output pixels.
/// How the model is drawn.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Style {
/// Lit faces with the element edges on them.
#[default]
ShadedEdges,
/// Lit faces only.
Shaded,
/// Element edges only.
Wireframe,
}
impl Style {
pub const ALL: [Style; 3] = [Style::ShadedEdges, Style::Shaded, Style::Wireframe];
pub fn label(self) -> &'static str {
match self {
Style::ShadedEdges => "Shaded with edges",
Style::Shaded => "Shaded",
Style::Wireframe => "Wireframe",
}
}
}
/// What is shown (the browser's eye toggles).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Show {
/// The open design and imported surfaces.
pub geometry: bool,
/// Elements.
pub mesh: bool,
pub nodes: bool,
/// Boundary conditions and loads.
pub loads: bool,
/// Reference points, couplings and rigid walls.
pub interactions: bool,
}
impl Show {
/// Geometry, mesh and the analysis setup, without nodes.
pub const DEFAULT: Show = Show { geometry: true, mesh: true, nodes: false, loads: true, interactions: true };
}
/// Mesh edge width, geometry edge width, line element width and node size, in points.
const EDGE_WIDTH: f64 = 1.0;
const GEOMETRY_EDGE_WIDTH: f64 = 1.5;
const LINE_ELEMENT_WIDTH: f64 = 3.0;
const NODE_SIZE: f64 = 5.0;
/// The scene for `display` drawn in `style` with `p`'s colours: the one builder of the window's
/// viewport and the headless pictures, so both draw the same model.
///
/// Geometry (the open design, imported surfaces) draws as faces with their boundaries while
/// the model has no element faces; once it is meshed, as its boundaries drawn along the mesh
/// (the element edges on geometry edges), so the mesh is not hidden behind coincident faces and
/// every line lies on the element faces.
pub fn build(display: &DisplayMesh, style: Style, show: Show, p: &Palette) -> Scene {
let mut s = Scene { background: Some(p.background), ..Default::default() };
// Geometry faces draw where no element faces cover them.
let mesh_faces = show.mesh && !display.tris.is_empty();
if style != Style::Wireframe {
if show.mesh {
s.tris = display.tris.iter().map(|d| SceneTri { p: d.p, normal: d.normal, color: p.element(d.kind) }).collect();
}
if !mesh_faces && show.geometry {
s.tris.extend(display.surface_tris.iter().map(|d| SceneTri {
p: d.p,
normal: d.normal,
color: display.body_color(d, p.geometry, &p.bodies),
}));
}
}
if style != Style::Shaded && show.mesh {
// Without faces behind them the edges take the element colour, so they read on the
// background.
let color = if style == Style::Wireframe { p.hex } else { p.mesh_edge };
s.lines = display.edges.iter().map(|e| SceneLine { a: e.p[0], b: e.p[1], color, width: EDGE_WIDTH }).collect();
}
// Face boundaries in every style: they are the geometry's outline. Over the mesh they are
// the element edges on geometry edges, so they lie exactly on the element faces; the
// geometry's own tessellation lies off them by its chord error.
if show.geometry {
let color = p.geometry_edge;
if mesh_faces {
s.lines.extend(display.edges.iter().filter(|e| e.feature).map(|e| SceneLine { a: e.p[0], b: e.p[1], color, width: GEOMETRY_EDGE_WIDTH }));
} else {
s.lines.extend(display.surface_edges.iter().map(|[a, b]| SceneLine { a: *a, b: *b, color, width: GEOMETRY_EDGE_WIDTH }));
}
}
if show.mesh {
s.lines.extend(display.lines.iter().map(|l| SceneLine { a: l.a, b: l.b, color: p.element(l.kind), width: LINE_ELEMENT_WIDTH }));
}
if show.nodes {
s.points = display.nodes.iter().map(|n| ScenePoint { p: n.p, color: p.node, size: NODE_SIZE }).collect();
}
s
}
/// The chordal tolerance geometry is tessellated with for display: a thousandth of its size.
fn display_tolerance(b: &Aabb3) -> f64 {
@@ -107,6 +213,89 @@ fn display_tolerance(b: &Aabb3) -> f64 {
if d.is_finite() { (d * 1e-3).clamp(1e-3, 1.0) } else { 0.1 }
}
/// The findings of a `geometry.check`, to draw over the design it checked.
#[derive(Clone, Debug, Default)]
pub struct GeometryMarks {
/// The design checked: the marks are stale once another is open.
pub design: std::sync::Weak<fieldcraft_geometry::Design>,
pub marks: Vec<GeometryMark>,
}
/// One finding to draw: its faces, its edge and the point to look at.
#[derive(Clone, Debug, PartialEq)]
pub struct GeometryMark {
/// The faces by body name and face index.
pub faces: Vec<(String, u32)>,
/// A short edge's polyline (empty for the other kinds).
pub path: Vec<[f64; 3]>,
pub at: [f64; 3],
/// A small or sliver face, by body and persistent name: what `geometry.face.absorb`
/// `from_check` absorbs.
pub small: Option<(String, String)>,
}
/// The findings of the last `geometry.check`, while the design it checked is still open.
pub fn geometry_marks(s: &Session) -> Option<&GeometryMarks> {
let marks = s.geometry_marks.as_deref()?;
let design = s.design.as_ref()?;
std::sync::Weak::ptr_eq(&marks.design, &std::sync::Arc::downgrade(design)).then_some(marks)
}
/// Marker line width, and the marker dot's diameter, in output pixels.
const MARK_WIDTH: f64 = 4.0;
const MARK_DOT: f64 = 12.0;
/// Draw `marks` over `scene` in `color`: the faces they name recoloured when `faces` (the
/// scene's triangles start with `display`'s surface triangles in order, as they do while no
/// element faces are drawn; `bodies` names the drawn surfaces in order), short edges as thick
/// lines and a dot at each finding's point, both over everything so a feature too small to see
/// at the zoom, or inside the mesh, still shows where it is.
pub fn draw_geometry_marks(scene: &mut Scene, display: &DisplayMesh, bodies: &[&str], marks: &GeometryMarks, faces: bool, color: Rgb) {
if faces {
let marked: std::collections::BTreeSet<(u32, u32)> = marks
.marks
.iter()
.flat_map(|m| &m.faces)
.filter_map(|(body, f)| bodies.iter().position(|b| b == body).and_then(|b| u32::try_from(b).ok()).map(|b| (b, *f)))
.collect();
for (t, d) in scene.tris.iter_mut().zip(&display.surface_tris) {
if marked.contains(&(d.body, d.face)) {
t.color = color;
}
}
}
for m in &marks.marks {
for w in m.path.windows(2) {
if let [a, b] = w {
scene.on_top.push(SceneLine { a: Vec3::from(*a), b: Vec3::from(*b), color, width: MARK_WIDTH });
}
}
let at = Vec3::from(m.at);
scene.on_top.push(SceneLine { a: at, b: at, color, width: MARK_DOT });
}
}
/// Draw the merged faces (`geometry.faces.merge`) of the drawn bodies (`bodies`, in the order of
/// their surfaces) in `color`, so each shows as one face in one colour. The scene's triangles
/// start with `display`'s surface triangles in order, as they do while no element faces are drawn.
pub fn draw_merged_faces(scene: &mut Scene, display: &DisplayMesh, s: &Session, bodies: &[&str], color: Rgb) {
let Some(design) = s.design.as_deref() else { return };
let merged: std::collections::BTreeSet<(u32, u32)> = bodies
.iter()
.enumerate()
.filter_map(|(i, name)| Some((u32::try_from(i).ok()?, design.body(name)?)))
.flat_map(|(i, b)| b.merged_faces().into_iter().map(move |(f, _)| (i, f)))
.collect();
if merged.is_empty() {
return;
}
for (t, d) in scene.tris.iter_mut().zip(&display.surface_tris) {
if merged.contains(&(d.body, d.face)) {
t.color = color;
}
}
}
/// What a picture shows besides the image.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Shown {
@@ -122,42 +311,143 @@ pub struct Shown {
pub sampled: Vec<String>,
}
/// A section through the mesh: the elements whose centroid lies on the camera's side of a plane
/// normal to an axis are not drawn, so the element faces inside show (an element cut: whole elements, not
/// sliced ones). Geometry surfaces are not drawn over a sectioned mesh.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Section {
/// 0, 1, 2: the plane is normal to X, Y, Z.
pub axis: usize,
/// Where the plane crosses the axis; `None` at the middle of the mesh.
pub at: Option<f64>,
}
impl Section {
/// `x`, `y`, `z` (the middle of the mesh) or `x=12.5`.
pub fn parse(s: &str) -> Option<Section> {
let (axis, at) = match s.split_once('=') {
Some((a, v)) => (a, Some(v.trim().parse::<f64>().ok().filter(|v| v.is_finite())?)),
None => (s, None),
};
let axis = ["x", "y", "z"].iter().position(|n| n.eq_ignore_ascii_case(axis.trim()))?;
Some(Section { axis, at })
}
/// The elements on the side of the plane `toward` points to (the direction toward the eye).
fn beyond(&self, m: &fieldcraft_model::Model, toward: Vec3) -> std::collections::BTreeSet<u32> {
let sign = match [toward.x, toward.y, toward.z].get(self.axis) {
Some(v) if *v < 0.0 => -1.0,
_ => 1.0,
};
let coord = |label: &u32| m.node(*label).and_then(|n| n.xyz.get(self.axis).copied());
let centroid = |nodes: &[u32]| {
let v: Vec<f64> = nodes.iter().filter_map(coord).collect();
(!v.is_empty()).then(|| v.iter().sum::<f64>() / v.len() as f64)
};
let at = self.at.unwrap_or_else(|| {
let (lo, hi) = m
.nodes()
.values()
.filter_map(|n| n.xyz.get(self.axis).copied())
.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), v| (a.min(v), b.max(v)));
if lo <= hi { (lo + hi) * 0.5 } else { 0.0 }
});
m.elements().iter().filter(|(_, e)| centroid(&e.nodes).is_some_and(|c| (c - at) * sign > 0.0)).map(|(l, _)| *l).collect()
}
}
/// The model's display data with the open design's bodies and the imported surfaces.
pub fn display(s: &Session) -> (DisplayMesh, Vec<String>) {
let mut d = DisplayMesh::from_model_hiding(&s.model, &s.hidden_elements());
display_cut(s, None, Vec3::Z)
}
/// [`display`] with a section through the mesh, cut away on the side `toward` points to.
pub fn display_cut(s: &Session, section: Option<Section>, toward: Vec3) -> (DisplayMesh, Vec<String>) {
let (d, failed, _) = display_parts(s, section, toward);
(d, failed)
}
/// [`display_cut`], and the names of the bodies drawn in the order of their surfaces
/// ([`fieldcraft_render::SurfaceTri::body`]).
fn display_parts(s: &Session, section: Option<Section>, toward: Vec3) -> (DisplayMesh, Vec<String>, Vec<String>) {
let mut bodies = Vec::new();
let mut hidden = s.hidden_elements();
let cut = section.filter(|_| !s.model.elements().is_empty());
if let Some(c) = cut {
hidden.extend(c.beyond(&s.model, toward));
}
let mut d = DisplayMesh::from_model_hiding(&s.model, &hidden);
let mut failed = Vec::new();
if cut.is_some() {
return (d, failed, bodies);
}
for body in s.design.iter().flat_map(|d| &d.bodies).filter(|b| s.shows(fieldcraft_io::ItemKind::Body, &b.name)) {
let mesh = body.measure().and_then(|m| body.tessellate(display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() })));
match mesh {
Ok(m) => d.add_surface(&m.positions, &m.triangles, &m.tri_face),
Ok(mut m) => {
// A merged face's B-rep faces were tessellated apart: their copies of the points
// they share differ by up to the weld distance, and would draw as edges.
if !body.merged_faces().is_empty() {
weld_points(&mut m.positions, m.weld_distance);
}
d.add_surface(&m.positions, &m.triangles, &m.tri_face);
bodies.push(body.name.clone());
}
Err(e) => failed.push(format!("{}: {e}", body.name)),
}
}
for surface in s.surfaces.iter().filter(|x| s.shows(fieldcraft_io::ItemKind::Surface, &x.name)) {
d.add_surface(&surface.mesh.positions, &surface.mesh.triangles, &surface.mesh.tri_face);
}
(d, failed)
(d, failed, bodies)
}
/// The model drawn as the viewport draws it (shaded with element edges).
/// Move every point within `distance` of an earlier one (in x order) onto it, so copies of one
/// point are identical.
fn weld_points(positions: &mut [[f64; 3]], distance: f64) {
let mut order: Vec<usize> = (0..positions.len()).collect();
order.sort_by(|&a, &b| positions.get(a).map_or(0.0, |p| p[0]).total_cmp(&positions.get(b).map_or(0.0, |p| p[0])));
let close = |p: [f64; 3], q: [f64; 3]| (0..3).map(|k| (p[k] - q[k]).powi(2)).sum::<f64>() <= distance * distance;
for (k, &i) in order.iter().enumerate() {
let Some(p) = positions.get(i).copied() else { continue };
// The earliest point within reach, among those not far behind in x.
let mut to = p;
for &j in order.get(..k).unwrap_or_default().iter().rev() {
let Some(q) = positions.get(j).copied() else { continue };
if p[0] - q[0] > distance {
break;
}
if close(p, q) {
to = q;
}
}
if let Some(slot) = positions.get_mut(i) {
*slot = to;
}
}
}
/// The model drawn as the viewport draws it by default (shaded with element edges).
fn model_scene(d: &DisplayMesh, p: &Palette) -> Scene {
let mut s = Scene { background: Some(p.background), ..Default::default() };
s.tris = d.tris.iter().map(|t| SceneTri { p: t.p, normal: t.normal, color: p.element(t.kind) }).collect();
let faces_drawn = d.tris.is_empty();
if faces_drawn {
s.tris.extend(d.surface_tris.iter().map(|t| SceneTri { p: t.p, normal: t.normal, color: d.body_color(t, p.geometry, &p.bodies) }));
build(d, Style::ShadedEdges, Show::DEFAULT, p)
}
/// The elements `mesh.quality.mark` marked (its set), drawn in the marker colour.
pub fn marked(s: &Session) -> std::collections::BTreeSet<u32> {
s.model.set(crate::cmd::mesh::MARKED_SET).map(|set| set.elements.iter().copied().collect()).unwrap_or_default()
}
/// Colour the faces of the `marked` elements in `scene`, whose triangles start with the element
/// faces of `display` in order (as [`model_scene`] and the app's viewport build them).
pub fn mark(scene: &mut Scene, display: &DisplayMesh, marked: &std::collections::BTreeSet<u32>, color: Rgb) {
if marked.is_empty() {
return;
}
s.lines = d.edges.iter().map(|e| SceneLine { a: e.p[0], b: e.p[1], color: p.mesh_edge, width: EDGE_WIDTH }).collect();
if faces_drawn {
s.lines.extend(d.surface_edges.iter().map(|[a, b]| SceneLine { a: *a, b: *b, color: p.mesh_edge, width: GEOMETRY_EDGE_WIDTH }));
} else {
// Over the mesh the geometry's edges are drawn along it (the element edges on them), so
// they lie on the element faces.
let edge = p.geometry_edge;
s.lines.extend(d.edges.iter().filter(|e| e.feature).map(|e| SceneLine { a: e.p[0], b: e.p[1], color: edge, width: GEOMETRY_EDGE_WIDTH }));
for (t, d) in scene.tris.iter_mut().zip(&display.tris) {
if marked.contains(&d.element) {
t.color = color;
}
}
s.lines.extend(d.lines.iter().map(|l| SceneLine { a: l.a, b: l.b, color: p.element(l.kind), width: LINE_ELEMENT_WIDTH }));
s
}
/// The model part of a picture: the model or the results view's plot, the shape the symbols sit
@@ -171,6 +461,8 @@ struct Picture {
colors: GlyphColors,
/// The box around the datums drawn: inside the clipping radius, never framed (#295).
datums: Aabb3,
/// The bodies drawn, in the order of their surfaces.
bodies: Vec<String>,
}
impl Picture {
@@ -185,18 +477,27 @@ impl Picture {
}
}
fn picture(s: &Session, p: &Palette) -> Picture {
let (base, failed) = display(s);
fn picture(s: &Session, p: &Palette, section: Option<Section>, toward: Vec3) -> Picture {
let (base, failed, bodies) = display_parts(s, section, toward);
let mut shown = Shown { failed, ..Shown::default() };
let plot = s.results_view.as_ref().and_then(|v| s.latest_result(&v.step).map(|(r, _)| (v, r)));
// Over a results plot, the symbols of what its step used.
let glyphs = plot.map_or_else(|| s.glyphs(), |(v, _)| s.step_glyphs(&v.step));
let Some((view, result)) = plot else {
let mut scene = model_scene(&base, p);
mark(&mut scene, &base, &marked(s), p.marked);
// `bodies` names the drawn surfaces in order.
let drawn: Vec<&str> = bodies.iter().map(String::as_str).collect();
if base.tris.is_empty() {
draw_merged_faces(&mut scene, &base, s, &drawn, p.merged);
}
if let Some(marks) = geometry_marks(s) {
draw_geometry_marks(&mut scene, &base, &drawn, marks, base.tris.is_empty(), p.marked);
}
let bounds = base.bounds;
let shows = |n: &str| s.shows(fieldcraft_io::ItemKind::Datum, n);
let datums = fieldcraft_render::glyphs::datums(&s.model, &base.bounds, &shows, None, &p.glyphs, &mut scene);
return Picture { scene, drawn: base, bounds, shown, glyphs, colors: p.glyphs, datums };
return Picture { scene, drawn: base, bounds, shown, glyphs, colors: p.glyphs, datums, bodies };
};
shown.step = Some(view.step.clone());
let pick = view.pick();
@@ -232,13 +533,13 @@ fn picture(s: &Session, p: &Palette) -> Picture {
}
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 }
Picture { scene, drawn, bounds, shown, glyphs, colors: p.glyphs.over_results(p.background.1), datums: Aabb3::EMPTY, bodies }
}
/// The scene of the session (the model, or the results view's plot) with the setup's symbols
/// at the model's own symbol unit, the box around it, and what it shows.
pub fn scene(s: &Session, p: &Palette) -> (Scene, Aabb3, Shown) {
let mut pic = picture(s, p);
let mut pic = picture(s, p, None, Vec3::Z);
let mut symbols = Scene::default();
let b = pic.symbols(pic.glyphs.model_unit(), &mut symbols);
let mut scene = pic.scene;
@@ -264,13 +565,280 @@ pub fn orientation(p: &Value) -> Result<Orientation> {
Orientation::checked(yaw, pitch, distance).ok_or_else(|| bad("`yaw` and `pitch` must be finite and `distance` finite and above 0"))
}
/// A PNG of the session seen from `at`, `w` × `h` pixels: framing everything drawn but the
/// What a camera looks at: a point, or the centre of mass or of the bounding box of the bodies
/// named (every body drawn when `None`).
#[derive(Clone, Debug, PartialEq)]
pub enum Target {
Point(Vec3),
Mass(Option<Vec<String>>),
Bbox(Option<Vec<String>>),
}
impl Target {
/// The default: the centre of mass of the model drawn, so a sweep of yaws orbits it.
pub const MODEL: Target = Target::Mass(None);
fn bodies(&self) -> Option<&[String]> {
match self {
Target::Point(_) => None,
Target::Mass(b) | Target::Bbox(b) => b.as_deref(),
}
}
/// The JSON form [`camera`] reads.
pub fn json(&self) -> Value {
let center = |what: &str, bodies: &Option<Vec<String>>| match bodies {
Some(b) => serde_json::json!({"center_of": what, "bodies": b}),
None => serde_json::json!({"center_of": what}),
};
match self {
Target::Point(p) => serde_json::json!([p.x, p.y, p.z]),
Target::Mass(b) => center("mass", b),
Target::Bbox(b) => center("bbox", b),
}
}
}
/// A camera aimed as agents give it ([`camera`]): an orientation, what it looks at, whether its
/// distance fits the target's bodies to the frame, and a perspective field of view.
#[derive(Clone, Debug, PartialEq)]
pub struct Aim {
pub at: Orientation,
/// `None` keeps the camera's target (the app's `ui.camera`); a picture of its own aims at
/// [`Target::MODEL`].
pub target: Option<Target>,
/// Without a distance: the target's bodies fill the frame from this direction. Otherwise the
/// distance keeps the whole model in the frame from every direction, so a sweep keeps its
/// size.
pub fit: bool,
/// Vertical field of view in degrees; 0 is orthographic.
pub fov: Option<f64>,
}
impl Aim {
/// Largest field of view, in degrees.
pub const MAX_FOV: f64 = 150.0;
/// Most bodies a target names.
pub const MAX_BODIES: usize = 1000;
/// Looking from `at`, with the target left as it is.
pub fn new(at: Orientation) -> Self {
Aim { at, target: None, fit: false, fov: None }
}
/// The JSON form [`camera`] reads.
pub fn json(&self) -> Value {
let mut v = serde_json::json!({"yaw": self.at.yaw, "pitch": self.at.pitch});
if let Some(d) = self.at.distance {
v["distance"] = d.into();
}
if let Some(t) = &self.target {
v["target"] = t.json();
}
if self.fit {
v["fit"] = true.into();
}
if let Some(f) = self.fov {
v["fov"] = f.into();
}
v
}
}
/// A camera from JSON: [`orientation`]'s `{yaw, pitch, distance?}` and `target`: `[x, y, z]` or
/// `{"center_of": "mass" | "bbox", "bodies"?: [names]}`; `fit` (the target's bodies fill the
/// frame; not with a distance) and `fov` (vertical, degrees, 0 orthographic). Hostile input is
/// refused or clamped.
pub fn camera(p: &Value) -> Result<Aim> {
let bad = |m: &str| EngineError::Params(format!("camera: {m}"));
let o = p.as_object().ok_or_else(|| bad("an object {yaw, pitch, distance?, target?, fit?, fov?}"))?;
if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "yaw" | "pitch" | "distance" | "target" | "fit" | "fov")) {
return Err(bad(&format!("unknown field `{}` (yaw, pitch, distance, target, fit, fov)", k.chars().take(40).collect::<String>())));
}
let given = |k: &str| o.get(k).filter(|v| !v.is_null());
let mut core = serde_json::Map::new();
for k in ["yaw", "pitch", "distance"] {
if let Some(v) = given(k) {
core.insert(k.into(), v.clone());
}
}
let at = orientation(&Value::Object(core))?;
let target = given("target").map(target).transpose()?;
let fit = match given("fit") {
None => false,
Some(v) => v.as_bool().ok_or_else(|| bad("`fit` must be true or false"))?,
};
if fit && at.distance.is_some() {
return Err(bad("give `distance` or `fit`, not both"));
}
let fov = match given("fov") {
None => None,
Some(v) => Some(
v.as_f64()
.filter(|f| f.is_finite() && (0.0..=Aim::MAX_FOV).contains(f))
.ok_or_else(|| bad(&format!("`fov` must be a number of degrees from 0 (orthographic) to {}", Aim::MAX_FOV)))?,
),
};
Ok(Aim { at, target, fit, fov })
}
fn target(v: &Value) -> Result<Target> {
let bad = |m: &str| EngineError::Params(format!("camera: target {m}"));
if let Some(a) = v.as_array() {
let c: Vec<f64> = a.iter().filter_map(Value::as_f64).filter(|x| x.is_finite() && x.abs() <= 1e12).collect();
return match c[..] {
[x, y, z] if a.len() == 3 => Ok(Target::Point(Vec3::new(x, y, z))),
_ => Err(bad("must be [x, y, z] (finite numbers)")),
};
}
let o = v.as_object().ok_or_else(|| bad("must be [x, y, z] or {center_of: mass | bbox, bodies?}"))?;
if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "center_of" | "bodies")) {
return Err(bad(&format!("has an unknown field `{}` (center_of, bodies)", k.chars().take(40).collect::<String>())));
}
let bodies = match o.get("bodies").filter(|v| !v.is_null()) {
None => None,
Some(b) => {
let a = b
.as_array()
.filter(|a| !a.is_empty() && a.len() <= Aim::MAX_BODIES)
.ok_or_else(|| bad(&format!("bodies must be a list of 1 to {} names", Aim::MAX_BODIES)))?;
Some(a.iter().map(|n| n.as_str().map(str::to_string).ok_or_else(|| bad("bodies must be names"))).collect::<Result<Vec<_>>>()?)
}
};
match o.get("center_of").and_then(Value::as_str).unwrap_or("mass") {
"mass" => Ok(Target::Mass(bodies)),
"bbox" => Ok(Target::Bbox(bodies)),
other => Err(bad(&format!("center_of `{}`: mass or bbox", other.chars().take(40).collect::<String>()))),
}
}
/// The centre of mass of the solid a closed triangle surface bounds (the divergence theorem), or
/// the centre of area of an open one; `None` without triangles.
fn centre_of_mass<'a>(tris: impl Iterator<Item = &'a [Vec3; 3]>) -> Option<Vec3> {
let (mut volume, mut moment, mut area, mut areal) = (0.0, Vec3::ZERO, 0.0, Vec3::ZERO);
for [a, b, c] in tris {
let v = a.dot(b.cross(*c)) / 6.0;
volume += v;
moment = moment + (*a + *b + *c) * (v / 4.0);
let s = (*b - *a).cross(*c - *a).len() * 0.5;
area += s;
areal = areal + (*a + *b + *c) * (s / 3.0);
}
if !(area > 0.0) {
return None;
}
// A closed surface's volume is well away from 0 compared with its area times its size;
// an open one (a shell) has none worth the name.
let size = area.sqrt();
let c = if volume.abs() > 1e-6 * area * size { moment * (1.0 / volume) } else { areal * (1.0 / area) };
c.is_finite().then_some(c)
}
impl Picture {
/// The point `target` names, and the box around what it frames: the bodies it names, or
/// everything drawn.
fn aim_point(&self, target: &Target) -> Result<(Vec3, Aabb3)> {
let surfaces: Vec<&[Vec3; 3]> = match target.bodies() {
Some(names) => {
let mut picked = Vec::new();
for n in names {
let i = self.bodies.iter().position(|b| b == n).ok_or_else(|| {
EngineError::Params(format!(
"camera: no body `{}` is drawn (drawn: {})",
n.chars().take(80).collect::<String>(),
if self.bodies.is_empty() { "none".into() } else { self.bodies.join(", ") }
))
})?;
picked.push(u32::try_from(i).unwrap_or(u32::MAX));
}
self.drawn.surface_tris.iter().filter(|t| picked.contains(&t.body)).map(|t| &t.p).collect()
}
// What is drawn: the elements' faces over the geometry's.
None if !self.drawn.tris.is_empty() => self.drawn.tris.iter().map(|t| &t.p).collect(),
None => self.drawn.surface_tris.iter().map(|t| &t.p).collect(),
};
let mut b = Aabb3::EMPTY;
for p in surfaces.iter().flat_map(|t| t.iter()) {
b.add(*p);
}
let framed = if target.bodies().is_some() { b } else { self.bounds };
let whole = if b.is_empty() { self.bounds } else { b };
let point = match target {
Target::Point(p) => *p,
Target::Bbox(_) => whole.center(),
Target::Mass(_) => centre_of_mass(surfaces.into_iter()).unwrap_or_else(|| whole.center()),
};
Ok((point, framed))
}
}
/// `cam` aimed as `aim` says with the picture `pic` and a view `aspect` wide per unit of height:
/// turned, its field of view set, then (with a target) moved to look at it from `aim`'s distance,
/// from the distance that fits the target's bodies (`fit`), or from the distance at which the
/// sphere about the target through the farthest corner of everything drawn fits the view.
fn aimed(pic: &Picture, aim: &Aim, mut cam: Camera, aspect: f64) -> Result<Camera> {
cam.orient(&Orientation { distance: None, ..aim.at });
if let Some(f) = aim.fov {
cam.fov = f.to_radians();
}
let Some(target) = &aim.target else {
cam.orient(&aim.at);
return Ok(cam);
};
let (point, framed) = pic.aim_point(target)?;
if !point.is_finite() {
return Ok(cam);
}
cam.target = point;
if aim.at.distance.is_some() {
cam.orient(&aim.at);
} else if aim.fit {
cam.fit_about(&framed, aspect);
} else if !pic.bounds.is_empty() {
let aspect = if aspect.is_finite() && aspect > 1e-6 { aspect } else { 1.0 };
let radius = pic.bounds.corners().iter().map(|c| c.dist(point)).fold(1e-3, f64::max) / (1.0 - 2.0 * fieldcraft_render::FIT_MARGIN);
// The half height the sphere needs, then the distance that shows it.
let half = radius / aspect.min(1.0);
let d = if cam.fov > 0.0 {
let half_angle = ((cam.fov * 0.5).tan() * aspect.min(1.0)).atan();
radius / half_angle.sin().max(1e-6)
} else {
half / Camera { distance: 1.0, ..cam }.half_height()
};
if d.is_finite() {
cam.distance = d.clamp(Orientation::DISTANCE.0, Orientation::DISTANCE.1);
}
}
Ok(cam)
}
/// The app's camera `cam` aimed as `aim` says at the session `s` drawn in a view `aspect` wide
/// per unit of height (the control channel's `ui.camera`); `cam` as it is but turned when `aim`
/// has no target.
pub fn aim(s: &Session, aim: &Aim, cam: Camera, aspect: f64) -> Result<Camera> {
let pic = picture(s, &Palette::LIGHT, None, cam.back());
aimed(&pic, aim, cam, aspect)
}
/// A picture ready to draw: the scene (with its background and clipping radius), the setup's
/// symbols as a layer over it, and the camera.
pub struct Framed {
pub scene: Scene,
pub symbols: Scene,
pub cam: Camera,
pub shown: Shown,
}
/// The session seen from `at` in a `w` × `h` pixel picture: framing everything drawn but the
/// datums (they are drawn at the model's extent, and framing them would only add margin), or from
/// `at`'s distance when it has one. The symbols are drawn at their default size on screen
/// ([`glyphs::DEFAULT_SIZE`] pixels) for that camera.
pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) -> Result<(Vec<u8>, Shown)> {
let mut pic = picture(s, p);
/// `at`'s distance when it has one. The symbols are sized for their default size on screen
/// ([`glyphs::DEFAULT_SIZE`] pixels) for that camera. The CPU rasterizer ([`render_png`]) and
/// the GPU (`fieldcraft-cli snapshot`) draw the same [`Framed`]. A `section` draws the mesh cut
/// ([`Section`]).
pub fn frame(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette, section: Option<Section>) -> Framed {
let mut cam = Camera::default();
cam.orient(at);
let mut pic = picture(s, p, section, cam.back());
let aspect = w as f64 / h.max(1) as f64;
let unit = |cam: &Camera| glyphs::unit_for(cam, h as f64, glyphs::DEFAULT_SIZE, 1.0).unwrap_or(f64::NAN);
// Framed on the model, then again with its symbols at that camera's size, which reach a
@@ -287,8 +855,32 @@ pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette
bounds.union(pic.symbols(unit(&cam), &mut symbols));
bounds.union(pic.datums);
pic.scene.radius = if bounds.is_empty() { 1.0 } else { (cam.target.dist(bounds.center()) + bounds.diagonal() * 0.5).max(1e-3) };
let png = fieldcraft_render::render_layers_png(&pic.scene, &[&symbols], &cam, w, h).map_err(|e| EngineError::Params(format!("render: {e}")))?;
Ok((png, pic.shown))
Framed { scene: pic.scene, symbols, cam, shown: pic.shown }
}
/// The session seen through `aim` ([`camera`]) in a `w` × `h` pixel picture, aimed at
/// [`Target::MODEL`] unless it names a target; the symbols at their default size on screen and
/// a `section` as [`frame`] draws them. An error names a body the target asks for that is not
/// drawn.
pub fn frame_at(s: &Session, aim: &Aim, w: usize, h: usize, p: &Palette, section: Option<Section>) -> Result<Framed> {
let aim = Aim { target: Some(aim.target.clone().unwrap_or(Target::MODEL)), ..aim.clone() };
let mut cam = Camera::default();
cam.orient(&aim.at);
let mut pic = picture(s, p, section, cam.back());
let cam = aimed(&pic, &aim, Camera { fov: 0.0, ..cam }, w as f64 / h.max(1) as f64)?;
let mut symbols = Scene::default();
let mut bounds = pic.bounds;
bounds.union(pic.symbols(glyphs::unit_for(&cam, h as f64, glyphs::DEFAULT_SIZE, 1.0).unwrap_or(f64::NAN), &mut symbols));
bounds.union(pic.datums);
pic.scene.radius = if bounds.is_empty() { 1.0 } else { (cam.target.dist(bounds.center()) + bounds.diagonal() * 0.5).max(1e-3) };
Ok(Framed { scene: pic.scene, symbols, cam, shown: pic.shown })
}
/// A PNG of the session seen from `at`, `w` × `h` pixels ([`frame`]), drawn on the CPU.
pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) -> Result<(Vec<u8>, Shown)> {
let f = frame(s, at, w, h, p, None);
let png = fieldcraft_render::render_layers_png(&f.scene, &[&f.symbols], &f.cam, w, h).map_err(|e| EngineError::Params(format!("render: {e}")))?;
Ok((png, f.shown))
}
#[cfg(test)]
@@ -331,6 +923,153 @@ mod tests {
assert!(!scene.lines.iter().any(|l| l.color == Palette::LIGHT.glyphs.constraint));
}
/// A session of hex8 boxes `[min, max]`, one element each.
fn boxes(b: &[([f64; 3], [f64; 3])]) -> Session {
let mut s = Session::default();
for (e, (lo, hi)) in b.iter().enumerate() {
let e = e as u32;
let corner = |i: usize| {
let pick = |k: usize, bit: bool| if bit { hi[k] } else { lo[k] };
let (x, y, z) = ([false, true, true, false][i % 4], [false, false, true, true][i % 4], i >= 4);
[pick(0, x), pick(1, y), pick(2, z)]
};
for i in 0..8 {
s.execute("mesh.node.add", &json!({"label": e * 8 + i as u32 + 1, "xyz": corner(i)})).unwrap();
}
s.execute("mesh.element.add", &json!({"label": e + 1, "kind": "hex8", "nodes": (1..=8).map(|i| e * 8 + i).collect::<Vec<_>>()})).unwrap();
}
s
}
/// RGBA pixels of a PNG.
fn pixels(png: &[u8]) -> Vec<u8> {
let mut r = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
let mut buf = vec![0; r.output_buffer_size().unwrap()];
let info = r.next_frame(&mut buf).unwrap();
buf.truncate(info.buffer_size());
buf
}
/// The centroid of the pixels of `png` that differ from the empty picture `empty`, and how
/// many there are.
fn drawn_centroid(png: &[u8], empty: &[u8], w: usize) -> (f64, f64, usize) {
let (a, b) = (pixels(png), pixels(empty));
let (mut sx, mut sy, mut n) = (0.0, 0.0, 0usize);
for (i, (p, q)) in a.chunks(4).zip(b.chunks(4)).enumerate() {
if p.iter().zip(q).take(3).any(|(x, y)| x.abs_diff(*y) > 8) {
sx += (i % w) as f64 + 0.5;
sy += (i / w) as f64 + 0.5;
n += 1;
}
}
(sx / n.max(1) as f64, sy / n.max(1) as f64, n)
}
fn shot(s: &Session, aim: &Aim, w: usize, h: usize) -> Vec<u8> {
let f = frame_at(s, aim, w, h, &Palette::LIGHT, None).unwrap();
fieldcraft_render::render_layers_png(&f.scene, &[&f.symbols], &f.cam, w, h).unwrap()
}
/// A yaw sweep of a box far from the origin orbits it: the box stays at the picture's
/// centre and keeps its size, from below and above, orthographic and in perspective (#467).
#[test]
fn a_yaw_sweep_orbits_the_model() {
let s = boxes(&[([200.0, 40.0, 30.0], [260.0, 70.0, 50.0])]);
let (w, h) = (240, 160);
let empty = shot(&Session::default(), &camera(&json!({"yaw": 0, "pitch": 0})).unwrap(), w, h);
for extra in [json!({}), json!({"fov": 40}), json!({"fit": true})] {
for pitch in [25.0, -30.0] {
let mut sizes = Vec::new();
for yaw in (0..360).step_by(30) {
let mut p = json!({"yaw": yaw, "pitch": pitch});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
let (x, y, n) = drawn_centroid(&shot(&s, &camera(&p).unwrap(), w, h), &empty, w);
assert!(n > 500, "{p}: {n} pixels");
// In perspective the near end of the box is drawn larger than the far end, which
// moves its silhouette's centroid off the centre of mass by a few percent of
// the width; the centre of mass itself projects to the centre (checked below).
let tolerance = if extra.get("fov").is_some() { 0.05 * w as f64 } else { 2.0 };
let off = (x - w as f64 / 2.0).abs().max((y - h as f64 / 2.0).abs());
assert!(off < tolerance, "{p}: centroid ({x:.1}, {y:.1})");
sizes.push(n);
let cam = frame_at(&s, &camera(&p).unwrap(), w, h, &Palette::LIGHT, None).unwrap().cam;
let (cx, cy, _) = cam.to_screen(Vec3::new(230.0, 55.0, 40.0), w as f64, h as f64, 100.0).unwrap();
assert!((cx - w as f64 / 2.0).abs() < 1e-6 && (cy - h as f64 / 2.0).abs() < 1e-6, "{p}");
}
// Without `fit` the distance is the same from every side; with it the box fills
// the frame, so it is larger.
if extra.get("fit").is_none() {
let (lo, hi) = (sizes.iter().min().unwrap(), sizes.iter().max().unwrap());
assert!(*hi < lo * 3, "{extra} {pitch}: {sizes:?}");
}
}
}
}
/// The target is the centre of mass by default, the bounding box's centre or a point when
/// asked; bodies name what it frames, and a body not drawn is an error.
#[test]
fn camera_targets() {
// An L: a long box and a short one on top of its end.
let s = boxes(&[([0.0, 0.0, 0.0], [90.0, 10.0, 10.0]), ([0.0, 0.0, 10.0], [10.0, 10.0, 40.0])]);
let pic = picture(&s, &Palette::LIGHT, None, Vec3::Z);
let (mass, _) = pic.aim_point(&Target::MODEL).unwrap();
// Volumes 9000 and 3000: x = (45·9000 + 5·3000)/12000 = 35, z = (5·9000 + 25·3000)/12000 = 10.
assert!(mass.dist(Vec3::new(35.0, 5.0, 10.0)) < 1e-9, "{mass:?}");
let (bbox, _) = pic.aim_point(&Target::Bbox(None)).unwrap();
assert!(bbox.dist(Vec3::new(45.0, 5.0, 20.0)) < 1e-9, "{bbox:?}");
let (point, _) = pic.aim_point(&Target::Point(Vec3::new(1.0, 2.0, 3.0))).unwrap();
assert_eq!(point, Vec3::new(1.0, 2.0, 3.0));
// An open surface (one face of a box) has its centre of area.
let face = [[Vec3::ZERO, Vec3::X, Vec3::new(1.0, 1.0, 0.0)], [Vec3::ZERO, Vec3::new(1.0, 1.0, 0.0), Vec3::Y]];
assert!(centre_of_mass(face.iter()).unwrap().dist(Vec3::new(0.5, 0.5, 0.0)) < 1e-12);
assert_eq!(centre_of_mass([].iter()), None);
let err = frame_at(
&s,
&camera(&json!({"yaw": 0, "pitch": 0, "target": {"center_of": "mass", "bodies": ["Gear"]}})).unwrap(),
64,
48,
&Palette::LIGHT,
None,
);
assert!(err.is_err_and(|e| e.to_string().contains("no body `Gear`")));
// The app's camera without a target keeps its own and only turns.
let cam = Camera { target: Vec3::new(7.0, 8.0, 9.0), distance: 321.0, ..Camera::default() };
let turned = aim(&s, &camera(&json!({"yaw": 90, "pitch": 10})).unwrap(), cam, 1.5).unwrap();
assert_eq!((turned.target, turned.distance), (cam.target, cam.distance));
let aimed = aim(&s, &camera(&json!({"yaw": 90, "pitch": 10, "target": {"center_of": "mass"}, "fit": true})).unwrap(), cam, 1.5).unwrap();
assert!(aimed.target.dist(mass) < 1e-9 && aimed.distance != cam.distance);
}
#[test]
fn camera_json_is_checked() {
let a = camera(&json!({"yaw": 10, "pitch": 20, "target": [1, 2, 3], "fov": 35})).unwrap();
assert_eq!((a.target, a.fov, a.fit), (Some(Target::Point(Vec3::new(1.0, 2.0, 3.0))), Some(35.0), false));
let a = camera(&json!({"yaw": 0, "pitch": 0, "target": {"center_of": "bbox", "bodies": ["A"]}, "fit": true})).unwrap();
assert_eq!(camera(&a.json()).unwrap(), a);
assert_eq!(a.target, Some(Target::Bbox(Some(vec!["A".into()]))));
assert_eq!(camera(&json!({"yaw": 0, "pitch": 0, "target": {}})).unwrap().target, Some(Target::MODEL));
for bad in [
json!({"yaw": 0, "pitch": 0, "target": [1, 2]}),
json!({"yaw": 0, "pitch": 0, "target": [1, 2, "x"]}),
json!({"yaw": 0, "pitch": 0, "target": [1, 2, 1e300]}),
json!({"yaw": 0, "pitch": 0, "target": {"center_of": "gravity"}}),
json!({"yaw": 0, "pitch": 0, "target": {"center_of": "mass", "bodies": []}}),
json!({"yaw": 0, "pitch": 0, "target": {"center_of": "mass", "bodies": [3]}}),
json!({"yaw": 0, "pitch": 0, "target": {"middle": 1}}),
json!({"yaw": 0, "pitch": 0, "target": "mass"}),
json!({"yaw": 0, "pitch": 0, "fit": true, "distance": 10}),
json!({"yaw": 0, "pitch": 0, "fit": 1}),
json!({"yaw": 0, "pitch": 0, "fov": -1}),
json!({"yaw": 0, "pitch": 0, "fov": 179}),
json!({"yaw": 0, "pitch": 0, "fov": "wide"}),
json!({"yaw": 0, "pitch": 0, "zoom": 2}),
json!({"pitch": 0}),
] {
assert!(camera(&bad).is_err(), "{bad}");
}
}
/// 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));
+8 -3
View File
@@ -12,8 +12,10 @@
//! condition: T. J. R. Hughes, R. L. Taylor, J. L. Sackman, A. Curnier, W. Kanoknukulchai,
//! *A finite element method for a class of contact-impact problems*, CMAME 8(3):249–276,
//! 1976), with Coulomb friction as a tangential impulse at most μ times the normal one (the
//! tangential velocity stops, or loses μ Δv_n when it slides), and prescribed degrees of
//! freedom take their velocity. The change a constraint makes is the impulse of its reaction,
//! tangential velocity stops, or loses μ Δv_n when it slides), prescribed degrees of
//! freedom take their velocity, and a symmetry plane not normal to an axis removes its nodes'
//! velocity along its normal (from their free components: the lumped mass is the same on a
//! node's three translations, so this is the mass-weighted projection onto the constraint). The change a constraint makes is the impulse of its reaction,
//! r^n = M Δv / Δt^n;
//! 4. energies: the works of the internal, contact and external forces by the trapezoidal rule,
//! W += ½ Δu · (f^{n−1} + f^n) (exactly the strain energy for a linear elastic material),
@@ -77,7 +79,7 @@ struct Output {
}
/// Velocity constraints over an increment of length `dt` (rigid walls when dt > 0, then
/// prescribed degrees of freedom); returns the kinetic energy the walls' friction took (`mass`
/// prescribed degrees of freedom, then inclined symmetry planes); returns the kinetic energy the walls' friction took (`mass`
/// per degree of freedom).
fn constrain(sys: &System, u: &[f64], v: &mut [f64], dt: f64, mass: &[f64]) -> f64 {
let mut dissipated = 0.0;
@@ -107,6 +109,9 @@ fn constrain(sys: &System, u: &[f64], v: &mut [f64], dt: f64, mass: &[f64]) -> f
*c = *p;
}
}
for row in &sys.planes {
row.project(v);
}
dissipated
}
+76 -2
View File
@@ -9,7 +9,8 @@ use fieldcraft_fem::plasticity::J2;
use fieldcraft_fem::uniform::{Hex8, Tet4, modulus_bound};
use fieldcraft_fem::{DofKind, DofLayout, DofSet, face};
use fieldcraft_model::{
Elastic, ElementKind, ExplicitDynamic, Friction, HourglassKind, Integration, Load, Model, Resolved, SectionKind, Step, StressRate, Target,
BoundaryCondition, Elastic, ElementKind, ExplicitDynamic, Friction, HourglassKind, Integration, Load, Model, Resolved, SectionKind, Step,
StressRate, Target,
};
use crate::contact::Interface;
@@ -100,6 +101,36 @@ pub struct Wall {
pub friction: Friction,
}
/// One velocity constraint of the symmetry planes not normal to an axis at a node: c · v = 0
/// over the node's translations, `q` its part on the components no prescribed velocity holds,
/// of unit length and perpendicular to the node's other rows' (Gram–Schmidt over the planes that
/// meet at the node). [`SymmetryRow::project`] removes q (c · v) from the velocity: with the same
/// lumped mass on a node's three translations this is the mass-weighted closest velocity that
/// satisfies the planes and the prescribed values (the constrained update of a lumped-mass
/// central difference scheme), exactly, in one pass over the rows.
#[derive(Debug, Clone)]
pub struct SymmetryRow {
pub node: usize,
pub q: [f64; 3],
pub c: [f64; 3],
}
impl SymmetryRow {
pub fn project(&self, v: &mut [f64]) {
let at = |k: usize| NODE_DOFS * self.node + k;
let along: f64 = (0..3).map(|k| self.c[k] * v.get(at(k)).copied().unwrap_or(0.0)).sum();
for k in 0..3 {
if let Some(x) = v.get_mut(at(k)) {
*x -= along * self.q[k];
}
}
}
}
/// A free part below this fraction of its row's size is round-off: the row repeats the node's
/// earlier ones, or the prescribed velocities hold it.
const REPEATED: f64 = 1e-10;
/// The discrete system of an explicit dynamic step.
///
/// Its elements are solids, so every node carries the three translations and the layout is
@@ -130,6 +161,8 @@ pub struct System {
/// Prescribed velocity per degree of freedom (`None` = free).
pub prescribed: Vec<Option<f64>>,
pub walls: Vec<Wall>,
/// The symmetry planes not normal to an axis, per node.
pub planes: Vec<SymmetryRow>,
/// The model's contacts ([`crate::contact`]).
pub contacts: Vec<Interface>,
/// Velocity per degree of freedom at the start (prescribed velocities included).
@@ -293,6 +326,7 @@ impl System {
discrete_increment: None,
prescribed: vec![None; dofs],
walls: Vec::new(),
planes: Vec::new(),
contacts: Vec::new(),
initial_velocity: vec![0.0; dofs],
external: vec![0.0; dofs],
@@ -541,6 +575,7 @@ impl System {
/// Prescribed velocities from the step's boundary conditions: zero for held degrees of
/// freedom, value / end time for a displacement reached over the step.
fn prescribe(&mut self, model: &Model, resolved: &Resolved, step: &Step, end_time: f64) -> Result<()> {
let mut normals: std::collections::BTreeMap<usize, Vec<[f64; 3]>> = std::collections::BTreeMap::new();
for name in &step.boundary_conditions {
let bc = model.boundary_condition(name).ok_or_else(|| invalid(format!("boundary condition `{name}` is missing")))?;
if let Target::ReferencePoint(rp) = bc.target() {
@@ -549,6 +584,14 @@ impl System {
if bc.is_thermal() {
return Err(Error::NotSupported(format!("temperature boundary conditions (`{name}`) in explicit dynamics")));
}
if let BoundaryCondition::Symmetry { plane, .. } = bc
&& let Some(normal) = plane.inclined()
{
for node in resolved.target_nodes(bc.target()).iter().filter_map(|l| self.node(*l)) {
normals.entry(node).or_default().push(normal);
}
continue;
}
for &label in resolved.target_nodes(bc.target()) {
let Some(node) = self.node(label) else { continue };
for kind in self.layout.kinds(node).iter() {
@@ -571,6 +614,34 @@ impl System {
}
}
}
// Each node's planes: orthonormal free parts, or rows the prescribed velocities satisfy.
for (node, rows) in normals {
let p = [0, 1, 2].map(|k| self.prescribed.get(NODE_DOFS * node + k).copied().flatten());
let mut done: Vec<SymmetryRow> = Vec::new();
for n in rows {
let mut c = n;
for r in &done {
let f: f64 = (0..3).map(|k| r.q[k] * if p[k].is_none() { c[k] } else { 0.0 }).sum();
for k in 0..3 {
c[k] -= f * r.c[k];
}
}
let q = [0, 1, 2].map(|k| if p[k].is_none() { c[k] } else { 0.0 });
let length = q.iter().map(|x| x * x).sum::<f64>().sqrt();
if length > REPEATED {
done.push(SymmetryRow { node, q: q.map(|x| x / length), c: c.map(|x| x / length) });
continue;
}
// Nothing free is left: the prescribed velocities must satisfy the row.
let along: f64 = (0..3).map(|k| c[k] * p[k].unwrap_or(0.0)).sum();
let size: f64 = (0..3).map(|k| (n[k] * p[k].unwrap_or(0.0)).abs()).sum();
if along.abs() > 1e-9 * size {
let label = self.labels.get(node).copied().unwrap_or_default();
return Err(invalid(format!("node {label} moves off a symmetry plane (normal {n:?}) by its prescribed velocities")));
}
}
self.planes.extend(done);
}
Ok(())
}
@@ -729,12 +800,15 @@ impl System {
}
}
}
// A prescribed velocity holds from the start.
// A prescribed velocity holds from the start, and so do the symmetry planes.
for (v, p) in self.initial_velocity.iter_mut().zip(&self.prescribed) {
if let Some(p) = p {
*v = *p;
}
}
for row in &self.planes {
row.project(&mut self.initial_velocity);
}
let size = self.xyz.iter().flatten().fold(0.0f64, |m, c| m.max(c.abs())).max(1.0);
for w in &settings.rigid_walls {
let len = w.normal.iter().map(|c| c * c).sum::<f64>().sqrt();
+164
View File
@@ -0,0 +1,164 @@
//! Symmetry about inclined planes in explicit dynamics (#242): the velocity along the plane's
//! normal is removed at its nodes every increment. A bar turned by a rotation R moves as R times
//! the unturned bar on the axis-aligned planes; and a half (or quarter) of a bar whose mesh is
//! mirror-symmetric moves as the whole at the nodes they share. One plane, and two planes meeting
//! at an edge.
use fieldcraft_explicit::{ExplicitResult, Limits, solve_explicit};
use fieldcraft_model::*;
/// A rotation by `angle` about the axis `a` (Rodrigues).
fn rotation(a: [f64; 3], angle: f64) -> [[f64; 3]; 3] {
let l = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt();
let a = a.map(|c| c / l);
let (s, c) = angle.sin_cos();
let k = [[0.0, -a[2], a[1]], [a[2], 0.0, -a[0]], [-a[1], a[0], 0.0]];
let mut r = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
r[i][j] = if i == j { c } else { 0.0 } + (1.0 - c) * a[i] * a[j] + s * k[i][j];
}
}
r
}
fn apply(r: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
[0, 1, 2].map(|i| (0..3).map(|j| r[i][j] * v[j]).sum())
}
fn norm(v: [f64; 3]) -> f64 {
v.iter().map(|c| c * c).sum::<f64>().sqrt()
}
/// A 6 × 1 × 1 bar of one-point hex8 cells along x, clamped at x = 0, under the body force
/// `force` for 0.2 time units. `whole[a]` spans −1..1 across axis a (y or z) with two cells,
/// otherwise 0..1 with one cell and a symmetry plane at 0. Everything is turned by `turn`.
fn bar(whole: [bool; 2], force: [f64; 3], turn: &[[f64; 3]; 3]) -> (Model, ExplicitResult) {
let mut m = Model::default();
let cells = [6usize, if whole[0] { 2 } else { 1 }, if whole[1] { 2 } else { 1 }];
let start = [0.0, if whole[0] { -1.0 } else { 0.0 }, if whole[1] { -1.0 } else { 0.0 }];
let label = |i: usize, j: usize, k: usize| (1 + i + (cells[0] + 1) * (j + (cells[1] + 1) * k)) as u32;
for k in 0..=cells[2] {
for j in 0..=cells[1] {
for i in 0..=cells[0] {
m.set_node(label(i, j, k), apply(turn, [i as f64, start[1] + j as f64, start[2] + k as f64])).unwrap();
}
}
}
let corners = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
let mut next = 1;
for k in 0..cells[2] {
for j in 0..cells[1] {
for i in 0..cells[0] {
m.set_element(next, ElementKind::Hex8, corners.iter().map(|c| label(i + c[0], j + c[1], k + c[2])).collect()).unwrap();
next += 1;
}
}
}
let all: Vec<u32> = m.elements().keys().copied().collect();
m.add_set("all", Set { elements: all, ..Set::default() }).unwrap();
let nodes = |f: &dyn Fn(usize, usize, usize) -> bool| -> Vec<u32> {
let mut out = Vec::new();
for k in 0..=cells[2] {
for j in 0..=cells[1] {
for i in 0..=cells[0] {
if f(i, j, k) {
out.push(label(i, j, k));
}
}
}
}
out.sort_unstable();
out
};
m.add_set("root", Set { nodes: nodes(&|i, _, _| i == 0), ..Set::default() }).unwrap();
m.add_set("tip", Set { nodes: nodes(&|i, _, _| i == cells[0]), ..Set::default() }).unwrap();
m.add_material("Mat", Material { elastic: Some(Elastic::Isotropic { young: 1.0e4, poisson: 0.3 }), density: Some(1.0), ..Material::default() })
.unwrap();
let formulation = Formulation { integration: Integration::Reduced, ..Formulation::default() };
m.add_section("bar", Section { set: "all".into(), material: "Mat".into(), kind: SectionKind::Solid {}, formulation }).unwrap();
let settings = ExplicitDynamic::new(0.2);
let mut step = Step::new("Pull", Procedure::ExplicitDynamic(settings));
m.add_boundary_condition("clamp", BoundaryCondition::Fixed { target: Target::Set("root".into()) }).unwrap();
step.boundary_conditions.insert("clamp".into());
for (a, name) in [(0usize, "y0"), (1, "z0")] {
if whole[a] {
continue;
}
m.add_set(name, Set { nodes: nodes(&|_, j, k| [j, k][a] == 0), ..Set::default() }).unwrap();
let mut normal = [0.0; 3];
normal[a + 1] = 1.0;
let plane = Symmetry::from_normal(apply(turn, normal)).unwrap();
m.add_boundary_condition(name, BoundaryCondition::Symmetry { target: Target::Set(name.into()), plane }).unwrap();
step.boundary_conditions.insert(name.into());
}
m.add_load("pull", Load::BodyForce { set: "all".into(), vector: apply(turn, force) }).unwrap();
step.loads.insert("pull".into());
m.add_step(step, None).unwrap();
let r = solve_explicit(&m, None, &Limits::default()).unwrap();
(m, r)
}
fn key(x: [f64; 3]) -> [i64; 3] {
x.map(|c| (c * 1e6).round() as i64)
}
/// The largest difference between `b`'s last-frame displacements and `turn` times `a`'s at the
/// same (unturned) places, over the nodes of `b`, relative to `a`'s largest.
fn difference(a: &(Model, ExplicitResult), b: &(Model, ExplicitResult), turn: &[[f64; 3]; 3]) -> f64 {
let ua = &a.1.frames.last().unwrap().displacement;
let at: std::collections::BTreeMap<[i64; 3], [f64; 3]> = a.1.nodes.iter().zip(ua).map(|(l, u)| (key(a.0.node(*l).unwrap().xyz), *u)).collect();
let size = ua.iter().fold(0.0f64, |m, u| m.max(norm(*u)));
let unturn = |x: [f64; 3]| [0, 1, 2].map(|i| (0..3).map(|j| turn[j][i] * x[j]).sum::<f64>());
let ub = &b.1.frames.last().unwrap().displacement;
b.1.nodes
.iter()
.zip(ub)
.map(|(l, u)| {
let want = apply(turn, at[&key(unturn(b.0.node(*l).unwrap().xyz))]);
norm([0, 1, 2].map(|k| u[k] - want[k])) / size
})
.fold(0.0, f64::max)
}
#[test]
fn turned_planes_move_as_the_axis_aligned_ones_and_the_whole() {
let identity = rotation([0.0, 0.0, 1.0], 0.0);
let turn = rotation([1.0, 2.0, 3.0], 0.7);
// Bending about one plane; an axial pull and a bend about both, two planes meeting at an edge.
for (whole, force) in [([false, true], [0.0, 0.0, -1.0]), ([false, false], [4.0, 0.0, 0.0])] {
let axis = bar(whole, force, &identity);
let turned = bar(whole, force, &turn);
let full = bar([true, true], force, &identity);
let full_turned = bar([true, true], force, &turn);
// The part moves as the whole, turned or not, to round-off: the planes hold exactly.
let (mirrored, mirrored_turned) = (difference(&full, &axis, &identity), difference(&full_turned, &turned, &identity));
// Turning the model changes the stable increment in its last digits, which the central
// difference scheme carries over its increments: the turned part may differ from the
// unturned by as much as the turned whole does (its increments may end a step apart), no more.
let (rotated, own) = (difference(&axis, &turned, &turn), difference(&full, &full_turned, &turn));
let tip = axis.1.frames.last().unwrap().displacement.iter().fold(0.0f64, |m, u| m.max(norm(*u)));
eprintln!(
"planes {whole:?}: tip {tip:.4e}; part vs whole {mirrored:.1e} (turned {mirrored_turned:.1e}); turned vs axis-aligned {rotated:.1e} (the whole: {own:.1e})"
);
assert!(tip > 1e-4, "{tip}");
assert!(mirrored < 1e-12 && mirrored_turned < 1e-12, "{mirrored} {mirrored_turned}");
assert!(rotated < 1.01 * own + 1e-12 && own < 1e-3, "{rotated} {own}");
assert!(turned.1.completed);
}
}
/// A prescribed velocity that leaves no free component along the normal must move along the
/// plane; one that does not is refused.
#[test]
fn a_prescribed_motion_off_the_plane_is_refused() {
let turn = rotation([0.0, 0.0, 1.0], 0.5);
let (mut m, _) = bar([false, true], [0.0, 0.0, -1.0], &turn);
let nodes = m.set("y0").unwrap().nodes.iter().copied().filter(|l| m.set("tip").unwrap().nodes.contains(l)).collect();
m.add_set("pushed", Set { nodes, ..Set::default() }).unwrap();
let push = BoundaryCondition::Displacement { target: Target::Set("pushed".into()), values: [Some(0.01), Some(0.01), None, None, None, None] };
m.add_boundary_condition("push", push).unwrap();
m.set_boundary_condition_active("Pull", "push", true).unwrap();
let e = solve_explicit(&m, None, &Limits::default()).unwrap_err().to_string();
assert!(e.contains("moves off a symmetry plane"), "{e}");
}
+1
View File
@@ -207,6 +207,7 @@ impl Basis {
ElementKind::Tet4 => Basis::Tet4,
ElementKind::Tet10 => Basis::Tet10,
ElementKind::Wedge6 => Basis::Wedge6,
ElementKind::Pyramid5 => Basis::Pyramid5,
ElementKind::Hex8 => Basis::Hex8,
ElementKind::Hex20 => Basis::Hex20,
}
+15 -2
View File
@@ -47,6 +47,7 @@ use crate::{Error, Result};
pub enum Family {
Tetrahedron,
Wedge,
Pyramid,
Hexahedron,
/// The 2D elements of plane and axisymmetric models ([`crate::plane`]).
Triangle,
@@ -74,7 +75,7 @@ pub struct ElementType {
}
/// The element table.
pub static ELEMENT_TYPES: [ElementType; 5] = [
pub static ELEMENT_TYPES: [ElementType; 6] = [
ElementType {
kind: ElementKind::Tet4,
family: Family::Tetrahedron,
@@ -108,6 +109,17 @@ pub static ELEMENT_TYPES: [ElementType; 5] = [
mass: Rule::Wedge,
incompatible_modes: false,
},
ElementType {
kind: ElementKind::Pyramid5,
family: Family::Pyramid,
nodes: 5,
corners: 5,
shape: shape::pyramid5,
full: Rule::Pyramid(2),
reduced: None,
mass: Rule::Pyramid(3),
incompatible_modes: false,
},
ElementType {
kind: ElementKind::Hex8,
family: Family::Hexahedron,
@@ -137,7 +149,7 @@ pub fn element_type(kind: ElementKind) -> Result<&'static ElementType> {
ELEMENT_TYPES
.iter()
.find(|t| t.kind == kind)
.ok_or_else(|| Error::NotSupported(format!("{} elements (the solver has tet4, tet10, wedge6, hex8 and hex20 so far)", kind.name())))
.ok_or_else(|| Error::NotSupported(format!("{} elements (the solver has tet4, tet10, wedge6, pyramid5, hex8 and hex20 so far)", kind.name())))
}
/// How a solid element is formulated.
@@ -730,6 +742,7 @@ pub fn extrapolation(ty: &ElementType, rule: Rule) -> Result<Extrapolation> {
let corner_basis = |xi: [f64; 3]| match ty.family {
Family::Tetrahedron => shape::tet4(xi),
Family::Wedge => shape::wedge6(xi),
Family::Pyramid => shape::pyramid5(xi),
Family::Hexahedron => shape::hex8(xi),
Family::Triangle => Basis::Tri3.eval(xi),
Family::Quadrilateral => Basis::Quad4.eval(xi),
+9 -3
View File
@@ -833,9 +833,15 @@ mod tests {
let x8: Vec<[f64; 3]> = x20[..8].to_vec();
let x6 = vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.1], [0., 0., 1.], [1.1, 0., 1.], [0., 1., 1.2]];
let x4 = vec![x10[0], x10[1], x10[2], x10[3]];
for (kind, x) in
[(ElementKind::Tet4, &x4), (ElementKind::Tet10, &x10), (ElementKind::Wedge6, &x6), (ElementKind::Hex8, &x8), (ElementKind::Hex20, &x20)]
{
let x5 = vec![[0., 0., 0.], [1., 0., 0.1], [1.1, 1., 0.], [0., 1., 0.], [0.5, 0.4, 0.9]];
for (kind, x) in [
(ElementKind::Tet4, &x4),
(ElementKind::Tet10, &x10),
(ElementKind::Wedge6, &x6),
(ElementKind::Pyramid5, &x5),
(ElementKind::Hex8, &x8),
(ElementKind::Hex20, &x20),
] {
for m in [&svk, &neo] {
let solid = Solid::new(kind, x, Formulation::Full).unwrap();
check_tangent(&solid, m, &displacement(x.len(), 0.08));
+32
View File
@@ -66,6 +66,10 @@ pub enum Rule {
/// Three triangle points times two Gauss points in ζ on the reference wedge: point k of each
/// layer sits near corner k, the lower layer first.
Wedge,
/// The collapsed (Duffy) product on the reference pyramid, n points per direction: Gauss–Legendre
/// in u, v ∈ [−1, 1] and w ∈ [0, 1], mapped by ξ = u(1 − w), η = v(1 − w), ζ = w with the
/// weight times (1 − w)², which makes the pyramid's rational functions polynomials in (u, v, w).
Pyramid(u8),
}
impl Rule {
@@ -125,6 +129,19 @@ impl Rule {
let tri = [[b, b], [a, b], [b, a]];
gauss_legendre(2).iter().flat_map(|&(z, wz)| tri.iter().map(move |t| Point { xi: [t[0], t[1], z], weight: wz / 6.0 })).collect()
}
Rule::Pyramid(n) => {
let g = gauss_legendre(usize::from(n));
let mut out = Vec::with_capacity(g.len().pow(3));
for &(z, wz) in g {
let (w, ww) = ((z + 1.0) / 2.0, wz / 2.0);
for &(v, wv) in g {
for &(u, wu) in g {
out.push(Point { xi: [u * (1.0 - w), v * (1.0 - w), w], weight: wu * wv * ww * (1.0 - w) * (1.0 - w) });
}
}
}
out
}
}
}
}
@@ -182,6 +199,21 @@ mod tests {
}
}
}
// Pyramid: ∫ ξ^a η^b ζ^c over the pyramid of base [−1, 1]² at ζ = 0 and apex (0, 0, 1)
// = ∫_0^1 ζ^c ∫ ξ^a ∫ η^b over the square of half-width 1 − ζ: for even a, b,
// 4/((a+1)(b+1)) ∫_0^1 ζ^c (1 − ζ)^(a+b+2) = 4/((a+1)(b+1)) · B(c + 1, a + b + 3).
let beta = |p: i32, q: i32| f(p - 1) * f(q - 1) / f(p + q - 1);
// Three points per direction are exact to degree 5 in w, i.e. a + b + c ≤ 3.
for a in 0..=3 {
for b in 0..=3 - a {
for c in 0..=3 - a - b {
let exact = if a % 2 == 1 || b % 2 == 1 { 0.0 } else { 4.0 / f64::from((a + 1) * (b + 1)) * beta(c + 1, a + b + 3) };
let got = integrate(Rule::Pyramid(3), a, b, c);
assert!((got - exact).abs() < 1e-14, "pyramid ξ^{a} η^{b} ζ^{c}: {got} vs {exact}");
}
}
}
assert!((integrate(Rule::Pyramid(2), 0, 0, 0) - 4.0 / 3.0).abs() < 1e-15);
// Triangle: ∫ ξ^a η^b = a! b! / (a + b + 2)!.
for (rule, degree) in [(Rule::TriConical(3), 4), (Rule::TriConical(4), 6), (Rule::TriCentroid, 1), (Rule::Tri3, 2)] {
for a in 0..=degree {
Loaded 100 of 245 files, more files were not shown because too many files have changed in this diff. Show more