Compare commits

...
34 Commits
Author SHA1 Message Date
jyvikoandClaude Opus 5.5 e4290c7366 mesh: faster tet improvement (#84)
Profile-driven (thin-ribs: 76% of the improvement in the topological
operations, most of it in face removal walking whole edge rings to find
one neighbour; pinhole-block: 44% in the smoothing passes).

Same results, less work:
- face removal finds the sandwiched face across a ring edge through the
  tetrahedron it already holds (O(1) instead of an edge ring), keeps the
  candidate tetrahedra of ring edges between rounds and updates the
  removed worst incrementally;
- Klincsek's table for edge removal starts at the quality to beat, so
  triangles that cannot help are never evaluated;
- a change clock per vertex: a topological operation (per tetrahedron,
  per edge, per face and side) or a smoothing that failed is not tried
  again until a tetrahedron it reads changes (exact: the operations are
  functions of that region; checked by re-running them);
- the quality of a live tetrahedron is cached per slot until one of its
  vertices is touched;
- smoothing line searches stop at the first tetrahedron that does not
  beat the worst, and gradients are computed only near the worst;
- the journal saves the free list's tail instead of the whole list.

For the clock to be exact, the quality is the same bits for every
listing of a tetrahedron with the same orientation (corners in a
canonical even order), and a closed edge ring starts at its smallest
vertex.

Changed results:
- smoothing uses the analytic gradient of the smallest dihedral sine
  (Klingner and Shewchuk 2007 §3.1; Freitag and Ollivier-Gooch 1997)
  instead of central differences (kept for flat or inverted
  tetrahedra);
- the composite insertion runs its topological passes on the new
  vertex's tetrahedra below the threshold, and smooths the neighbours
  whose star has one, as the passes do.

Tests: analytic gradient against central differences, quality the same
for every even reordering, cached qualities equal fresh ones after
improvement, rollbacks restore every slot; the washer fingerprint moves
(the same on arm64 and x86-64).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 22:11:53 -04:00
jyvikoandClaude Opus 5.5 4329630943 mesh: the tet objective sees needles; no tet below scaled Jacobian 0.3 on the skill's tet parts
The improvement objective was the smallest dihedral sine, which misses
needles: every dihedral angle fine while a corner is nearly flat, so
the corpus meshes had scaled Jacobians down to 0.01-0.07. The objective
is now the smaller of the dihedral sine and the scaled Jacobian at the
worst corner (the measure analysts check, Knupp 2001), weighted so a
Jacobian of 0.3 (the skill's limit) counts as a 30 degree dihedral
angle (the schedule's target): the Jacobian decides only near that
limit. The existing edge removal, multi-face removal, smoothing and
insertion then remove the needles.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:41:22 -04:00
jyvikoandClaude Opus 5.5 2e639336c3 docs: skill results with the plan sized for its chords
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
jyvikoandClaude Opus 5.5 8e5072edf0 testkit: why the sphere's fallback misses the volume limit
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
jyvikoandClaude Opus 5.5 f2ea44b62d testkit: skill volume limit 1 %, causes of the parts now passing dropped
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
jyvikoandClaude Opus 5.5 8ad0800aa6 mesh: size a plan so its chords keep the volume within 0.5 %
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
jyvikoandClaude Opus 5.5 729a770d6c mesh: divide a block edge that bends with a curved target by its length
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
jyvikoandClaude Opus 5.5 5db21aba7b ui: geometry.defeature.propose lists the small features with an on/off switch each (#433)
Holes by diameter, fillets by radius, chamfers by distance and bosses by height below
thresholds, or under a geometry.check finding; the reply panel switches each feature
off and on (geometry.defeature) and defeatures the whole proposal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 18:30:45 -04:00
jyvikoandClaude Opus 5.5 2456912850 mesh: strong relax objective never ranks an inverted element below a valid one
In the max-min phase a valid element's term q^-16 reached 1e96 as q -> 0+, far above
an invalid element's 2e8, so the descent kept elements inverted rather than pass
through flat: the distorted plate stayed tangled (4 invalid) once its mesh was the
same on every platform. The shape term now continues as its tangent line below
q = 0.3 (finite, steep) and an invalid element costs 1e11, above any valid one.

Adds a test that the plate's mesh, its perturbation and the strong relaxation are
the same bits with 1 and 4 threads and match a fingerprint taken on macOS (CI checks
it on Linux).

Part of #438.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 18:30:00 -04:00
jyvikoandClaude Opus 5.5 700a7e3d10 mesh: platform-independent maths (libm) so meshes are bit-identical on macOS and Linux
The system maths library's sin, cos, atan2, log … are not correctly rounded and differ
between platforms: mesh.hex.generate gave the plate of the strong-relax test a different
mesh on Linux than on macOS (nodes from 2809 on), so the test passed on a Mac and failed
in CI. Every transcendental call in fieldcraft-mesh now goes through the libm crate (a
pure-Rust port of musl's), which gives the same bits everywhere.

Part of #438.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 18:30:00 -04:00
jyvikoandClaude Opus 5.5 f297ffca3a engine: geometry.defeature takes features out of the design for meshing (#433)
Suppresses the SolidCraft features named (holes filled, fillets and chamfers back to
their sharp edges, bosses removed) and rebuilds the design; a feature that cannot be
suppressed alone is merged into its neighbour for meshing only. Kept in the model,
replayed on open, undoable; geometry.defeature.delete turns them back on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:43:42 -04:00
jyvikoandClaude Opus 5.5 3f0cae70a2 model: keep defeatures, features of the design taken out for meshing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:43:42 -04:00
jyvikoandClaude Opus 5.5 e9ef7f9d4d geometry: defeature a design by suppressing its features, virtually when they cannot be
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:43:42 -04:00
jyvikoandClaude Opus 5.5 9c1c8f75c2 mesh: the tet mesher takes its input in a canonical order (#115)
SolidCraft lists a solid's faces in a different order from run to run
(the rounded box, for one), which reordered the triangle soup and the
face tags with every coordinate the same; welding keeps the first copy
it meets and every later stage visits ids in order, so the tet mesh
changed between runs. CanonicalSoup sorts the triangles by their
coordinates, numbers vertices by first use and face tags by first
appearance, and maps the tags back; mesh.tet.generate and the corpus
use it.

Tests: the washer's tet10 mesh has a fixed fingerprint (identical on
arm64 and x86-64 builds); a reordered, renumbered input gives the same
mesh; heavy: rounded-box and filleted-bracket mesh bit for bit the same
on repeated runs and with 1 and 8 threads.

The bracket oracle cases stay unfrozen: SolidCraft's tessellation
itself differs in the last bits between arm64 and x86-64. The coarse
case moves to 203.6 MPa with the canonical order; its band is 5%.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:43:30 -04:00
jyvikoandClaude Opus 5.5 20281e5959 mesh: portable atan2 and cbrt for the quality measures (#115)
The platform maths libraries' atan2 and cbrt differ in the last bits
between macOS and glibc, so the dihedral angles and the shape measure a
mesh reports did too. The crate's own versions use + - * / and sqrt
only (correctly rounded everywhere) and agree with the platform's to a
few ulps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:43:30 -04:00
jyvikoandClaude Opus 5.5 030799dfc8 ui: draw size controls in the viewport, pick their faces (#435)
Faces and bodies under a size control are tinted, a box drawn as its
edges and a sphere as a translucent ball, each labelled with its name
and size. The Local Size form takes the selected faces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:08:27 -04:00
jyvikoandClaude Opus 5.5 b62be1ada6 engine: mesh.size.* commands, honoured by every mesher (#435)
mesh.size.set adds a size control on faces, bodies, a box or a sphere;
mesh.size.growth sets the growth rate; mesh.size.clear and
mesh.size.list. The tet mesher bakes the controls into its graded field,
the hex meshers, cells and the octree take the graded RegionSize, and
the block projection and mesh plans divide their edges by it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:08:27 -04:00
jyvikoandClaude Opus 5.5 c611080182 mesh: block projection divides its edges by a graded size field (#435)
ProjectOptions::field measures each block edge in units of the field,
so the division counts follow the size controls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:08:27 -04:00
jyvikoandClaude Opus 5.5 4d0790565c mesh: the octree roots at a field's largest size and plans depth to its smallest (#435)
SizeField::range gives a field's smallest and largest size. The octree
starts its root cells at the largest instead of the size at the part's
centre, and refuses before building when the smallest needs more levels
than max_depth.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:08:27 -04:00
jyvikoandClaude Opus 5.5 950d86c54e mesh: local sizes in boxes, balls and on surfaces, graded (#435)
RegionSize is the exact graded field the structured meshers query;
the tetrahedral refinement bakes the same local sizes into its graded
background grid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:08:27 -04:00
jyvikoandClaude Opus 5.5 e5ac6d35ce model: size controls and the size field's growth rate (#435)
Named size controls on faces or bodies, in a box or in a sphere, and
the growth rate the field grades at, saved with the model.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:08:27 -04:00
jyvikoandClaude Opus 5.5 85b117ec7c docs: corpus regenerated with the octree's up-front plan
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:40:28 -04:00
jyvikoandClaude Opus 5.5 5a4c501524 mesh: octree chooses its levels and root size before building
The thinnest wall (measured to a facing wall) and narrowest patch give the cell size the grid
needs; the requested size refined that deep comes first, a root shrunk so one level fewer is
exact second; beyond the levels or cells allowed meshing fails at once naming the wall, levels and
cells. max_depth is a cap (default 4); rebuilds stay within the planned levels; lattice positions
no longer depend on the lattice depth; the report gives root size, levels, wall, patch and cells
per level before and after closure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:40:28 -04:00
jyvikoandClaude Opus 5.5 effb5a7a14 mesh: octree feature graph drops creases that separate no patches
A crease inside one face whose ends are not on other features (a tessellation crease across the
crank's r1.5 bore fillets) bounded nothing, so its open ends were corners no region border could
reach and the feature map failed on every build. Patch widths now count bounding curves only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:40:28 -04:00
jyvikoandClaude Opus 5.5 54e367970f 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:40:18 -04:00
jyvikoandClaude Opus 5.5 7cd3dcfaa0 assets: drop the Transition placeholder icon (fix develop CI)
#519 delivered tet-hex transitions through mesh.cells and removed the
'coming soon' mesh.transition catalogue entry, leaving its icon without an
entry (icons::tests::every_catalogue_entry_has_an_icon_and_every_icon_an_entry).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:26:10 -04:00
jyvikoandClaude Opus 5.5 a2e747b9ba mesh: projection and relaxation keep to a merged face's B-rep pieces
A merged face on several surfaces (a sliver absorbed into its neighbour)
had no exact surface, so the projection target fell back to its
triangles: flat curvature probes gave fewer divisions round the boss and
the enclosure base's symmetric mesh dropped from 0.70 to 0.51 once its
absorbs reached the cells. The target now projects a point of such a
face onto the exact surface of the B-rep piece it is on, trimmed to that
piece (Tessellation::with_pieces, Shape::tessellate_pieces); node
relaxation slides on it the same way.

Test from commands: the enclosure base's four slivers absorbed, meshed
whole and by symmetry, the absorbs kept in every cell.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:25:37 -04:00
jyvikoandClaude Opus 5.5 09385f069f geometry: a face merge applies to the cells of a partitioned body
A merge on `Base` failed with "no body `Base`" as soon as partitions cut
the body into cells (mesh.plan.apply's symmetry and corner cuts), so the
plan's cells and the body as opened silently lost every absorb and merge.
Each cell now takes the merge where the pieces of its faces touch (named
after the face kept, else merge:<name>[#k]), and the body as opened keeps
its merges whether partitions cut it or not.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:25:37 -04:00
jyvikoandClaude Opus 5.5 6c79498005 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:25:26 -04:00
jyvikoandClaude Opus 5.5 e8e3fc647c 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:25:26 -04:00
103 changed files with 5325 additions and 558 deletions

No files matched your search

+3 -1
View File
@@ -67,6 +67,8 @@ is committed and listed here with its exports.
| 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.defeature.svg, assets/icons/geometry.defeature.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.defeature.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 |
@@ -103,6 +105,7 @@ is committed and listed here with its exports.
| 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 |
| assets/icons/mesh.size.set.svg, assets/icons/mesh.size.set.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.set.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.seed.edge.svg, assets/icons/mesh.seed.edge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.seed.edge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.smooth.svg, assets/icons/mesh.smooth.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.smooth.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| 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 |
@@ -111,7 +114,6 @@ is committed and listed here with its exports.
| 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 |
| assets/icons/mesh.verify.svg, assets/icons/mesh.verify.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.verify.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/model.activity.svg, assets/icons/model.activity.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.activity.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
Generated
+2
View File
@@ -1418,6 +1418,7 @@ name = "fieldcraft-mesh"
version = "0.6.0"
dependencies = [
"fieldcraft-model",
"libm",
"num-bigint",
"num-rational",
"num-traits",
@@ -1470,6 +1471,7 @@ dependencies = [
"fieldcraft-engine",
"fieldcraft-geometry",
"fieldcraft-mesh",
"fieldcraft-parallel",
"serde_json",
"thiserror 2.0.21",
]
+1 -1
View File
@@ -28,7 +28,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|---|---|---|
| M0 Foundation | Workspace, quality gates, FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft | done |
| M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`fieldcraft-mesh::surface`, `mesh.surface.check`, done) | in progress |
| M2 Tet meshing and size field | Size field (global, curvature and proximity; edge seeds done: `mesh.seed.edge` on persistent design edge names (`<face>|<face>`) with a count or size, geometric bias and an exact, min or free constraint, shown as ticks and under Mesh › Seeds, honoured by the hex sweep, extrude, revolve, mapped, blocks and cells meshers through interval assignment (conflicting exact seeds named) and by the tet mesher, which divides the edge at the seed's nodes and grades its size field from them; per-face seeds to do; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
| M2 Tet meshing and size field | Size field (global, curvature and proximity; edge seeds done: `mesh.seed.edge` on persistent design edge names (`<face>|<face>`) with a count or size, geometric bias and an exact, min or free constraint, shown as ticks and under Mesh › Seeds, honoured by the hex sweep, extrude, revolve, mapped, blocks and cells meshers through interval assignment (conflicting exact seeds named) and by the tet mesher, which divides the edge at the seed's nodes and grades its size field from them; per-face seeds to do; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in; size controls done (#435): `mesh.size.set` on faces, bodies, a box or a sphere with a growth rate (`mesh.size.growth`), saved with the model, listed by `mesh.size.list`, drawn in the viewport, honoured by the tet mesher's graded field, the structured hex meshers' and the block projection's divisions and the octree's depth); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress |
| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started |
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); partitions inside FieldCraft (done: datum planes and axes, `geometry.partition` cuts the open design by a datum plane, kept in the model and replayed when the design opens, the two faces of each cut paired by name); cells meshed each with its own method into one mesh (done: `mesh.cells` — hex where a method fits, else tet; matching interfaces merged node for node, others, such as hex next to tet, tied by dual mortar, verified by a tied hex–tet bar in tension to 1e-6); meshing half a symmetric part and mirroring it (done: `mesh.mirror` reflects hex8/hex20/tet4/tet10/wedge6 across a datum plane, right-handed, nodes on the plane merged, tags carried to the image half; a mirrored half bar equals the whole bar's mesh and bending deflection to 1e-12; samples `crank-arm` and `crank-mirror`: one datum, the half extruded in automatic blocks and mirrored, scaled Jacobian ≥ 0.37; or solving the half: `model.boundary_condition.symmetry` on a datum plane, a half bar with half the load bends as the whole to 1e-11); three-sided sweep regions mapped as triangle primitives (done: midpoint subdivision, dominance constraints in interval assignment); automatic blocks of extruded cross-sections (done: `hex::decompose` — holes separated, O-grids, cuts from concave corners and fillets chosen by block quality, kept only when no worse than the free mesh; nothing added to the model); pyramid transitions; automatic decomposition of 3D parts into sweepable and mappable regions | started |
+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.defeature</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="M9 8 C9 8.828 10.343 9.5 12 9.5 C13.657 9.5 15 8.828 15 8 C15 7.172 13.657 6.5 12 6.5 C10.343 6.5 9 7.172 9 8 Z" fill="#f0b44a" stroke="#f0b44a" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 11 L16.5 5" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 886 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>mesh.size.set</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 3 L21 3 L21 21 L3 21 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 3 L12 21 M3 12 L21 12" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 12 L21 12 L21 21 L12 21 Z" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16.5 12 L16.5 21 M12 16.5 L21 16.5" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.
-10
View File
@@ -1,10 +0,0 @@
<?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.transition</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 4 L21 4 L21 20 L3 20 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 4 L7.5 20 M3 9.33 L12 9.33 M3 14.67 L12 14.67" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 4 L21 9.33 L12 14.67 L21 20 M12 14.67 L21 14.67 M12 4 L21 4" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 4 L12 20" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 816 B

Binary file not shown.
+1
View File
@@ -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": "M9 8 A3 1.5 0 1 0 15 8 A3 1.5 0 1 0 9 8 Z", "fill": "warm", "stroke": "warm", "width": 1}, {"d": "M7.5 11 L16.5 5", "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]}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"rect": [3, 3, 18, 18], "fill": "soft", "stroke": "line"}, {"d": "M12 3 L12 21 M3 12 L21 12", "stroke": "line", "width": 1}, {"rect": [12, 12, 9, 9], "fill": null, "stroke": "accent", "width": 1.5}, {"d": "M16.5 12 L16.5 21 M12 16.5 L21 16.5", "stroke": "accent", "width": 1}]}
-1
View File
@@ -1 +0,0 @@
{"shapes": [{"rect": [3, 4, 18, 16], "fill": "soft", "stroke": "line"}, {"d": "M7.5 4 L7.5 20 M3 9.33 L12 9.33 M3 14.67 L12 14.67", "stroke": "line", "width": 1}, {"d": "M12 4 L21 9.33 L12 14.67 L21 20 M12 14.67 L21 14.67 M12 4 L21 4", "stroke": "line", "width": 1}, {"d": "M12 4 L12 20", "stroke": "accent", "width": 1.75}]}
+24
View File
@@ -309,6 +309,22 @@ pub static CATALOGUE: &[Capability] = &[
"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.defeature",
"Defeature",
Geometry,
"Prepare",
"Take small holes, fillets, chamfers and bosses out for meshing, from a proposal list with each one on or off",
&["geometry.defeature.propose", "geometry.defeature.delete"],
),
covering(
"geometry.stitch",
"Stitch",
@@ -328,6 +344,14 @@ pub static CATALOGUE: &[Capability] = &[
"Element counts or sizes per design edge, with bias and an exact, minimum or free constraint; the meshers honour them",
&["mesh.seed.clear", "mesh.seed.list"],
),
ready_more(
"mesh.size.set",
"Local Size",
Mesh,
"Size",
"Element size on faces or bodies, in a box or in a sphere, growing away at a set rate; every mesher honours it",
&["mesh.size.growth", "mesh.size.clear", "mesh.size.list"],
),
soon("mesh.size.adapt", "Curvature", Mesh, "Size", "M2", "Refine by curvature and proximity"),
ready(
"mesh.tet.generate",
+8 -4
View File
@@ -28,7 +28,6 @@ use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_mesh::cdt::CdtOptions;
use fieldcraft_mesh::hex::{Cell, CellsMesh, HexMesh, HexMethod, HexOptions, hex_mesh_swept_cells};
use fieldcraft_mesh::size::UniformSize;
use fieldcraft_mesh::surface::BoundarySurface;
use fieldcraft_model::geometry::Entity;
use fieldcraft_model::{ElementFace, GeometryRef, Model, Set, Tie};
@@ -173,7 +172,10 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = hex_params.as_object_mut() {
o.remove("order");
}
// The cells are meshed at one size, so the size controls apply to all of them alike.
let sizing = super::sizes::sizing(s)?.joint(&names.iter().map(String::as_str).collect::<Vec<_>>());
let (tet_options, quality) = quality_options(&tet_params, s.limits.max_tets as u64)?;
let tet_options = sizing.tet_options("", &tet_params, &tet_options);
let cdt = CdtOptions { max_steiner: s.limits.max_steiner, max_tets: tet_options.max_tets };
let seeds = super::seeds::edge_seeds(s)?;
@@ -204,7 +206,7 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|&i| shapes.get(i))
.map(|shape| Ok(Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? }))
.collect::<Result<_>>()?;
match swept_together(p, &inputs, m, quadratic, size, tolerance, &seeds)? {
match swept_together(p, &inputs, m, quadratic, size, tolerance, &seeds, &sizing)? {
Ok(cm) => {
for &i in &members {
if let Some(slot) = joint.get_mut(i) {
@@ -229,7 +231,7 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
let input = || Ok::<_, EngineError>(Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? });
let hex = |m: HexMethod| -> Result<(Input<'static>, HexMesh)> {
let b = input()?;
let mesh = hex_body(&hex_params, &b, m, quadratic, size)?;
let mesh = hex_body(&hex_params, &b, m, quadratic, size, &sizing)?;
Ok((b, mesh))
};
let (cell, mut info) = match method {
@@ -363,8 +365,10 @@ fn swept_together(
size: Option<f64>,
tolerance: f64,
seeds: &[fieldcraft_mesh::seeds::EdgeSeed],
sizing: &super::sizes::Sizing,
) -> Result<std::result::Result<CellsMesh, String>> {
let h = size.ok_or_else(|| EngineError::Params("no element size".into()))?;
let field = sizing.field("", h)?;
let mut surfaces = Vec::with_capacity(inputs.len());
for b in inputs {
let options = super::mesh::surface_options(p, &b.mesh)?;
@@ -382,7 +386,7 @@ fn swept_together(
seeds: seeds.to_vec(),
..HexOptions::default()
};
match hex_mesh_swept_cells(&cells, &UniformSize(h), &options) {
match hex_mesh_swept_cells(&cells, &field, &options) {
Ok(cm) => Ok(Ok(cm)),
Err(fieldcraft_mesh::Error::Model(fieldcraft_model::Error::Cancelled)) => Err(EngineError::Model(fieldcraft_model::Error::Cancelled)),
Err(e @ fieldcraft_mesh::Error::Limit(_)) => Err(EngineError::Params(e.to_string())),
+20 -6
View File
@@ -470,15 +470,29 @@ fn cuts(model: &Model) -> Vec<Cut> {
.collect()
}
/// `design` cut by `model`'s partitions, then with its face merges (the design as opened when
/// the model has neither).
/// `design` without the features `model` defeatures, cut by its partitions, then with its face
/// merges (after those of the features defeatured virtually) 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> {
let merges: Vec<Merge> = model
.face_merges()
let defeatures: Vec<fieldcraft_geometry::defeature::Defeature> =
model.defeatures().iter().map(|d| fieldcraft_geometry::defeature::Defeature { name: d.name.clone(), features: d.features.clone() }).collect();
let (design, mut merges) = design.defeatured(&defeatures)?;
merges.extend(model.face_merges().iter().map(|m| Merge {
name: m.name.clone(),
body: m.body.clone(),
faces: m.faces.clone(),
into: m.into.clone(),
}));
let splits: Vec<fieldcraft_geometry::face_split::FaceSplit> = model
.face_splits()
.iter()
.map(|m| Merge { name: m.name.clone(), body: m.body.clone(), faces: m.faces.clone(), into: m.into.clone() })
.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)?)
Ok(design.partitioned(&cuts(model))?.merged(&merges)?.split(&splits)?)
}
/// Cut the session's design by its model's partitions again (and merge its faces again). The bodies that are gone (cells
+352
View File
@@ -0,0 +1,352 @@
//! Defeaturing for meshing: `geometry.defeature` takes holes, fillets, chamfers and small bosses
//! out of the open design by suppressing their SolidCraft features (virtually, as a face merge,
//! when a feature cannot be suppressed on its own), and `geometry.defeature.propose` lists the
//! candidates of a body against size thresholds and the last geometry.check.
//!
//! A defeature is part of the model (`fieldcraft_model::Defeature`, by feature id); the geometry
//! layer applies it to the design as opened (`fieldcraft_geometry::defeature`), before the
//! partitions, whenever the design is opened or cut again ([`super::datum::partitioned`]), so the
//! meshers, the viewport and the checks all see the part without them.
use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_geometry::defeature::{Candidate, candidate};
use fieldcraft_model::Defeature;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::requirement::Requirement;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const DEFEATURE: CommandSpec = CommandSpec {
id: "geometry.defeature",
label: "Defeature",
params: r#"{"name": "Small", "features": [3, "F7"] (feature ids, as geometry.features and geometry.defeature.propose list them; or the text "3, F7")} — takes features of the open design out for meshing: the design is rebuilt without them, as SolidCraft builds it with them suppressed (a hole filled, a fillet or chamfer back to its sharp edge, a boss gone), and the other faces keep their persistent names. A feature that cannot be suppressed alone (a later feature needs it, or a body would go) is defeatured virtually instead: its faces are merged into the neighbour they share the longest edges with, for meshing only (virtual topology; the B-rep keeps it). A feature that builds a body or a sketch, or one the design lacks, is an error. Kept in the model by feature id and applied again whenever the design is opened (before the partitions); the same name again replaces its features. A suppressed feature affects every body it shaped. The bodies lose their meshes (`replaced`; remesh them); sets naming their faces are `stale_sets`. Replies `suppressed` (feature ids), `virtual` ([{feature, reason}]) and `bodies`. One undo step; geometry.defeature.delete turns them back on"#,
form: Some(super::forms::DEFEATURE),
kind: CommandKind::Model,
requires: &[Requirement::Design],
run: defeature,
};
pub const DEFEATURE_DELETE: CommandSpec = CommandSpec {
id: "geometry.defeature.delete",
label: "Delete Defeature",
params: r#"{"name": "Small"} — turns a defeature's features back on: the design is rebuilt with them; the bodies they shaped lose their meshes (`replaced`)"#,
form: Some(super::forms::DEFEATURE_DELETE),
kind: CommandKind::Model,
requires: &[Requirement::Defeature],
run: delete,
};
pub const DEFEATURE_PROPOSE: CommandSpec = CommandSpec {
id: "geometry.defeature.propose",
label: "Propose Defeaturing",
params: r#"{"body": "Base", "below"?: {"hole_diameter"?: 6, "fillet_radius"?: 2, "chamfer"?: 1, "boss_height"?: 2} (mm), "from_check"?: false, "name"?: "Defeature"} — lists the features of a body (as opened) that defeaturing may take out (read-only): holes (a hole, or a cut of a circle or cylinder) by diameter, fillets by radius, chamfers by distance, bosses (a join of an extrude, cylinder or box) by height. A candidate is `proposed` when it is smaller than its `below` threshold, or with `from_check` when one of its faces is a finding of the last geometry.check (still marked). Replies `candidates` ([{id, feature, name, type, category, size, faces, proposed, suppressed, by, method}]: `suppressed` when a defeature takes it out, `by` that defeature, `method` suppressed or virtual), `proposal` (the proposed ids) and `name`, the defeature the panel's on/off switches edit (geometry.defeature with that name)"#,
form: Some(super::forms::DEFEATURE_PROPOSE),
kind: CommandKind::Model,
requires: &[Requirement::Design],
run: propose,
};
/// A feature id: `3` or `"F3"`.
#[derive(Deserialize)]
#[serde(untagged)]
enum FeatureId {
Id(u64),
Name(String),
}
/// The feature ids of `features`: a list, or the form's text (`3, F7`).
fn ids(p: &Value) -> Result<Vec<u64>> {
let given: Vec<FeatureId> = match p.get("features") {
Some(Value::String(t)) if t.len() <= 64 * 1024 => {
t.split([',', ' ', ';']).filter(|w| !w.is_empty()).map(|w| FeatureId::Name(w.to_string())).collect()
}
_ => super::req(p, "features")?,
};
if given.is_empty() || given.len() > fieldcraft_model::virtual_faces::MAX_DEFEATURE_FEATURES {
return Err(EngineError::invalid("features", format!("1 to {} feature ids", fieldcraft_model::virtual_faces::MAX_DEFEATURE_FEATURES)));
}
let mut out = Vec::with_capacity(given.len());
for f in given {
let id = match f {
FeatureId::Id(id) => Some(id),
FeatureId::Name(n) => n.strip_prefix('F').unwrap_or(&n).parse().ok(),
};
match id {
Some(id) if !out.contains(&id) => out.push(id),
Some(_) => {}
None => return Err(EngineError::invalid("features", "feature ids: 3 or \"F3\"")),
}
}
Ok(out)
}
/// The bodies of the open design (as opened) that features `ids` shaped.
fn shaped(s: &Session, ids: &BTreeSet<u64>) -> Vec<String> {
let Some(design) = s.design.as_deref() else { return Vec::new() };
let graph = design.undefeatured().features(None).unwrap_or_default();
let sources: Vec<String> = graph.into_iter().filter(|b| b.features.iter().any(|f| ids.contains(&f.id))).map(|b| b.body).collect();
// A body the partitions cut: each of its cells.
design.bodies.iter().map(|b| b.name.clone()).filter(|n| design.source_of(n).is_some_and(|src| sources.iter().any(|x| x == src))).collect()
}
/// What the design's defeatures did with the features of `name`, as the reply lists them.
fn outcome(s: &Session, name: &str) -> (Vec<u64>, Vec<Value>) {
let report = s.design.as_deref().map(|d| d.defeat_report().to_vec()).unwrap_or_default();
let mine = report.into_iter().filter(|r| r.defeature == name);
let (mut suppressed, mut virt) = (Vec::new(), Vec::new());
for r in mine {
if r.method == "suppressed" {
suppressed.push(r.feature);
} else {
virt.push(json!({"feature": r.feature, "reason": r.reason}));
}
}
(suppressed, virt)
}
fn defeature(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?.to_string();
let features = ids(p)?;
let before: BTreeSet<u64> = s.model.defeatures().iter().filter(|d| d.name == name).flat_map(|d| d.features.clone()).collect();
s.model.set_defeature(Defeature { name: name.clone(), features: features.clone() })?;
let mut touched = before;
touched.extend(&features);
let bodies = shaped(s, &touched);
let merges: Vec<String> = features.iter().map(|id| format!("merge {name}-F{id}")).collect();
let mut errors = vec![format!("defeature {name}")];
errors.extend(merges);
let reply = super::virtual_faces::reapply(s, &errors, &bodies, json!({"name": name}))?;
let (suppressed, virt) = outcome(s, &name);
let bodies = shaped(s, &touched);
let mut reply = reply;
if let Some(o) = reply.as_object_mut() {
o.insert("suppressed".into(), json!(suppressed));
o.insert("virtual".into(), json!(virt));
o.insert("bodies".into(), json!(bodies));
}
Ok(reply)
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?;
let removed = s.model.remove_defeature(name)?;
let bodies = shaped(s, &removed.features.iter().copied().collect());
let mut cleared = super::datum::recut(s)?;
cleared.extend(super::replace::clear(&mut s.model, bodies.iter().map(String::as_str)));
Ok(cleared.report_if_any(s, json!({"deleted": name, "features": removed.features, "bodies": bodies})))
}
/// Size thresholds of a proposal (mm).
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct Below {
hole_diameter: Option<f64>,
fillet_radius: Option<f64>,
chamfer: Option<f64>,
boss_height: Option<f64>,
}
impl Below {
fn of(&self, category: &str) -> Option<f64> {
match category {
"hole" => self.hole_diameter,
"fillet" => self.fillet_radius,
"chamfer" => self.chamfer,
"boss" => self.boss_height,
_ => None,
}
}
}
fn propose(s: &mut Session, p: &Value) -> Result<Value> {
let body: String = super::req(p, "body")?;
let below: Below = super::opt(p, "below")?.unwrap_or_default();
for (k, v) in [
("hole_diameter", below.hole_diameter),
("fillet_radius", below.fillet_radius),
("chamfer", below.chamfer),
("boss_height", below.boss_height),
] {
if v.is_some_and(|v| !(0.0..=fieldcraft_model::MAX_COORD).contains(&v)) {
return Err(EngineError::invalid("below", format!("`{k}` is a length of 0 or more")));
}
}
let from_check: bool = super::opt(p, "from_check")?.unwrap_or(false);
let name = match p.get("name") {
None | Some(Value::Null) => "Defeature".to_string(),
Some(_) => super::name(p)?.to_string(),
};
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
let source = design
.source_of(&body)
.ok_or_else(|| EngineError::Params(format!("the open design has no body `{}`", body.chars().take(200).collect::<String>())))?;
let graph = design.undefeatured().features(Some(source))?;
// The faces the last check marked on the body (merged faces by their B-rep faces).
let mut flagged: BTreeSet<String> = BTreeSet::new();
if from_check {
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()))?;
for (b, i) in marks.marks.iter().flat_map(|m| m.faces.iter()) {
if design.source_of(b) != Some(source) {
continue;
}
if let Some(f) = design.body(b).and_then(|shape| shape.faces().get(*i as usize)) {
flagged.insert(f.name.split('#').next().unwrap_or(&f.name).to_string());
flagged.extend(f.members.iter().cloned());
}
}
}
// Which defeature takes each feature out, and how.
let by: BTreeMap<u64, (String, &'static str)> = design.defeat_report().iter().map(|r| (r.feature, (r.defeature.clone(), r.method))).collect();
let mut candidates = Vec::new();
let mut proposal = Vec::new();
for c in graph.iter().flat_map(|b| b.features.iter()).filter_map(candidate) {
let Candidate { id, name: fname, kind, category, size, faces } = c;
let small = below.of(category).is_some_and(|t| size < t);
let hit = faces.iter().any(|f| flagged.contains(f));
let proposed = small || hit;
if proposed {
proposal.push(id);
}
let taken = by.get(&id);
candidates.push(json!({
"id": id,
"feature": format!("F{id}"),
"name": fname,
"type": kind,
"category": category,
"size": size,
"faces": faces,
"proposed": proposed,
"suppressed": taken.is_some(),
"by": taken.map(|t| t.0.clone()),
"method": taken.map(|t| t.1),
}));
}
let off = candidates.iter().filter(|c| c["suppressed"] == true).count();
let summary = format!(
"{} on `{source}`: {} proposed, {} defeatured",
match candidates.len() {
1 => "1 candidate".to_string(),
n => format!("{n} candidates"),
},
proposal.len(),
off
);
Ok(json!({"body": source, "name": name, "candidates": candidates, "proposal": proposal, "summary": summary}))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
fn faces(s: &Session) -> usize {
s.design.as_ref().unwrap().bodies[0].faces().len()
}
fn hole(s: &mut Session) -> u64 {
let body = s.design.as_ref().unwrap().bodies[0].name.clone();
let r = s.execute("geometry.defeature.propose", &json!({"body": body, "below": {"hole_diameter": 12}})).unwrap();
assert_eq!(r["proposal"].as_array().map(Vec::len), Some(1), "{r}");
r["proposal"][0].as_u64().unwrap()
}
/// The sample plate: its hole is proposed below 12 mm, suppressing it gives a block meshed
/// without it (tet and hex), undo and redo take it out and put it back, it is saved and
/// replayed on open, and deleting the defeature brings the hole back.
#[test]
fn the_plate_hole_is_suppressed_meshed_undone_saved_and_restored() {
let (mut s, dir) = super::super::datum::tests::plate();
let body = s.design.as_ref().unwrap().bodies[0].name.clone();
assert_eq!(faces(&s), 8);
let none = s.execute("geometry.defeature.propose", &json!({"body": body, "below": {"hole_diameter": 5}})).unwrap();
assert_eq!((none["candidates"].as_array().map(Vec::len), none["proposal"].as_array().map(Vec::len)), (Some(1), Some(0)), "{none}");
let id = hole(&mut s);
s.execute("mesh.tet.generate", &json!({"body": body, "size": 4})).unwrap();
let with_hole = s.model.elements().len();
let r = s.execute("geometry.defeature", &json!({"name": "Holes", "features": [id]})).unwrap();
assert_eq!(r["suppressed"], json!([id]), "{r}");
assert_eq!(r["replaced"][0]["body"].as_str(), Some(body.as_str()), "{r}");
assert_eq!(faces(&s), 6);
// The proposal shows it off, by the defeature.
let p = s.execute("geometry.defeature.propose", &json!({"body": body})).unwrap();
assert_eq!((p["candidates"][0]["suppressed"].as_bool(), p["candidates"][0]["by"].as_str()), (Some(true), Some("Holes")), "{p}");
// Meshed without it: a tet mesh fills the whole block, and the block sweeps into hexes.
s.execute("mesh.tet.generate", &json!({"body": body, "size": 4})).unwrap();
let volume = |s: &Session| s.design.as_ref().unwrap().bodies[0].measure().unwrap().volume;
assert!((volume(&s) - 40.0 * 30.0 * 10.0).abs() < 1e-6, "{}", volume(&s));
assert_ne!(s.model.elements().len(), with_hole);
s.execute("mesh.hex.sweep", &json!({"body": body, "size": 5})).unwrap();
assert!(s.model.elements().values().all(|e| e.nodes.len() == 8), "a block sweeps into hexes only");
// Undo the meshes and the defeature: the hole is back; redo takes it out again.
while !s.model.defeatures().is_empty() {
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(faces(&s), 8);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(faces(&s), 6);
// Saved, and replayed on open of the model and on a re-open of the design.
let file = dir.join("defeatured.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.defeatures().len(), faces(&t)), (1, 6));
let design = s.sources.design.as_ref().unwrap().path().to_string();
s.execute("geometry.open", &json!({"path": design})).unwrap();
assert_eq!(faces(&s), 6);
// Switched back on.
let r = s.execute("geometry.defeature.delete", &json!({"name": "Holes"})).unwrap();
assert_eq!(r["features"], json!([id]), "{r}");
assert_eq!(faces(&s), 8);
std::fs::remove_dir_all(dir).ok();
}
/// Unknown features, body-building ones and bad ids are refused and leave the model as it was.
#[test]
fn bad_features_are_refused() {
let (mut s, dir) = super::super::datum::tests::plate();
for features in [json!([999]), json!(["Fx"]), json!([]), json!("F1, x"), json!(7)] {
assert!(s.execute("geometry.defeature", &json!({"name": "Bad", "features": features})).is_err(), "{features}");
}
let body = s.design.as_ref().unwrap().bodies[0].name.clone();
let base = s.execute("geometry.features", &json!({"body": body})).unwrap()["bodies"][0]["features"][0]["id"].as_u64().unwrap();
let e = s.execute("geometry.defeature", &json!({"name": "Bad", "features": [base]})).unwrap_err();
assert!(e.to_string().contains("builds a body"), "{e}");
assert!(s.model.defeatures().is_empty() && faces(&s) == 8);
for below in [json!({"hole_diameter": -1}), json!({"hole_diameter": "x"}), json!({"nope": 1})] {
assert!(s.execute("geometry.defeature.propose", &json!({"body": body, "below": below})).is_err(), "{below}");
}
assert!(s.execute("geometry.defeature.propose", &json!({"body": "Nope"})).is_err());
std::fs::remove_dir_all(dir).ok();
}
/// The bracket's 6 mm fillet is proposed below 8 mm, defeatured back to a sharp corner and
/// meshed; its holes stay.
#[test]
fn a_bracket_is_meshed_with_its_small_fillet_defeatured() {
let (_dir, mut s) = super::super::datum::tests::scripted(json!([
{"command": "sketch.create", "params": {"plane": "XY", "name": "Profile"}},
{"command": "sketch.line", "params": {"points": [[0, 0], [60, 0], [60, 8], [8, 8], [8, 50], [0, 50]], "closed": true}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 40, "body_name": "Bracket"}},
{"command": "solid.fillet", "params": {"edges": [[8, 8, 20]], "radius": 6}},
{"command": "solid.hole", "params": {"position": [40, 8, 20], "diameter": 8}},
{"command": "solid.hole", "params": {"position": [8, 32, 20], "diameter": 8}}
]));
let p = s.execute("geometry.defeature.propose", &json!({"body": "Bracket", "below": {"fillet_radius": 8}})).unwrap();
let cats: Vec<&str> = p["candidates"].as_array().unwrap().iter().filter_map(|c| c["category"].as_str()).collect();
assert_eq!(cats, ["fillet", "hole", "hole"], "{p}");
let fillet = p["proposal"].as_array().unwrap().clone();
assert_eq!(fillet.len(), 1);
let before = faces(&s);
let r = s.execute("geometry.defeature", &json!({"name": "Fillets", "features": fillet})).unwrap();
assert_eq!(r["suppressed"], json!(fillet), "{r}");
assert_eq!(faces(&s), before - 1);
let r = s.execute("mesh.tet.generate", &json!({"body": "Bracket", "size": 4})).unwrap();
assert!(r["elements"].as_u64().unwrap_or(0) > 0, "{r}");
}
}
+67 -2
View File
@@ -1246,7 +1246,9 @@ pub const HEX_OCTREE: &[Field] = &[
SURFACE,
HEX_SIZE,
TOLERANCE,
int("max_depth", "Refinement levels", 0, 5).help("How many times the octree may refine near the surface.").hint("3"),
int("max_depth", "Refinement levels", 0, 5)
.help("The most times the octree may refine near thin walls, narrow faces and curves; the levels the part needs are found before meshing.")
.hint("4"),
int("max_elements", "Max elements", 1, 10_000_000).help("Cap on the elements created."),
int("attempts", "Attempts", 1, 8).help("How many times to retry with adjusted settings.").hint("4"),
int("max_sweeps", "Optimiser sweeps", 0, 10_000).help("The most smoothing sweeps.").hint("300"),
@@ -1425,7 +1427,10 @@ pub const NODE_RELAX: &[Field] = &[
.help("Guarded: each move keeps the worst element around the node from getting worse. Strong: move the nodes of the bad elements and morph the mesh round them, nodes on CAD edges held.")
.def("\"guarded\""),
range("below", "Below", D::None, 0.0, 1.0).help("Strong: the elements below this scaled Jacobian are moved.").hint("0.7"),
positive("reach", "Reach", D::Length).help("Strong: nodes this close to a moved node follow it, blended to zero at the reach.").hint("three element lengths"),
positive("reach", "Reach", D::Length).help("Strong: nodes this close to a moved node follow it, blended to zero at the reach.").hint("five element lengths"),
choice("guards", "Keep a morph", &[("valid", "Unless an element inverts"), ("strict", "Only when it improves the elements")])
.help("Strong: valid keeps every morph that inverts no element; strict keeps one only when the worst element it touches improves and no element gets longer.")
.hint("valid"),
int("iterations", "Iterations", 1, 200).help("Guarded: sweeps over the nodes (1 to 100). Strong: morph rounds.").hint("5 (strong: 20)"),
range("strength", "Strength", D::None, 0.0, 1.0).help("How far each node moves toward its relaxed position, 0 to 1.").def("1"),
choice("falloff", "Falloff", &[("smooth", "Smooth"), ("linear", "Linear"), ("none", "None")])
@@ -1478,6 +1483,37 @@ pub const SEED_EDGE: &[Field] = &[
pub const SEED_CLEAR: &[Field] = &[json("edges", "Edges").help("The edges to clear, as JSON; every seed when empty.").hint("every seed")];
pub const SIZE_SET: &[Field] = &[
NAME,
positive("size", "Element size", D::Length).help("The element size in the region.").req(),
face_rows("faces", "Faces").help("Faces to refine. Pick them, then Use as faces.").hint("or bodies, a box or a sphere"),
references("bodies", "Bodies", C::Body).help("Whole bodies meshed at this size.").hint("or faces, a box or a sphere"),
group(
"box",
"Box",
&[
xyz("min", "Box min", D::Length).help("The box corner with the smallest coordinates.").req(),
xyz("max", "Box max", D::Length).help("The box corner with the largest coordinates.").req(),
],
)
.help("An axis-aligned box to refine."),
group(
"sphere",
"Sphere",
&[
xyz("center", "Centre", D::Length).help("The sphere's centre; click the model to place it.").req(),
positive("radius", "Radius", D::Length).help("The sphere's radius.").req(),
],
)
.help("A sphere to refine."),
];
pub const SIZE_GROWTH: &[Field] =
&[range("rate", "Growth rate", D::None, 0.05, 2.0).help("The largest change of element size per unit length away from a local size.").def("0.3")];
pub const SIZE_CLEAR: &[Field] =
&[references("names", "Local sizes", C::SizeControl).help("The size controls to remove; every one when empty.").hint("every one")];
pub const DELAUNAY_POINTS: &[Field] = &[points("points", "Points", D::Length, 20_000).help("The points to triangulate.").req()];
pub const TAG: &[Field] = &[
@@ -1740,7 +1776,36 @@ pub const STITCH: &[Field] = &[
.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 DEFEATURE: &[Field] = &[
NAME,
text("features", "Features")
.help("The feature ids to take out for meshing (3 or F3), as Propose Defeaturing and the feature graph list them; separate them with commas.")
.req(),
];
pub const DEFEATURE_DELETE: &[Field] = existing!("Defeature", C::Defeature);
pub const DEFEATURE_PROPOSE: &[Field] = &[
reference("body", "Body", C::Body).help("The body whose holes, fillets, chamfers and bosses to list.").req(),
group(
"below",
"Propose below",
&[
positive("hole_diameter", "Hole diameter", D::Millimetre).help("Propose holes narrower than this."),
positive("fillet_radius", "Fillet radius", D::Millimetre).help("Propose fillets with a smaller radius."),
positive("chamfer", "Chamfer", D::Millimetre).help("Propose chamfers smaller than this."),
positive("boss_height", "Boss height", D::Millimetre).help("Propose bosses lower than this."),
],
)
.help("Size thresholds: a feature below its threshold is proposed."),
flag("from_check", "From check").help("Also propose the features with a face the last Check Geometry found."),
text("name", "Defeature").help("The defeature the list's on/off switches edit.").hint("Defeature"),
];
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>"),
+28 -12
View File
@@ -5,18 +5,18 @@ use std::borrow::Cow;
use fieldcraft_geometry::{FaceSurface, Shape, SurfaceMesh};
use fieldcraft_mesh::hex::{BoundaryFace, Cell, HexMesh, HexMethod, HexOptions, MAX_CELLS, Surface, hex_mesh, hex_mesh_cells};
use fieldcraft_mesh::size::UniformSize;
use fieldcraft_mesh::surface::BoundarySurface;
use fieldcraft_model::{GeometryRef, Model};
use serde_json::{Value, json};
use super::sizes::Sizing;
use crate::requirement::Requirement;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const HEX_GENERATE: CommandSpec = CommandSpec {
id: "mesh.hex.generate",
label: "Hex Mesh",
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with structured hexahedra: multi-level sweeps along a direction or around an axis (their cross-section cut into mappable blocks automatically: O-grids round holes, triangle primitives, four-sided blocks; nothing is added to the model), one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Edge seeds (mesh.seed.edge) set the counts and spacing of their edges: exact seeds are held, and seeds that cannot all hold (opposite sides of a mapped face, the layers between two levels) are an error naming them. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#,
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with structured hexahedra: multi-level sweeps along a direction or around an axis (their cross-section cut into mappable blocks automatically: O-grids round holes, triangle primitives, four-sided blocks; nothing is added to the model), one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Edge seeds (mesh.seed.edge) set the counts and spacing of their edges: exact seeds are held, and seeds that cannot all hold (opposite sides of a mapped face, the layers between two levels) are an error naming them. Size controls (mesh.size.set) set the division counts and spacing of the other edges through the graded size field (a structured mesh carries a count across its block, so a control refines the whole layer or row it lies in). Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#,
form: Some(super::forms::HEX_GENERATE),
kind: CommandKind::Model,
requires: &[Requirement::Geometry],
@@ -42,6 +42,9 @@ pub(super) struct Input<'a> {
/// The design's edge seeds ([`super::seeds::edge_seeds`]); the mesher applies those on its
/// edges.
pub(super) seeds: Vec<fieldcraft_mesh::seeds::EdgeSeed>,
/// Per triangle of `mesh`, the B-rep face it lies on: its piece of a merged face (empty for
/// a surface that is not a body's).
pub(super) pieces: Vec<u32>,
}
fn analytic(s: FaceSurface) -> Surface {
@@ -73,7 +76,8 @@ pub(super) fn shape_input(shape: &Shape, tolerance: Option<f64>) -> Result<Input
}
let surfaces = shape.face_surfaces()?.into_iter().map(|f| f.map(analytic)).collect();
let face_names = shape.faces().iter().map(|f| f.name.clone()).collect();
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(shape.tessellate(tol)?), surfaces, face_names, diagonal: diag, seeds: Vec::new() })
let (mesh, pieces) = shape.tessellate_pieces(tol)?;
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(mesh), surfaces, face_names, diagonal: diag, seeds: Vec::new(), pieces })
}
/// The imported surface `surface` names, with the body name its mesh is tagged with.
@@ -109,7 +113,15 @@ pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
}
// Faces named by index, as mesh.tet.generate names them.
let face_names = (0..t.faces).map(|f| f.to_string()).collect();
Ok(vec![Input { name, mesh: Cow::Borrowed(&t.mesh), surfaces: Vec::new(), face_names, diagonal: diagonal(lo, hi), seeds: Vec::new() }])
Ok(vec![Input {
name,
mesh: Cow::Borrowed(&t.mesh),
surfaces: Vec::new(),
face_names,
diagonal: diagonal(lo, hi),
seeds: Vec::new(),
pieces: Vec::new(),
}])
}
(None, None) => {
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first, or give `surface`".into()))?;
@@ -146,7 +158,7 @@ pub const HEX_MAPPED: CommandSpec = CommandSpec {
pub const HEX_BLOCKS: CommandSpec = CommandSpec {
id: "mesh.hex.blocks",
label: "Block Hex Mesh",
params: r#"{"bodies": ["Cell1", "Cell2"] (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 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#,
params: r#"{"bodies": ["Cell1", "Cell2"] (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 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets, size controls (mesh.size.set; one on a whole cell refines its surface) setting the counts and spacing through the graded size field. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#,
form: Some(super::forms::HEX_BLOCKS),
kind: CommandKind::Model,
requires: &[Requirement::Design],
@@ -220,7 +232,9 @@ pub(super) fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
seeds: super::seeds::edge_seeds(s)?,
..HexOptions::default()
};
let cm = hex_mesh_cells(&cells, &UniformSize(h), &options).map_err(|e| match e {
let names: Vec<&str> = inputs.iter().map(|b| b.name.as_str()).collect();
let field = super::sizes::sizing(s)?.joint(&names).field("", h)?;
let cm = hex_mesh_cells(&cells, &field, &options).map_err(|e| match e {
fieldcraft_mesh::Error::Model(fieldcraft_model::Error::Cancelled) => EngineError::Model(fieldcraft_model::Error::Cancelled),
e => EngineError::Params(e.to_string()),
})?;
@@ -285,9 +299,10 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
})?,
};
let bodies = inputs(s, p)?;
let sizing = super::sizes::sizing(s)?;
let mut meshes: Vec<(Input, HexMesh)> = Vec::with_capacity(bodies.len());
for b in bodies {
let mesh = hex_body(p, &b, method, quadratic, size)?;
let mesh = hex_body(p, &b, method, quadratic, size, &sizing)?;
meshes.push((b, mesh));
}
// Replace the bodies' meshes (replace.rs); other bodies and the definitions stay.
@@ -315,13 +330,13 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
Ok(cleared.report(s, json!({"bodies": report, "nodes": nodes, "elements": elements})))
}
/// Mesh one body with `method` (hex20 when `quadratic`) at `size` (default: from its extent);
/// the model is not touched. Errors name the body.
pub(super) fn hex_body(p: &Value, b: &Input, method: HexMethod, quadratic: bool, size: Option<f64>) -> Result<HexMesh> {
/// Mesh one body with `method` (hex20 when `quadratic`) at `size` (default: from its extent) and
/// the size controls of `sizing`; the model is not touched. Errors name the body.
pub(super) fn hex_body(p: &Value, b: &Input, method: HexMethod, quadratic: bool, size: Option<f64>, sizing: &Sizing) -> Result<HexMesh> {
if !(b.diagonal > 0.0 && b.diagonal.is_finite()) {
return Err(EngineError::Params(format!("body `{}` has no extent", b.name)));
}
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal);
let h = sizing.body_size(&b.name, size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal));
if h < MIN_RELATIVE_SIZE * b.diagonal {
return Err(EngineError::Params(format!("`size` must be at least {} mm for body `{}`", MIN_RELATIVE_SIZE * b.diagonal, b.name)));
}
@@ -337,7 +352,8 @@ pub(super) fn hex_body(p: &Value, b: &Input, method: HexMethod, quadratic: bool,
seeds: b.seeds.clone(),
..HexOptions::default()
};
hex_mesh(&surface, &b.surfaces, &UniformSize(h), &options).map_err(|e| EngineError::of_body(&b.name, e))
let field = sizing.field(&b.name, h)?;
hex_mesh(&surface, &b.surfaces, &field, &options).map_err(|e| EngineError::of_body(&b.name, e))
}
/// Add a mesh of several cells (`inputs[c]` is cell `c`) after the model's largest labels, its
+35 -19
View File
@@ -7,6 +7,7 @@ use fieldcraft_mesh::cdt::{CdtOptions, tetrahedralize};
use fieldcraft_mesh::delaunay::Triangulation;
use fieldcraft_mesh::quality::{self, QualityReport};
use fieldcraft_mesh::surface::{BoundarySurface, Defect, FeatureKind, SurfaceOptions};
use fieldcraft_mesh::tet::canonical::CanonicalSoup;
use fieldcraft_mesh::tet::geometry::AnalyticSurface;
use fieldcraft_mesh::tet::improve::ImproveOptions;
use fieldcraft_mesh::tet::{Order, QualityMesh, QualityOptions, TetQuality, quality_mesh, raw_mesh};
@@ -127,7 +128,7 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec {
pub const TET_GENERATE: CommandSpec = CommandSpec {
id: "mesh.tet.generate",
label: "Generate Tet Mesh",
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Replaces the mesh of each body it meshes (its elements and the nodes only they use; other bodies' meshes stay; the reply's `replaced`, `stale_sets` and `results_cleared` say what went), adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Edge seeds (mesh.seed.edge) divide their edges at the seeded nodes, and the size field grades from them. Reports the totals and, under `bodies`, each body's counts, size, quality report and `seeds` (per seeded edge, the mesh edges along it)"#,
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2; default the model's growth rate, mesh.size.growth), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Replaces the mesh of each body it meshes (its elements and the nodes only they use; other bodies' meshes stay; the reply's `replaced`, `stale_sets` and `results_cleared` say what went), adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Edge seeds (mesh.seed.edge) divide their edges at the seeded nodes, and the size field grades from them; size controls (mesh.size.set) refine it, graded at the model's growth rate (mesh.size.growth) unless `grading` is given. Reports the totals and, under `bodies`, each body's counts, size, quality report and `seeds` (per seeded edge, the mesh edges along it)"#,
form: Some(super::forms::TET),
kind: CommandKind::Model,
requires: &[Requirement::Geometry],
@@ -216,21 +217,30 @@ pub(super) fn quality_options(p: &Value, max_tets: u64) -> Result<(QualityOption
/// The exact surfaces of a body's faces, for the quality stage's projections (none when the
/// kernel cannot give them: the stage then keeps to the tessellation).
pub fn analytic_surfaces(shape: &fieldcraft_geometry::Shape) -> Vec<Option<AnalyticSurface>> {
shape.face_surfaces().unwrap_or_default().into_iter().map(|f| f.map(analytic_surface)).collect()
}
fn analytic_surface(f: fieldcraft_geometry::FaceSurface) -> AnalyticSurface {
use fieldcraft_geometry::FaceSurface as F;
shape
.face_surfaces()
.unwrap_or_default()
.into_iter()
.map(|f| {
f.map(|f| match f {
F::Plane { normal, point } => AnalyticSurface::Plane { normal, point },
F::Cylinder { origin, axis, radius } => AnalyticSurface::Cylinder { origin, axis, radius },
F::Cone { apex, axis, half_angle } => AnalyticSurface::Cone { apex, axis, half_angle },
F::Sphere { center, radius } => AnalyticSurface::Sphere { center, radius },
F::Torus { center, axis, major, minor } => AnalyticSurface::Torus { center, axis, major, minor },
})
})
.collect()
match f {
F::Plane { normal, point } => AnalyticSurface::Plane { normal, point },
F::Cylinder { origin, axis, radius } => AnalyticSurface::Cylinder { origin, axis, radius },
F::Cone { apex, axis, half_angle } => AnalyticSurface::Cone { apex, axis, half_angle },
F::Sphere { center, radius } => AnalyticSurface::Sphere { center, radius },
F::Torus { center, axis, major, minor } => AnalyticSurface::Torus { center, axis, major, minor },
}
}
/// The B-rep pieces of `shape`'s merged faces (virtual topology) for a projection target built
/// on its tessellation whose triangles lie on the B-rep faces `tri_piece`
/// ([`fieldcraft_geometry::Shape::tessellate_pieces`]): a merged face on no one surface then
/// projects onto each piece's own; none when no face is merged.
pub(crate) fn pieces(shape: &fieldcraft_geometry::Shape, tri_piece: &[u32]) -> fieldcraft_mesh::tet::geometry::Pieces {
if !shape.has_merged_faces() {
return fieldcraft_mesh::tet::geometry::Pieces::default();
}
let analytic = shape.brep_face_surfaces().unwrap_or_default().into_iter().map(|f| f.map(analytic_surface)).collect();
fieldcraft_mesh::tet::geometry::Pieces { tri_piece: tri_piece.to_vec(), analytic }
}
pub(super) fn quality_json(q: &TetQuality) -> Value {
@@ -279,15 +289,19 @@ pub(super) fn mesh_body(
Some(_) => Vec::new(),
};
fieldcraft_model::progress::stage(&format!("{}: boundary", input.name), None, "");
let surface = BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &surface_options(p, &input.mesh)?)?;
// The mesh depends on the geometry only, not on the order the kernel lists it in.
let soup = CanonicalSoup::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face)?;
let surface = BoundarySurface::new(&soup.positions, &soup.triangles, &soup.tri_face, &surface_options(p, &input.mesh)?)?;
fieldcraft_model::progress::stage(&format!("{}: Delaunay", input.name), None, "");
let tets = tetrahedralize(&surface, cdt)?;
let mesh = if quality {
let options = QualityOptions { seeds: input.seeds.clone(), ..options.clone() };
quality_mesh(&surface, &tets, analytic, &options)?
let mut mesh = if quality {
let frozen_faces = options.frozen_faces.iter().filter_map(|&f| soup.canonical_tag(f)).collect();
let options = QualityOptions { seeds: input.seeds.clone(), frozen_faces, ..options.clone() };
quality_mesh(&surface, &tets, soup.analytic(&analytic), &options)?
} else {
raw_mesh(&surface, &tets)?
};
soup.restore_tags(&mut mesh);
Ok(BodyTets {
name: input.name.clone(),
face_names: input.face_names.clone(),
@@ -306,9 +320,11 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
CdtOptions { max_steiner: optional_count(p, "max_steiner", s.limits.max_steiner as u64, 0..=TET_MAX_STEINER)?, max_tets: options.max_tets };
// Every body is meshed before the model changes; with several, an error names its body.
let inputs = super::hex::inputs(s, p)?;
let sizing = super::sizes::sizing(s)?;
let several = inputs.len() > 1;
let mut meshed = Vec::with_capacity(inputs.len());
for input in inputs {
let options = sizing.tet_options(&input.name, p, &options);
meshed.push(mesh_body(s.design.as_deref(), p, input, &cdt_options, &options, quality, several)?);
}
+11
View File
@@ -5,6 +5,7 @@ mod cells;
mod contact;
pub(crate) mod datum;
pub(crate) mod deck;
mod defeature;
pub(crate) mod define;
pub mod diagnostics;
mod edit;
@@ -31,6 +32,7 @@ pub(crate) mod relax;
pub mod replace;
mod results;
pub(crate) mod seeds;
pub(crate) mod sizes;
pub(crate) mod solve;
pub(crate) mod stitch;
mod symmetry;
@@ -76,6 +78,11 @@ pub static ALL: &[CommandSpec] = &[
virtual_faces::FACES_MERGE,
virtual_faces::FACES_MERGE_DELETE,
virtual_faces::FACE_ABSORB,
virtual_faces::FACE_SPLIT,
virtual_faces::FACE_SPLIT_DELETE,
defeature::DEFEATURE,
defeature::DEFEATURE_DELETE,
defeature::DEFEATURE_PROPOSE,
stitch::STITCH,
stitch::STITCH_DELETE,
mesh::NODE_ADD,
@@ -104,6 +111,10 @@ pub static ALL: &[CommandSpec] = &[
seeds::SEED_EDGE,
seeds::SEED_CLEAR,
seeds::SEED_LIST,
sizes::SIZE_SET,
sizes::SIZE_GROWTH,
sizes::SIZE_CLEAR,
sizes::SIZE_LIST,
mirror::MIRROR,
nodes::MERGE,
nodes::DUPLICATES,
+11 -5
View File
@@ -2,7 +2,6 @@
//! imported surfaces.
use fieldcraft_mesh::octree::{OctreeHexMesh, OctreeOptions, octree_hex_mesh};
use fieldcraft_mesh::size::UniformSize;
use fieldcraft_mesh::surface::BoundarySurface;
use fieldcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS};
use serde_json::{Value, json};
@@ -15,7 +14,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const HEX_OCTREE: CommandSpec = CommandSpec {
id: "mesh.hex.octree",
label: "Octree Hex Mesh",
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 3 (refinement levels below the size, each a third, 0..=5), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics"#,
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 4 (the most refinement levels below the size, each a third, 0..=5; the levels the part's thinnest wall, narrowest face and smallest local size (mesh.size.set) need are found before meshing, and a part that needs more levels or more than max_elements cells fails at once, naming them), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid (root cells at `size`, refined where the size controls ask for smaller elements) with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics (root cell size, levels, the thinnest wall and narrowest face, cells per level after refinement and after closure)"#,
form: Some(super::forms::HEX_OCTREE),
kind: CommandKind::Model,
requires: &[Requirement::Geometry],
@@ -46,13 +45,14 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
..defaults
};
let bodies = inputs(s, p)?;
let sizing = super::sizes::sizing(s)?;
let mut meshes: Vec<(Input, OctreeHexMesh)> = Vec::with_capacity(bodies.len());
let mut total = 0usize;
for b in bodies {
if !(b.diagonal > 0.0 && b.diagonal.is_finite()) {
return Err(EngineError::Params(format!("body `{}` has no extent", b.name)));
}
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal);
let h = sizing.body_size(&b.name, size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal));
if h < MIN_RELATIVE_SIZE * b.diagonal {
return Err(EngineError::Params(format!("`size` must be at least {} mm for body `{}`", MIN_RELATIVE_SIZE * b.diagonal, b.name)));
}
@@ -60,7 +60,8 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
let surface = BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &surface_options)
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
let left = OctreeOptions { max_elements: options.max_elements.saturating_sub(total).max(1), ..options.clone() };
let mesh = octree_hex_mesh(&surface, &UniformSize(h), &left).map_err(|e| EngineError::of_body(&b.name, e))?;
let field = sizing.field(&b.name, h)?;
let mesh = octree_hex_mesh(&surface, &field, &left).map_err(|e| EngineError::of_body(&b.name, e))?;
total += mesh.hexes.len();
meshes.push((b, mesh));
}
@@ -90,7 +91,7 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
"quality": {"min_scaled_jacobian": r.min_scaled_jacobian, "mean_scaled_jacobian": r.mean_scaled_jacobian},
"volume": r.volume,
"surface_volume": r.surface_volume,
"grid": {"cells": r.grid_cells, "core_elements": r.core_elements, "layer_elements": r.layer_elements, "frame": r.frame},
"grid": {"cells": r.grid_cells, "root_size": r.root_size, "levels": r.levels, "thinnest_wall": finite(r.thinnest_wall), "narrowest_patch": finite(r.narrowest_patch), "refined_per_level": r.refined_cells, "closed_per_level": r.closed_cells, "core_elements": r.core_elements, "layer_elements": r.layer_elements, "frame": r.frame},
"features": {"corners": r.corners, "curves": r.curves, "patches": r.patches},
"attempts": r.attempts,
"retries": r.retries,
@@ -102,6 +103,11 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
Ok(cleared.report(s, json!({"bodies": report, "nodes": nodes, "elements": elements})))
}
/// A measure that may be infinite (no wall, no bounded face) as JSON: `null` when it is.
fn finite(x: f64) -> Value {
if x.is_finite() { json!(x) } else { Value::Null }
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
+148 -3
View File
@@ -54,6 +54,81 @@ const MAX_ELEMENTS: usize = 5_000_000;
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
/// The finest tessellation a plan asks for, as a fraction of the diagonal (as the meshers take).
const MIN_RELATIVE_TOLERANCE: f64 = 1e-5;
/// The share of the body's volume the chords of its curved faces may cost at the plan's size: a
/// linear element's face spans a chord of the surface, and the sag between them is volume lost
/// (or gained, in a hole).
const CHORD_VOLUME_BUDGET: f64 = 0.005;
/// The principal curvatures' magnitudes, larger first, of an analytic surface at `p` (none for a
/// plane or a free-form surface).
fn curvatures(s: &FaceSurface, p: V3) -> Option<(f64, f64)> {
let off_axis = |origin: V3, axis: V3| {
let v = std::array::from_fn(|k| p[k] - origin[k]);
let a = unit(axis)?;
let along = dot(v, a);
let r: V3 = std::array::from_fn(|k| v[k] - along * a[k]);
Some(dot(r, r).sqrt())
};
let k = match *s {
FaceSurface::Plane { .. } => return None,
FaceSurface::Cylinder { radius, .. } => (1.0 / radius, 0.0),
FaceSurface::Sphere { radius, .. } => (1.0 / radius, 1.0 / radius),
FaceSurface::Cone { apex, axis, half_angle } => (half_angle.cos() / off_axis(apex, axis)?, 0.0),
// Round the tube 1/minor; round the axis cos φ / ρ, with ρ the distance from the axis.
FaceSurface::Torus { center, axis, major, minor } => {
let rho = off_axis(center, axis)?;
(1.0 / minor, ((rho - major) / (minor * rho)).abs())
}
};
let k = (k.0.abs(), k.1.abs());
(k.0.is_finite() && k.1.is_finite()).then_some(if k.0 >= k.1 { k } else { (k.1, k.0) })
}
/// The largest element size whose chords cost each curved face at most [`CHORD_VOLUME_BUDGET`]
/// of the body's `volume`, with the curved face that needs the finest size and its radius.
///
/// A linear face of size h across a surface of principal curvatures κ1, κ2 sags h²(κ1 + κ2)/12
/// on average below it, so a face of area A costs A h²(κ1 + κ2)/12 of volume. A face's share is
/// bounded two ways, and the looser bound is enough: its cost of all the faces against the body's volume (a
/// small blend costs little whatever its curvature), and per face its cost against the volume it
/// bounds itself, the turning angle per element (a thin tube's wall, whose inner and outer chords nearly
/// cancel, needs no finer size than a solid bar of its radius).
fn curvature_size(surfaces: &[Option<FaceSurface>], mesh: &fieldcraft_geometry::SurfaceMesh, volume: f64) -> Option<(f64, usize, f64)> {
let n = surfaces.len();
// Per face: ∫(κ1 + κ2) dA, area, ∫ κ1 (κ1 + κ2) dA, ∫ κ1 dA.
let mut sums = vec![[0.0f64; 4]; n];
for (t, &f) in mesh.triangles.iter().zip(&mesh.tri_face) {
let Some(Some(surface)) = surfaces.get(f as usize) else { continue };
let [a, b, c] = t.map(|i| mesh.positions.get(i as usize).copied().unwrap_or_default());
let centroid = std::array::from_fn(|k| (a[k] + b[k] + c[k]) / 3.0);
let Some((k1, k2)) = curvatures(surface, centroid) else { continue };
let area = 0.5 * {
let n = cross(std::array::from_fn(|k| b[k] - a[k]), std::array::from_fn(|k| c[k] - a[k]));
dot(n, n).sqrt()
};
if let Some(s) = sums.get_mut(f as usize) {
s[0] += area * (k1 + k2);
s[1] += area;
s[2] += area * k1 * (k1 + k2);
s[3] += area * k1;
}
}
let e = CHORD_VOLUME_BUDGET;
// The whole body's cost against its volume, then the face that needs the finest size by its
// own turning angle, never finer than the body's bound.
let total: f64 = sums.iter().map(|s| s[0]).sum();
let global = (12.0 * e * volume.abs() / total).sqrt();
let (local, face, radius) = sums
.iter()
.enumerate()
.filter(|(_, s)| s[1] > 0.0 && s[2] > 0.0 && s[3] > 0.0)
// A circle split into arcs of angle θ loses θ²/6 of its area, a sphere's θ²/3: h² κ1 (κ1 + κ2)/6.
.map(|(i, s)| ((6.0 * e * s[1] / s[2]).sqrt(), i, s[1] / s[3]))
.filter(|(h, _, _)| h.is_finite() && *h > 0.0)
.min_by(|a, b| a.0.total_cmp(&b.0))?;
let h = global.max(local);
h.is_finite().then_some((h, face, radius))
}
pub const PLAN: CommandSpec = CommandSpec {
id: "mesh.plan",
@@ -849,6 +924,18 @@ pub(crate) fn build(design: &Design, body: &str, size: Option<f64>, layers: u32,
h = through;
}
}
// The curved faces: fine enough that the chords keep the volume.
let chords = shape.tessellate(1e-3 * diagonal)?;
if let Some((c, face, r)) = curvature_size(&surfaces, &chords, m.volume)
&& c < h
{
let name = shape.faces().get(face).map_or_else(|| format!("face {face}"), |f| f.name.clone());
notes.push(format!(
"size {c:.4} mm: the chords of {name} (radius ~{r:.4} mm) cost at most {:.1} % of the volume",
100.0 * CHORD_VOLUME_BUDGET
));
h = c;
}
let floor = (m.volume.abs() / max_elements as f64).cbrt();
if h < floor {
notes.push(format!("size {floor:.4} mm instead of {h:.4}: at most {max_elements} elements (fewer layers through the thinnest wall)"));
@@ -1567,7 +1654,8 @@ fn corner_meshes(
c: f64,
tolerance: f64,
) -> Result<CornerCopy> {
let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, ..Default::default() };
let field = super::sizes::sizing(s)?.local_field(plan.size)?;
let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, field, ..Default::default() };
let whole_body = super::project::open_body(s, cell, Some(tolerance), &json!({}))?;
let merge = super::project::merge_distance(None, whole_body.diagonal())?;
// What the cell meshed whole gets (no corner may be worse), and per candidate cut the whole
@@ -1783,7 +1871,8 @@ fn apply_projection(s: &mut Session, name: &str, plan: &MeshPlan, tolerance: f64
// Cut faces another cell's mesh already covers are taken as given, so the cells meet node
// to node there.
let (fixed, fixed_faces) = cut_face_patches(s, &body, &plan.body, plan.size)?;
let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, fixed, ..Default::default() };
let field = super::sizes::sizing(s)?.local_field(plan.size)?;
let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, fixed, field, ..Default::default() };
let mut mesh = super::project::project_body(s, &plan.body, body, &layout.topology, &opts, merge)
.map_err(|e| EngineError::Params(format!("mesh plan `{name}`: the projected layout does not mesh: {e}")))?;
if let Some(m) = mesh.as_object_mut() {
@@ -2405,6 +2494,60 @@ mod acceptance {
accept(&mut s, &r, 0.5, 10.0);
}
/// The enclosure base's bosses cut a 0.42 mm strip out of the middle of the inner fillet
/// (#511, #513): geometry.check finds the slivers and geometry.face.absorb absorbs them.
/// The absorbed faces stay absorbed in the cells the plan's symmetry cuts the body into, and
/// the projection keeps to the exact surfaces of a merged face's pieces, so the mesh is no
/// worse for them, whole or by symmetry.
#[test]
fn absorbed_slivers_stay_absorbed_in_the_plan_s_cells() {
let mut commands = vec![
json!({"command": "solid.box", "params": {"length": 56, "width": 42, "height": 16, "body_name": "Base"}}),
json!({"command": "solid.fillet", "params": {"edges": [[0, 0, 8], [56, 0, 8], [56, 42, 8], [0, 42, 8]], "radius": 5}}),
json!({"command": "solid.shell", "params": {"faces": [[28, 21, 16]], "thickness": 3, "body": "Base"}}),
];
for (x, y) in [(-1.0, -1.0), (57.0, -1.0), (-1.0, 43.0), (57.0, 43.0)] {
commands.push(json!({"command": "solid.cylinder", "params": {"diameter": 13, "height": 16, "base": [x, y, 0], "operation": "join", "targets": ["Base"]}}));
}
let (_d, mut s) = open(json!(commands));
let check = s.execute("geometry.check", &json!({"body": "Base", "size": 1.5, "mark": true})).unwrap();
assert_eq!(check["counts"]["sliver_face"], 4, "{check}");
let absorbed = s.execute("geometry.face.absorb", &json!({"from_check": true})).unwrap();
assert_eq!(absorbed["absorbed"].as_array().map(Vec::len), Some(4), "{absorbed}");
let plan = s.execute("mesh.plan", &json!({"body": "Base", "size": 1.5, "layers": 2})).unwrap();
assert_eq!(plan["mesher"], "project", "{plan}");
let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"], "symmetry": false})).unwrap();
accept(&mut s, &r, 0.6, 10.0);
let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap();
assert_eq!(r["symmetry"].as_array().map(Vec::len), Some(2), "{r}");
let design = s.design.as_deref().unwrap();
assert!(design.errors.is_empty(), "{:?}", design.errors);
// Each quarter holds one corner, its sliver absorbed.
let cells: Vec<_> = design.bodies.iter().filter(|b| b.name.starts_with("Base/")).collect();
assert_eq!(cells.len(), 4);
assert!(cells.iter().all(|c| c.merged_faces().len() == 1), "{:?}", cells.iter().map(|c| c.merged_faces()).collect::<Vec<_>>());
assert_eq!(design.source_body("Base").map(|b| b.merged_faces().len()), Some(4));
accept(&mut s, &r, 0.65, 10.0);
}
#[test]
fn curved_bodies_mesh_within_a_percent_of_their_volume_at_the_plan_s_size() {
// The plan's size keeps the chords of a torus (swept) and a cylinder (butterfly) within
// the volume budget; the butterfly's rim is divided by its length, not its class's mean.
for cmds in [
json!([{"command": "solid.torus", "params": {"major": 30.0, "minor": 8.0, "body_name": "B"}}]),
json!([{"command": "solid.cylinder", "params": {"radius": 10.0, "height": 40.0, "body_name": "B"}}]),
] {
let (_d, mut s) = open(cmds.clone());
let body = s.execute("geometry.bodies", &json!({})).unwrap()[0]["volume"].as_f64().unwrap();
let plan = s.execute("mesh.plan", &json!({"body": "B"})).unwrap();
assert!(plan["notes"].to_string().contains("the chords of"), "{plan}");
s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap();
let mesh = s.execute("mesh.quality", &json!({})).unwrap()["volume"].as_f64().unwrap();
assert!(((mesh - body) / body).abs() < 0.01, "{cmds}: mesh {mesh} against {body}");
}
}
/// The crank arm sample, whole (#455, #190): its sides are at an angle to the axes and its
/// root arcs meet the bosses at a kink, so it takes the ring-and-pillow layout: each boss an
/// annulus from its bore or pedal hole to its circle through every level, the arm a carved
@@ -2424,7 +2567,9 @@ mod acceptance {
let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap();
let layout = r["layout"].as_str().unwrap();
assert!(layout.contains("2 circle(s) ringed") && layout.contains("pillow layer"), "{r}");
accept(&mut s, &r, 0.5, 7.0);
// The rims are divided by their own length (no chord longer than the size), which
// thins the bore's ring elements a little at 2.5 mm (edge ratio 7.1).
accept(&mut s, &r, 0.5, 7.5);
}
}
+5 -2
View File
@@ -286,7 +286,7 @@ pub(crate) fn open_body(s: &Session, name: &str, tolerance: Option<f64>, raw: &V
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 target = Tessellation::new(&surface, super::mesh::analytic_surfaces(shape)).with_pieces(&surface, super::mesh::pieces(shape, &input.pieces));
let m = shape.measure()?;
Ok(Body { input, surface, target, lo: m.bbox_min, hi: m.bbox_max })
}
@@ -485,7 +485,9 @@ fn hex_project(s: &mut Session, raw: &Value) -> Result<Value> {
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 sizing = super::sizes::sizing(s)?;
let size = element_size(sizing.bodies.get(&p.body).copied().or(p.size), diag)?;
let field = sizing.local_field(size)?;
let mut t = topology(&p, b.lo, b.hi)?;
operate(&mut t, &p.operations)?;
if p.preview {
@@ -545,6 +547,7 @@ fn hex_project(s: &mut Session, raw: &Value) -> Result<Value> {
min_quality,
fixed,
planes: Vec::new(),
field,
};
let merge = merge_distance(p.merge, diag)?;
project_body(s, &p.body, b, &t, &opts, merge)
+6 -2
View File
@@ -202,8 +202,12 @@ fn squeezed_junction_elements_are_reported_not_meshed() {
p["min_quality"] = json!(0.3);
let model = s.model.clone();
let e = s.execute("mesh.hex.project", &p).unwrap_err().to_string();
// Smoothing leaves the worst element in the squeezed ring block (34) or in block 36.
assert!(e.contains("smallest scaled Jacobian") && (e.contains("worst in block 34") || e.contains("worst in block 36")), "{e}");
// Smoothing leaves the worst element in the squeezed ring block (34) or in block 36; with
// the cup's rim divided by its own length, the junction's corner (block 13) folds instead,
// and the validity gate refuses it.
let squeezed = e.contains("smallest scaled Jacobian") && (e.contains("worst in block 34") || e.contains("worst in block 36"));
let folded = e.contains("inverted, twisted or degenerate") && e.contains("block 13");
assert!(squeezed || folded, "{e}");
assert_eq!(s.model, model);
}
+11 -6
View File
@@ -28,7 +28,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const RELAX: CommandSpec = CommandSpec {
id: "mesh.node.relax",
label: "Relax Nodes",
params: r#"{"center": [x, y, z], "radius": mm (the sphere of influence) | "around_element": label (a sphere round that element; radius default twice its longest edge) | "faces": [{"body": "Base", "face": "F3"}] (persistent B-rep face names, as model.set.create takes them: every node on those faces and the nodes of the elements touching them; pick faces in the view) | "nodes": [labels] | "elements": [labels] (their nodes), "bodies"?: ["Body1"] (only their nodes; a partitioned body's cells count as it), "iterations"?: 5 (1..=100), "strength"?: 1 (0..1], "falloff"?: "smooth" (default: weight 1 at the centre to 0 at the radius) | "linear" | "none", "field"?: "Name" (the relaxation field to keep; default Relax1, Relax2, …), "reapply"?: false (apply the field again after each remesh of these bodies), "mode"?: "guarded" (default) | "strong", "below"?: 0.7 (strong: the elements below this scaled Jacobian are the bad ones), "reach"?: mm (strong: nodes this close to a bad node follow it; default three times the bad elements' mean edge length)} — moves the nodes to improve the worst elements around them (smart Laplacian, then optimization of the worst element; a move is kept only when the worst element around the node does not get worse, and never inverts one). `"mode": "strong"` morphs the region instead (`bodies` alone is the whole body, nothing listed the whole mesh; `iterations` are rounds, default 20, 1..=200): each round moves the corner nodes of the elements below `below` to better places (their own optimisation, the rest held) and carries that displacement to the nodes within `reach` by harmonic (graph Laplacian) interpolation, zero beyond; nodes on B-rep edges and vertices are held, nodes on faces put back on them, nodes outside the reach unchanged. A round is kept (at full or a halved scale) only when nothing inverts, no element gets longer than the longest was, and the worst element it touches improves; the result never has a lower smallest scaled Jacobian than the start. Its reply adds the mean scaled Jacobian and, per round, the bad elements and nodes, their clusters (bad nodes within the reach of each other, each morphed and checked on its own) and how many were kept, the region's nodes, the reach and the min and mean (`rounds`). Nodes on a B-rep face slide in its UV parameters and stay on it, nodes on a B-rep edge slide along it, nodes on vertices stay; nodes on partition cut faces move freely (shared by both cells); mid-side nodes follow, on the geometry. Reports the nodes moved, the largest displacement, the region's smallest scaled Jacobian, largest edge ratio and worst elements before and after, the field kept, and the listed faces no mesh is tagged with (`faces_without_mesh`; an error when none is). One undo step"#,
params: r#"{"center": [x, y, z], "radius": mm (the sphere of influence) | "around_element": label (a sphere round that element; radius default twice its longest edge) | "faces": [{"body": "Base", "face": "F3"}] (persistent B-rep face names, as model.set.create takes them: every node on those faces and the nodes of the elements touching them; pick faces in the view) | "nodes": [labels] | "elements": [labels] (their nodes), "bodies"?: ["Body1"] (only their nodes; a partitioned body's cells count as it), "iterations"?: 5 (1..=100), "strength"?: 1 (0..1], "falloff"?: "smooth" (default: weight 1 at the centre to 0 at the radius) | "linear" | "none", "field"?: "Name" (the relaxation field to keep; default Relax1, Relax2, …), "reapply"?: false (apply the field again after each remesh of these bodies), "mode"?: "guarded" (default) | "strong", "below"?: 0.7 (strong: the elements below this scaled Jacobian are the bad ones), "reach"?: mm (strong: nodes this close to a bad node follow it; default five times the bad elements' mean edge length), "guards"?: "valid" (default) | "strict" (strong: when a cluster's morph is kept)} — moves the nodes to improve the worst elements around them (smart Laplacian, then optimization of the worst element; a move is kept only when the worst element around the node does not get worse, and never inverts one). `"mode": "strong"` morphs the region instead (`bodies` alone is the whole body, nothing listed the whole mesh; `iterations` are rounds, default 20, 1..=200): each round moves the corner nodes of the elements below `below` to better places (their own optimisation, the rest held) and carries that displacement to the nodes within `reach` by harmonic (graph Laplacian) interpolation, zero beyond; nodes on B-rep edges and vertices are held, nodes on faces put back on them, nodes outside the reach unchanged. A cluster's morph is kept whenever no element it touches inverts (`"guards": "valid"`); with `"guards": "strict"` it is kept (at full or a halved scale) only when besides no element gets longer than the longest was, and the worst element it touches improves. The result is the round with the highest smallest scaled Jacobian, never lower than the start. Its reply adds the mean scaled Jacobian and, per round, the bad elements and nodes, their clusters (bad nodes within the reach of each other, each morphed and checked on its own) and how many were kept, the region's nodes, the reach and the min and mean (`rounds`). Nodes on a B-rep face slide in its UV parameters and stay on it, nodes on a B-rep edge slide along it, nodes on vertices stay; nodes on partition cut faces move freely (shared by both cells); mid-side nodes follow, on the geometry. Reports the nodes moved, the largest displacement, the region's smallest scaled Jacobian, largest edge ratio and worst elements before and after, the field kept, and the listed faces no mesh is tagged with (`faces_without_mesh`; an error when none is). One undo step"#,
form: Some(super::forms::NODE_RELAX),
kind: CommandKind::Model,
requires: &[Requirement::Mesh],
@@ -194,7 +194,7 @@ fn design_geometry(s: &Session, nodes: &BTreeSet<u32>) -> Result<DesignGeometry>
None => None,
Some(shape) => {
let tol = (shape.size()? * 1e-3).max(1e-6);
let mesh = shape.tessellate(tol)?;
let (mesh, pieces) = shape.tessellate_pieces(tol)?;
let surface = BoundarySurface::new(
&mesh.positions,
&mesh.triangles,
@@ -202,7 +202,7 @@ fn design_geometry(s: &Session, nodes: &BTreeSet<u32>) -> Result<DesignGeometry>
&SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() },
)?;
let analytic = super::mesh::analytic_surfaces(shape);
g.bodies.push(Tessellation::new(&surface, analytic.clone()));
g.bodies.push(Tessellation::new(&surface, analytic.clone()).with_pieces(&surface, super::mesh::pieces(shape, &pieces)));
Some((g.bodies.len() - 1, analytic))
}
};
@@ -522,6 +522,11 @@ fn relax_strong(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| EngineError::param("reach", ProblemKind::NotPositive))
.map(Some)?,
};
let guards = match super::opt::<String>(p, "guards")?.as_deref() {
None | Some("valid") => relax::Guards::Valid,
Some("strict") => relax::Guards::Strict,
Some(_) => return Err(EngineError::param("guards", ProblemKind::NotAChoice)),
};
let rounds = match p.get("iterations") {
None => STRONG_ROUNDS,
Some(v) => v
@@ -543,7 +548,7 @@ fn relax_strong(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Params("no mesh nodes in the region to relax".into()));
}
let g = design_geometry(s, &touched(&s.model, &nodes.iter().map(|&n| (n, 1.0)).collect()))?;
let r = relax::morph(&s.model, &nodes, relax::MorphOptions { below, reach, rounds }, &g)?;
let r = relax::morph(&s.model, &nodes, relax::MorphOptions { below, reach, rounds, guards }, &g)?;
let field_name = match super::opt::<String>(p, "field")? {
Some(n) => n,
None => (1..).map(|i| format!("Relax{i}")).find(|n| s.model.relaxation(n).is_none()).unwrap_or_else(|| "Relax".into()),
@@ -609,8 +614,8 @@ fn relax_nodes(s: &mut Session, p: &Value) -> Result<Value> {
Some("strong") => return relax_strong(s, p),
Some(_) => return Err(EngineError::param("mode", ProblemKind::NotAChoice)),
}
if p.get("below").is_some() || p.get("reach").is_some() {
return Err(EngineError::Params("`below` and `reach` go with \"mode\": \"strong\"".into()));
if ["below", "reach", "guards"].iter().any(|k| p.get(*k).is_some()) {
return Err(EngineError::Params("`below`, `reach` and `guards` go with \"mode\": \"strong\"".into()));
}
let (w, sphere) = weights(&s.model, p)?;
if w.is_empty() {
+38 -1
View File
@@ -275,7 +275,8 @@ fn on_box_edge(p: [f64; 3]) -> bool {
fn strong_relaxation_of_a_filleted_box_keeps_faces_and_edges() {
let (_d, mut s, _) = filleted_box();
let before = s.model.clone();
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "bodies": ["B"], "below": 0.3})).unwrap();
// The tet mesher leaves no element below the skill's 0.3, so relax those below 0.45.
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "bodies": ["B"], "below": 0.45})).unwrap();
let (b, a) = (&r["before"], &r["after"]);
// The worst tets may not improve: their corners can all be held on the box's edges.
assert!(a["min_scaled_jacobian"].as_f64().unwrap() >= b["min_scaled_jacobian"].as_f64().unwrap(), "{r}");
@@ -349,6 +350,8 @@ fn strong_mode_parameters_are_checked() {
json!({"mode": "strong", "nodes": [1], "iterations": 201}),
json!({"below": 0.5}),
json!({"nodes": [1], "reach": 1}),
json!({"nodes": [1], "guards": "strict"}),
json!({"mode": "strong", "nodes": [1], "guards": "loose"}),
json!({"nodes": [1], "mode": "strong", "layers": 1}),
] {
assert!(s.execute("mesh.node.relax", &p).is_err(), "{p}");
@@ -357,3 +360,37 @@ fn strong_mode_parameters_are_checked() {
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "below": 0.01})).unwrap();
assert_eq!(r["moved"], 0, "{r}");
}
/// The mesh and the strong relaxation are the same bits on every platform and thread count:
/// the plate's hex mesh differed between macOS and Linux (the system maths library's `atan2`,
/// `sin` … are not correctly rounded), so the distorted-plate test passed on one and failed on
/// the other (#438). The fingerprint is of the nodes after mesh.hex.generate, after the
/// perturbation and after the strong relaxation; CI checks it on Linux, it was taken on macOS.
#[test]
fn mesh_and_strong_relaxation_are_the_same_bits_everywhere() {
let fingerprint = |s: &Session, h: &mut u64| {
// FNV-1a over every node's label and coordinate bits.
for (n, p) in s.model.nodes() {
for word in [u64::from(*n), p.xyz[0].to_bits(), p.xyz[1].to_bits(), p.xyz[2].to_bits()] {
for b in word.to_le_bytes() {
*h = (*h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3);
}
}
}
};
let run = || {
let mut h = 0xcbf2_9ce4_8422_2325;
let mut s = plate();
s.execute("mesh.hex.generate", &json!({"size": 2.0})).unwrap();
fingerprint(&s, &mut h);
perturb(&mut s, ON_HOLE, 6.0, 0.35);
fingerprint(&s, &mut h);
s.execute("mesh.node.relax", &json!({"mode": "strong", "below": 0.5, "reach": 4.0})).unwrap();
fingerprint(&s, &mut h);
h
};
let one = fieldcraft_parallel::with_threads(1, run);
let many = fieldcraft_parallel::with_threads(4, run);
assert_eq!(one, many, "1 and 4 threads differ");
assert_eq!(one, 0x7445_e04c_0d65_c1d0, "fingerprint {one:#018x}");
}
+461
View File
@@ -0,0 +1,461 @@
//! Size controls: `mesh.size.set` asks for elements of a size on faces or bodies, in a box or in
//! a sphere; `mesh.size.growth` sets how fast the size grows away from them; `mesh.size.clear`
//! removes controls and `mesh.size.list` lists them with what each covers in the open design.
//! Controls live in the model (`fieldcraft_model::sizes`), are saved with it, and every mesher
//! reads them through [`Sizing`]: the tetrahedral mesher bakes them into its graded size field,
//! the structured hex meshers and the octree take the exact graded field
//! ([`fieldcraft_mesh::size::RegionSize`]), and the block projection (`mesh.hex.project`) divides
//! its block edges by it.
use std::collections::BTreeMap;
use fieldcraft_geometry::{Design, Shape};
use fieldcraft_mesh::size::{LocalSize, Region, RegionSize};
use fieldcraft_mesh::tet::QualityOptions;
use fieldcraft_model::{Entity, GeometryRef, SizeControl, SizeRegion};
use serde::Deserialize;
use serde_json::{Value, json};
use super::opt;
use crate::requirement::Requirement;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
/// Most controls one `mesh.size.clear` names.
const MAX_NAMES: usize = 10_000;
/// The chordal tolerance of the tessellation a face control is resolved on, relative to the
/// body's diagonal (as the meshers' default).
const FACE_TOLERANCE: f64 = 1e-3;
pub const SIZE_SET: CommandSpec = CommandSpec {
id: "mesh.size.set",
label: "Local Size",
params: r#"{"name": "Hole", "size": 0.5, and one of "faces": [{"body": "Body1", "face": "F3"}, …] (persistent face names, geometry.bodies; a name without its `#k` number takes every piece), "bodies": ["Body1", …], "box": {"min": [x, y, z], "max": [x, y, z]}, "sphere": {"center": [x, y, z], "radius": r}} — adds the size control `name`, or replaces it: elements of about `size` on those faces, in those bodies, in the box or in the sphere. Every mesher honours it: the size at a point is the smallest of the mesher's size and, over the controls, a control's size plus the growth rate (mesh.size.growth) times the distance to its region, so elements grow away from it gradually. A control finer than the mesher's size refines; a coarser one changes nothing, except on whole bodies, where it is the body's size in place of the mesher's `size` (meshers that mesh several bodies together, mesh.hex.blocks and mesh.cells, take it as a control on the body's surface). Saved with the model; mesh.size.list shows it"#,
form: Some(super::forms::SIZE_SET),
kind: CommandKind::Model,
requires: &[],
run: size_set,
};
pub const SIZE_GROWTH: CommandSpec = CommandSpec {
id: "mesh.size.growth",
label: "Size Growth",
params: r#"{"rate": 0.3 (0.05..=2; omitted or null: back to the default, 0.3)} — the size field's growth rate: the largest change of element size per unit length away from a size control or an edge seed. mesh.tet.generate's `grading` overrides it for one run"#,
form: Some(super::forms::SIZE_GROWTH),
kind: CommandKind::Model,
requires: &[],
run: size_growth,
};
pub const SIZE_CLEAR: CommandSpec = CommandSpec {
id: "mesh.size.clear",
label: "Clear Local Sizes",
params: r#"{"names"?: ["Hole", …]} — removes these size controls, or every one"#,
form: Some(super::forms::SIZE_CLEAR),
kind: CommandKind::Model,
requires: &[Requirement::SizeControl],
run: size_clear,
};
pub const SIZE_LIST: CommandSpec = CommandSpec {
id: "mesh.size.list",
label: "Local Sizes",
params: "{} — the size controls by name, each with its region and size and, for faces and bodies, what it covers in the open design (`stale`: names the design no longer has), and the growth rate",
form: Some(&[]),
kind: CommandKind::Model,
requires: &[],
run: size_list,
};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BoxParams {
min: [f64; 3],
max: [f64; 3],
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SphereParams {
center: [f64; 3],
radius: f64,
}
fn size_set(s: &mut Session, p: &Value) -> Result<Value> {
let name: String = opt(p, "name")?.ok_or_else(|| EngineError::Params("`name` is required".into()))?;
let size: f64 = opt(p, "size")?.ok_or_else(|| EngineError::Params("`size` is required: the element size".into()))?;
let faces: Option<Vec<GeometryRef>> = opt(p, "faces")?;
let bodies: Option<Vec<String>> = opt(p, "bodies")?;
let boxed: Option<BoxParams> = opt(p, "box")?;
let sphere: Option<SphereParams> = opt(p, "sphere")?;
let given = [faces.is_some(), bodies.is_some(), boxed.is_some(), sphere.is_some()].iter().filter(|g| **g).count();
if given != 1 {
return Err(EngineError::Params("give one of `faces`, `bodies`, `box` or `sphere`".into()));
}
let region = if let Some(faces) = faces {
if let Some(r) = faces.iter().find(|r| !matches!(r.entity, Entity::Face(_))) {
return Err(EngineError::Params(format!("`faces` lists the {r}; name faces as {{body, face}}")));
}
SizeRegion::Geometry(faces)
} else if let Some(bodies) = bodies {
SizeRegion::Geometry(bodies.into_iter().map(|b| GeometryRef::new(b, Entity::Body)).collect::<fieldcraft_model::Result<_>>()?)
} else if let Some(b) = boxed {
SizeRegion::Box { min: b.min, max: b.max }
} else if let Some(sp) = sphere {
SizeRegion::Sphere { center: sp.center, radius: sp.radius }
} else {
return Err(EngineError::Params("give one of `faces`, `bodies`, `box` or `sphere`".into()));
};
let control = SizeControl { region, size };
// Faces and bodies must name the open design's, when there is one.
if let (Some(design), SizeRegion::Geometry(refs)) = (s.design.as_deref(), &control.region)
&& let Some(r) = refs.iter().find(|r| covered(design, r).is_ok_and(|c| c.is_empty()))
{
return Err(EngineError::Params(format!("the design has no {r} (geometry.bodies lists the faces)")));
}
s.model.set_size_control(&name, control.clone())?;
Ok(report(s, &name, &control))
}
fn size_growth(s: &mut Session, p: &Value) -> Result<Value> {
let rate: Option<f64> = opt(p, "rate")?;
s.model.set_size_growth(rate)?;
Ok(json!({"growth": s.model.size_growth(), "default": !s.model.size_growth_set()}))
}
fn size_clear(s: &mut Session, p: &Value) -> Result<Value> {
let names: Option<Vec<String>> = opt(p, "names")?;
let cleared = match names {
None => {
let all: Vec<String> = s.model.size_controls().keys().cloned().collect();
if all.is_empty() {
return Err(EngineError::Params("the model has no size controls".into()));
}
for n in &all {
s.model.remove_size_control(n)?;
}
all.len()
}
Some(names) => {
if names.is_empty() || names.len() > MAX_NAMES {
return Err(EngineError::Params(format!("`names` must list 1 to {MAX_NAMES} size controls")));
}
for n in &names {
s.model.remove_size_control(n)?;
}
names.len()
}
};
Ok(json!({"cleared": cleared, "left": s.model.size_controls().len()}))
}
fn size_list(s: &mut Session, _: &Value) -> Result<Value> {
let controls: serde_json::Map<String, Value> = s.model.size_controls().iter().map(|(n, c)| (n.clone(), report(s, n, c))).collect();
Ok(json!({"controls": controls, "growth": s.model.size_growth()}))
}
/// A control's report: its definition and, for faces and bodies, the names each covers in the
/// open design.
fn report(s: &Session, name: &str, c: &SizeControl) -> Value {
let mut v = json!({"name": name, "size": c.size, "region": c.region});
if let (SizeRegion::Geometry(refs), Some(design)) = (&c.region, s.design.as_deref()) {
let on: Vec<Value> = refs.iter().map(|r| json!({"target": r.to_string(), "covers": covered(design, r).unwrap_or_default()})).collect();
v["stale"] = json!(on.iter().any(|o| o["covers"].as_array().is_none_or(Vec::is_empty)));
v["on"] = json!(on);
}
v
}
/// The faces a face reference selects (its `#k` pieces too), or the body a body reference names.
fn covered(design: &Design, r: &GeometryRef) -> Result<Vec<String>> {
let Some(body) = design.body(&r.body) else { return Ok(Vec::new()) };
Ok(match r.entity {
Entity::Body => vec![body.name.clone()],
_ => body.faces().iter().filter(|f| selects(r, &body.name, &f.name)).map(|f| f.name.clone()).collect(),
})
}
fn selects(r: &GeometryRef, body: &str, face: &str) -> bool {
GeometryRef::new(body.to_string(), Entity::Face(face.to_string())).is_ok_and(|g| r.selects(&g))
}
/// The model's size controls resolved on the open design, as the meshers take them.
#[derive(Debug, Clone, Default)]
pub(crate) struct Sizing {
/// Local sizes on faces, in boxes and in spheres.
pub(crate) locals: Vec<LocalSize>,
/// Per body, the size its body controls set.
pub(crate) bodies: BTreeMap<String, f64>,
/// Per body, its boundary surface's triangles (for the meshers that take a body control as
/// a local size on its surface).
surfaces: BTreeMap<String, Vec<[[f64; 3]; 3]>>,
pub(crate) growth: f64,
}
/// The triangles of `shape`'s faces that `select` picks.
fn triangles(shape: &Shape, select: impl Fn(&str) -> bool) -> Result<Vec<[[f64; 3]; 3]>> {
let tolerance = FACE_TOLERANCE * shape.size()?;
let mesh = shape.tessellate(tolerance)?;
let names = shape.faces();
let point = |i: u32| mesh.positions.get(i as usize).copied();
Ok(mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, f)| names.get(**f as usize).is_some_and(|n| select(&n.name)))
.filter_map(|(t, _)| Some([point(t[0])?, point(t[1])?, point(t[2])?]))
.collect())
}
/// Resolve the model's size controls on the open design. Face controls on faces the design does
/// not have, and body controls on bodies it does not have, are left out (mesh.size.list shows
/// them as stale).
pub(crate) fn sizing(s: &Session) -> Result<Sizing> {
let mut out = Sizing { growth: s.model.size_growth(), ..Sizing::default() };
for c in s.model.size_controls().values() {
let region = match &c.region {
SizeRegion::Box { min, max } => Region::Box { lo: *min, hi: *max },
SizeRegion::Sphere { center, radius } => Region::Sphere { center: *center, radius: *radius },
SizeRegion::Geometry(refs) => {
let Some(design) = s.design.as_deref() else { continue };
let mut tris = Vec::new();
for r in refs {
let Some(shape) = design.body(&r.body) else { continue };
if r.entity == Entity::Body {
let size = out.bodies.entry(shape.name.clone()).or_insert(c.size);
*size = size.min(c.size);
if !out.surfaces.contains_key(&shape.name) {
out.surfaces.insert(shape.name.clone(), triangles(shape, |_| true)?);
}
} else {
tris.extend(triangles(shape, |f| selects(r, &shape.name, f))?);
}
}
if tris.is_empty() {
continue;
}
Region::Triangles(tris)
}
};
out.locals.push(LocalSize { region, size: c.size });
}
Ok(out)
}
impl Sizing {
/// The size of `body` meshed alone: its body control's, else `size`.
pub(crate) fn body_size(&self, body: &str, size: f64) -> f64 {
self.bodies.get(body).copied().unwrap_or(size)
}
/// The sizing of several bodies meshed together at one size, so the curves and faces they
/// share are divided alike: the face, box and sphere controls, and each body control as a
/// local size on that body's surface.
pub(crate) fn joint(&self, bodies: &[&str]) -> Sizing {
let mut locals = self.locals.clone();
for b in bodies {
if let (Some(&size), Some(tris)) = (self.bodies.get(*b), self.surfaces.get(*b)) {
locals.push(LocalSize { region: Region::Triangles(tris.clone()), size });
}
}
Sizing { locals, growth: self.growth, ..Sizing::default() }
}
/// The graded field of `body` meshed at `size` (its body control's size in place of it).
pub(crate) fn field(&self, body: &str, size: f64) -> Result<RegionSize> {
Ok(RegionSize::new(self.body_size(body, size), self.growth, self.locals.clone())?)
}
/// The graded field of the face, box and sphere controls about a size already chosen (body
/// controls taken into it, or a mesh plan's size), for a mesher that takes a uniform size
/// and an optional field; `None` when no control refines it.
pub(crate) fn local_field(&self, size: f64) -> Result<Option<RegionSize>> {
Ok(Some(RegionSize::new(size, self.growth, self.locals.clone())?).filter(|f| !f.is_uniform()))
}
/// The tetrahedral mesher's options for `body`: its body control's size in place of the
/// global one, the local sizes, and the growth rate unless the command gives `grading`.
pub(crate) fn tet_options(&self, body: &str, p: &Value, o: &QualityOptions) -> QualityOptions {
QualityOptions {
size: self.bodies.get(body).copied().or(o.size),
locals: self.locals.clone(),
grading: if p.get("grading").is_some() { o.grading } else { self.growth },
..o.clone()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn plate() -> (Session, std::path::PathBuf) {
super::super::datum::tests::plate()
}
/// The name of the plate's face that lies in the plane x = `x`.
fn face_at_x(s: &Session, x: f64) -> String {
let body = s.design.as_ref().unwrap().body("Body1").unwrap();
let mesh = body.tessellate(0.1).unwrap();
let faces = body.faces();
(0..faces.len())
.find(|&f| {
let mut pts = mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g as usize == f).flat_map(|(t, _)| t.iter());
pts.clone().next().is_some() && pts.all(|&v| (mesh.positions[v as usize][0] - x).abs() < 1e-6)
})
.map(|f| faces[f].name.clone())
.unwrap()
}
/// Every element edge of the model's tet4 or hex8 elements, once: its two end points.
fn edges(s: &Session) -> Vec<([f64; 3], [f64; 3])> {
const TET: &[[usize; 2]] = &[[0, 1], [0, 2], [0, 3], [1, 2], [1, 3], [2, 3]];
const HEX: &[[usize; 2]] = &[[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]];
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
for e in s.model.elements().values() {
let pairs = if e.nodes.len() == 4 { TET } else { HEX };
for [a, b] in pairs {
let (a, b) = (e.nodes[*a], e.nodes[*b]);
if seen.insert((a.min(b), a.max(b))) {
out.push((s.model.nodes()[&a].xyz, s.model.nodes()[&b].xyz));
}
}
}
out
}
fn length((a, b): &([f64; 3], [f64; 3])) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
fn mean(v: &[f64]) -> f64 {
assert!(!v.is_empty());
v.iter().sum::<f64>() / v.len() as f64
}
#[test]
fn controls_are_set_listed_cleared_saved_and_undone() {
let (mut s, dir) = plate();
let end = face_at_x(&s, 40.0);
let r = s.execute("mesh.size.set", &json!({"name": "End", "size": 1.5, "faces": [{"body": "Body1", "face": end}]})).unwrap();
assert_eq!(r["on"][0]["covers"], json!([end]), "{r}");
assert_eq!(r["stale"], false);
s.execute("mesh.size.set", &json!({"name": "Zone", "size": 2, "box": {"min": [0, 0, 0], "max": [5, 5, 10]}})).unwrap();
s.execute("mesh.size.set", &json!({"name": "Zone", "size": 2, "sphere": {"center": [0, 0, 0], "radius": 3}})).unwrap();
s.execute("mesh.size.set", &json!({"name": "Whole", "size": 4, "bodies": ["Body1"]})).unwrap();
s.execute("mesh.size.growth", &json!({"rate": 0.5})).unwrap();
let list = s.execute("mesh.size.list", &json!({})).unwrap();
assert_eq!(list["controls"].as_object().unwrap().len(), 3, "{list}");
assert_eq!(list["controls"]["Zone"]["region"]["sphere"]["radius"], 3.0);
assert_eq!(list["growth"], 0.5);
let file = dir.join("sized.fieldcraft");
s.execute("file.save", &json!({"path": file.to_str().unwrap()})).unwrap();
let mut back = Session::default();
back.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
assert_eq!(back.model.size_controls(), s.model.size_controls());
assert_eq!(back.model.size_growth(), 0.5);
s.execute("mesh.size.clear", &json!({"names": ["Zone"]})).unwrap();
assert_eq!(s.model.size_controls().len(), 2);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.model.size_controls().len(), 3);
assert!(s.execute("mesh.size.clear", &json!({"names": ["Nope"]})).is_err());
assert_eq!(s.execute("mesh.size.clear", &json!({})).unwrap()["cleared"], 3);
assert!(s.execute("mesh.size.clear", &json!({})).is_err(), "nothing to clear");
s.execute("mesh.size.growth", &json!({})).unwrap();
assert_eq!(s.model.size_growth(), fieldcraft_model::sizes::DEFAULT_GROWTH);
}
#[test]
fn hostile_params_never_panic() {
let (mut s, _) = plate();
for p in [
json!({}),
json!({"name": "A"}),
json!({"name": "A", "size": 1}),
json!({"name": "A", "size": 0, "bodies": ["Body1"]}),
json!({"name": "A", "size": -1, "bodies": ["Body1"]}),
json!({"name": "A", "size": 1e300, "bodies": ["Body1"]}),
json!({"name": "A", "size": "big", "bodies": ["Body1"]}),
json!({"name": "A", "size": 1, "bodies": ["Nope"]}),
json!({"name": "A", "size": 1, "bodies": []}),
json!({"name": "A", "size": 1, "faces": [{"body": "Body1", "face": "Nope"}]}),
json!({"name": "A", "size": 1, "faces": [{"body": "Body1", "edge": "E"}]}),
json!({"name": "A", "size": 1, "bodies": ["Body1"], "box": {"min": [0, 0, 0], "max": [1, 1, 1]}}),
json!({"name": "A", "size": 1, "box": {"min": [1, 0, 0], "max": [0, 1, 1]}}),
json!({"name": "A", "size": 1, "box": {"min": [0, 0], "max": [1, 1, 1]}}),
json!({"name": "A", "size": 1, "sphere": {"center": [0, 0, 0], "radius": 0}}),
json!({"name": "A", "size": 1, "sphere": {"center": [0, 0, 0], "radius": 1, "x": 1}}),
json!({"name": "1 bad", "size": 1, "bodies": ["Body1"]}),
] {
assert!(s.execute("mesh.size.set", &p).is_err(), "{p}");
}
for p in [json!({"rate": 0}), json!({"rate": 5}), json!({"rate": "x"})] {
assert!(s.execute("mesh.size.growth", &p).is_err(), "{p}");
}
assert!(s.execute("mesh.size.clear", &json!({"names": []})).is_err());
assert!(s.model.size_controls().is_empty());
}
/// A size box halves the tetrahedra inside it; a face control gives its face the size it
/// asks for, and the elements grow away from it no faster than the growth rate allows.
#[test]
fn tet_meshes_follow_size_controls() {
let (mut s, _) = plate();
s.execute("mesh.tet.generate", &json!({"body": "Body1", "size": 6, "tolerance": 0.2})).unwrap();
let inside = |e: &&([f64; 3], [f64; 3])| [e.0, e.1].iter().all(|p| p[0] >= 30.0 && p[1] >= 0.0 && p[1] <= 10.0);
let before = mean(&edges(&s).iter().filter(inside).map(length).collect::<Vec<_>>());
s.execute("mesh.size.set", &json!({"name": "Zone", "size": 3, "box": {"min": [30, -1, -1], "max": [41, 10, 11]}})).unwrap();
s.execute("mesh.tet.generate", &json!({"body": "Body1", "size": 6, "tolerance": 0.2})).unwrap();
let after = mean(&edges(&s).iter().filter(inside).map(length).collect::<Vec<_>>());
assert!((after / before - 0.5).abs() < 0.15, "box: {before} -> {after}");
s.execute("mesh.size.clear", &json!({})).unwrap();
let end = face_at_x(&s, 40.0);
s.execute("mesh.size.set", &json!({"name": "End", "size": 1.5, "faces": [{"body": "Body1", "face": end}]})).unwrap();
s.execute("mesh.size.growth", &json!({"rate": 0.5})).unwrap();
s.execute("mesh.tet.generate", &json!({"body": "Body1", "size": 6, "tolerance": 0.2})).unwrap();
let all = edges(&s);
let on_face: Vec<f64> = all.iter().filter(|e| (e.0[0] - 40.0).abs() < 1e-6 && (e.1[0] - 40.0).abs() < 1e-6).map(length).collect();
let m = mean(&on_face);
assert!((1.2..1.95).contains(&m), "on the face: {m}");
// Slabs away from the face: the mean edge stays near 1.5 + 0.5 d (and below 6).
for d in [3.0, 6.0, 9.0] {
let slab: Vec<f64> = all.iter().filter(|e| (40.0 - 0.5 * (e.0[0] + e.1[0]) - d).abs() < 1.0).map(length).collect();
let want = (1.5f64 + 0.5 * d).min(6.0);
let m = mean(&slab);
assert!(m < want * 1.3 && m > want * 0.5, "{d} mm from the face: mean edge {m}, field {want}");
}
}
/// A face control sets the hex divisions of the face's edges: the swept plate's end face gets
/// elements of the size asked for.
#[test]
fn hex_meshes_follow_size_controls() {
let (mut s, _) = plate();
let end = face_at_x(&s, 40.0);
s.execute("mesh.hex.generate", &json!({"body": "Body1", "size": 5})).unwrap();
let on_end = |s: &Session| -> Vec<f64> {
edges(s).iter().filter(|e| (e.0[0] - 40.0).abs() < 1e-6 && (e.1[0] - 40.0).abs() < 1e-6).map(length).collect()
};
let coarse = on_end(&s).into_iter().fold(0.0, f64::max);
assert!(coarse > 3.0, "{coarse}");
s.execute("mesh.size.set", &json!({"name": "End", "size": 1.25, "faces": [{"body": "Body1", "face": end}]})).unwrap();
s.execute("mesh.hex.generate", &json!({"body": "Body1", "size": 5})).unwrap();
let fine = on_end(&s);
assert!(fine.iter().all(|&l| l < 1.25 * 1.3), "{fine:?}");
assert!(mean(&fine) > 1.25 * 0.7, "{fine:?}");
}
/// The octree's cells on a face with a size control reach the level its size needs.
#[test]
fn octree_meshes_follow_size_controls() {
let (mut s, _) = plate();
let end = face_at_x(&s, 40.0);
s.execute("mesh.size.set", &json!({"name": "End", "size": 2, "faces": [{"body": "Body1", "face": end}]})).unwrap();
s.execute("mesh.hex.octree", &json!({"body": "Body1", "size": 6})).unwrap();
let on_end: Vec<f64> = edges(&s).iter().filter(|e| (e.0[0] - 40.0).abs() < 1e-3 && (e.1[0] - 40.0).abs() < 1e-3).map(length).collect();
assert!(!on_end.is_empty() && mean(&on_end) < 2.0 * 1.3, "{on_end:?}");
}
}
+120 -7
View File
@@ -18,7 +18,7 @@ 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"#,
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 projection mesher and node relaxation keep to each B-rep piece's own surface, the other meshers 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: on a body they cut, in each cell where its faces, or their pieces, touch); 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],
@@ -45,6 +45,26 @@ pub const FACE_ABSORB: CommandSpec = CommandSpec {
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;
@@ -75,12 +95,12 @@ fn stale_sets(s: &Session, body: &str) -> Vec<String> {
s.model.sets().iter().filter(|(_, set)| set.geometry.iter().any(gone)).map(|(n, _)| n.clone()).collect()
}
/// Apply the model's partitions and merges to the design again, failing with the error of a merge
/// named in `merges` when it has one; the meshes of `bodies` go (their faces changed).
fn reapply(s: &mut Session, merges: &[String], bodies: &[String], reply: Value) -> Result<Value> {
/// 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).
pub(super) 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 features: Vec<String> = merges.iter().map(|m| format!("merge {m}")).collect();
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() {
@@ -110,7 +130,7 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
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, std::slice::from_ref(&name), std::slice::from_ref(&body), reply)?;
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() {
@@ -200,7 +220,29 @@ fn absorb(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Params(format!("no face could be absorbed: {}", json!(skipped))));
}
let bodies: Vec<String> = shapes.into_iter().map(|b| b.name).collect();
reapply(s, &merges, &bodies, json!({"absorbed": absorbed, "skipped": skipped}))
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> {
@@ -507,4 +549,75 @@ mod tests {
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();
}
}
+20
View File
@@ -36,8 +36,14 @@ pub enum Requirement {
FaceMerge,
/// A stitch.
Stitch,
/// A face split.
FaceSplit,
/// A defeature.
Defeature,
/// An edge seed.
Seed,
/// A size control.
SizeControl,
/// At least one mesh node.
Nodes,
/// At least one element.
@@ -85,7 +91,10 @@ impl Requirement {
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::Defeature => !m.defeatures().is_empty(),
Requirement::Seed => !m.seeds().is_empty(),
Requirement::SizeControl => !m.size_controls().is_empty(),
Requirement::Nodes => !m.nodes().is_empty(),
Requirement::Mesh => has_elements,
Requirement::Analysable => {
@@ -127,7 +136,10 @@ impl Requirement {
Requirement::Partition => "a partition",
Requirement::FaceMerge => "a face merge",
Requirement::Stitch => "a stitch",
Requirement::FaceSplit => "a face split",
Requirement::Defeature => "a defeature",
Requirement::Seed => "an edge seed",
Requirement::SizeControl => "a local size",
Requirement::Nodes => "mesh nodes",
Requirement::Mesh | Requirement::Analysable => "a mesh",
Requirement::ElementMaterial | Requirement::Material => "a material",
@@ -159,7 +171,10 @@ impl Requirement {
Requirement::Partition => "geometry.partition",
Requirement::FaceMerge => "geometry.faces.merge",
Requirement::Stitch => "geometry.stitch",
Requirement::FaceSplit => "geometry.face.split",
Requirement::Defeature => "geometry.defeature",
Requirement::Seed => "mesh.seed.edge",
Requirement::SizeControl => "mesh.size.set",
Requirement::Nodes => "mesh.node.add",
Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate",
Requirement::ElementMaterial | Requirement::Material => "model.material.create",
@@ -218,6 +233,9 @@ impl crate::schema::Collection {
C::Partition => Requirement::Partition,
C::FaceMerge => Requirement::FaceMerge,
C::Stitch => Requirement::Stitch,
C::FaceSplit => Requirement::FaceSplit,
C::SizeControl => Requirement::SizeControl,
C::Defeature => Requirement::Defeature,
}
}
}
@@ -360,6 +378,8 @@ mod tests {
C::Partition,
C::FaceMerge,
C::Stitch,
C::FaceSplit,
C::Defeature,
];
let empty = Session::default();
for c in all {
+12
View File
@@ -221,6 +221,12 @@ pub enum Collection {
FaceMerge,
/// A stitch (`geometry.stitch`).
Stitch,
/// A face split (`geometry.face.split`).
FaceSplit,
/// A size control (`mesh.size.set`).
SizeControl,
/// A defeature (`geometry.defeature`).
Defeature,
}
impl Collection {
@@ -248,6 +254,9 @@ impl Collection {
Collection::Partition => "partition",
Collection::FaceMerge => "face merge",
Collection::Stitch => "stitch",
Collection::FaceSplit => "face split",
Collection::SizeControl => "size control",
Collection::Defeature => "defeature",
}
}
}
@@ -279,6 +288,9 @@ impl Session {
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())),
Collection::SizeControl => Box::new(m.size_controls().keys().map(String::as_str)),
Collection::Defeature => Box::new(m.defeatures().iter().map(|f| f.name.as_str())),
};
names.take(MAX_NAMES).map(str::to_string).collect()
}
+417
View File
@@ -0,0 +1,417 @@
//! Defeaturing for meshing: holes, fillets, chamfers and small bosses taken out of a design so they
//! do not drive the mesh.
//!
//! A design opened from its timeline knows its features ([`Design::features`]), so a hole is
//! removed by rebuilding the design with the hole's feature suppressed, exactly as SolidCraft
//! would build it without it: the hole is filled, a fillet or chamfer goes back to its sharp edge,
//! a boss is gone. The faces the other features made keep their persistent names, so sets, seeds
//! and loads on them still resolve.
//!
//! A feature that cannot be suppressed on its own (a later feature needs it: a fillet along the
//! hole's edge, a pattern of it), or whose suppression would take a body away, is kept in the
//! B-rep and defeatured for meshing only instead, as virtual topology
//! ([`crate::virtual_faces`]): its faces are merged into the neighbour they share the longest
//! edges with, so the meshers see no edges around it (the material stays). Such a feature is
//! listed with `virtual` in [`Design::defeat_report`].
//!
//! Defeatures live in the FieldCraft model by feature id (the `F<id>` of persistent names, stable
//! across edits of the design) and are applied whenever the design is opened, before the
//! partitions. [`Design::defeatured`] keeps the design as opened, so switching a feature back on
//! rebuilds from it.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use serde::Serialize;
use solidcraft_doc::FeatureKind;
use crate::features::FeatureNode;
use crate::virtual_faces::Merge;
use crate::{Design, Error, FeatureError, Result};
/// Most features all of a design's defeatures name together.
pub const MAX_DEFEATURED: usize = 1024;
/// Most rebuilds spent finding which features suppress on their own when suppressing them all
/// at once breaks the design; the features past it are defeatured virtually.
const MAX_TRIALS: usize = 64;
/// Features of the design's timeline suppressed for meshing (`geometry.defeature`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Defeature {
pub name: String,
/// Feature ids (`F<id>`).
pub features: Vec<u64>,
}
/// How a defeatured feature was taken out.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Defeated {
/// The defeature that names it.
pub defeature: String,
pub feature: u64,
/// `suppressed` (the design rebuilt without it) or `virtual` (its faces merged into a
/// neighbour for meshing; the B-rep keeps it).
pub method: &'static str,
/// Why it could not be suppressed, for a virtual one.
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
/// The defeatures applied to a design, and the design as opened.
#[derive(Debug)]
pub(crate) struct Applied {
/// The design as opened, before any defeature.
original: Design,
defeatures: Vec<Defeature>,
report: Vec<Defeated>,
/// The virtual defeatures, as merges applied with the model's own.
merges: Vec<Merge>,
}
/// A feature a mesh-prep pass may take out, and its size.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Candidate {
pub id: u64,
pub name: String,
/// The feature's definition type (`hole`, `extrude`, `fillet`, …).
#[serde(rename = "type")]
pub kind: String,
/// `hole`, `fillet`, `chamfer` or `boss`.
pub category: &'static str,
/// The size compared: a hole's diameter, a fillet's radius, a chamfer's distance, a boss's
/// height (mm).
pub size: f64,
/// The body's faces it made.
pub faces: Vec<String>,
}
/// Whether the feature `node` is a hole, fillet, chamfer or boss, and its size.
pub fn candidate(node: &FeatureNode) -> Option<Candidate> {
let v = |k: &str| node.values.get(k).copied();
let op = node.operation.as_deref();
// A circular profile's diameter (an extrude of a circle).
let circle = || {
let p = node.sketch.as_ref()?.profiles.first()?;
match p.outer.as_slice() {
[crate::Curve::Circle { radius, .. }] if p.holes.is_empty() => Some(2.0 * radius),
_ => None,
}
};
let (category, size) = match (node.kind.as_str(), op) {
("hole", _) => ("hole", v("diameter")?),
("fillet", _) => ("fillet", v("radius")?),
("chamfer", _) => ("chamfer", v("distance")?),
("cylinder", Some("cut")) => ("hole", 2.0 * v("radius")?),
("extrude", Some("cut")) => ("hole", circle()?),
("extrude", Some("join")) => ("boss", v("distance")?.abs()),
("cylinder" | "box", Some("join")) => ("boss", v("height")?.abs()),
_ => return None,
};
(size.is_finite() && size > 0.0).then(|| Candidate {
id: node.id,
name: node.name.clone(),
kind: node.kind.clone(),
category,
size,
faces: node.faces.clone(),
})
}
impl Design {
/// The design as opened, before its defeatures.
pub fn undefeatured(&self) -> &Design {
self.defeat.as_deref().map_or(self, |a| &a.original)
}
/// How each defeatured feature was taken out, in the defeatures' order.
pub fn defeat_report(&self) -> &[Defeated] {
self.defeat.as_deref().map_or(&[], |a| a.report.as_slice())
}
/// The design with `defeatures` applied, from the design as opened, and the merges that
/// defeature its features virtually (apply them with the model's own, [`Design::merged`]).
/// A defeature naming a feature the timeline lacks, or one that builds a body or a sketch, is
/// listed in [`Design::errors`] (`defeature <name>`) and the rest still apply. A design
/// without a timeline defeatures nothing (each is an error).
pub fn defeatured(&self, defeatures: &[Defeature]) -> Result<(Design, Vec<Merge>)> {
let base = self.undefeatured();
if defeatures.iter().all(|d| d.features.is_empty()) {
return Ok((base.clone(), Vec::new()));
}
if let Some(a) = self.defeat.as_deref().filter(|a| a.defeatures == defeatures) {
return Ok((self.clone(), a.merges.clone()));
}
if defeatures.iter().map(|d| d.features.len()).sum::<usize>() > MAX_DEFEATURED {
return Err(Error::Invalid(format!("a design takes at most {MAX_DEFEATURED} defeatured features")));
}
let mut errors = Vec::new();
let fail = |d: &Defeature, message: String| FeatureError { feature: format!("defeature {}", d.name), message, bodies: Vec::new() };
// The features to take out, each with the first defeature naming it.
let mut wanted: BTreeMap<u64, &str> = BTreeMap::new();
let mut order = Vec::new();
for d in defeatures {
for &id in &d.features {
let refused = match base.history.as_deref().map(|h| h.doc.feature(id)) {
None => Some("the design has no feature timeline".to_string()),
Some(None) => Some(format!("the design has no feature F{id}")),
Some(Some(f)) if !suppressible(&f.kind) => {
Some(format!("F{id} ({}) builds a body or a sketch; it is not a feature to defeature", f.name))
}
Some(Some(_)) => None,
};
match refused {
Some(m) => errors.push(fail(d, m)),
None if !wanted.contains_key(&id) => {
wanted.insert(id, &d.name);
order.push(id);
}
None => {}
}
}
}
// Suppress them all at once; when that breaks the design, one at a time, keeping those
// that rebuild cleanly.
let mut kept: BTreeSet<u64> = order.iter().copied().collect();
let mut rebuilt = rebuild_without(base, &kept)?.ok();
let mut reasons: BTreeMap<u64, String> = BTreeMap::new();
if rebuilt.is_none() {
kept.clear();
for (k, &id) in order.iter().enumerate() {
if k >= MAX_TRIALS {
reasons.insert(id, format!("more than {MAX_TRIALS} features to try one at a time"));
continue;
}
let mut next = kept.clone();
next.insert(id);
match rebuild_without(base, &next)? {
Ok(d) => {
kept = next;
rebuilt = Some(d);
}
Err(why) => {
reasons.insert(id, why);
}
}
}
}
let mut design = match rebuilt {
Some(d) if !kept.is_empty() => d,
_ => base.clone(),
};
// The rest: their faces merged into a neighbour, body by body.
let mut merges = Vec::new();
let mut report = Vec::new();
let graph = if reasons.is_empty() { Vec::new() } else { base.features(None)? };
for &id in &order {
let defeature = wanted.get(&id).map_or_else(String::new, |n| n.to_string());
if kept.contains(&id) {
report.push(Defeated { defeature, feature: id, method: "suppressed", reason: None });
continue;
}
for b in &graph {
let Some(node) = b.features.iter().find(|f| f.id == id).filter(|f| !f.faces.is_empty()) else { continue };
match absorb(base, &b.body, &node.faces) {
Ok(Some(into)) => {
let mut faces = node.faces.clone();
faces.push(into.clone());
merges.push(Merge { name: format!("{defeature}-F{id}"), body: b.body.clone(), faces, into: Some(into) });
}
Ok(None) => {}
Err(e) => errors.push(FeatureError {
feature: format!("defeature {defeature}"),
message: format!("F{id} on `{}`: {e}", b.body),
bodies: vec![b.body.clone()],
}),
}
}
report.push(Defeated { defeature, feature: id, method: "virtual", reason: reasons.get(&id).cloned() });
}
design.errors.extend(errors);
design.defeat = Some(Arc::new(Applied { original: base.clone(), defeatures: defeatures.to_vec(), report, merges: merges.clone() }));
Ok((design, merges))
}
}
/// Whether a feature of this kind may be taken out without taking a body or a sketch with it.
fn suppressible(kind: &FeatureKind) -> bool {
use solidcraft_doc::Operation::NewBody;
match kind {
FeatureKind::Sketch { .. }
| FeatureKind::ConstructionPlane { .. }
| FeatureKind::ConstructionAxis { .. }
| FeatureKind::ConstructionPoint { .. }
| FeatureKind::Combine { .. }
| FeatureKind::Split { .. } => false,
FeatureKind::Extrude { operation, .. }
| FeatureKind::Revolve { operation, .. }
| FeatureKind::Sweep { operation, .. }
| FeatureKind::Loft { operation, .. }
| FeatureKind::Box { operation, .. }
| FeatureKind::Cylinder { operation, .. }
| FeatureKind::Sphere { operation, .. }
| FeatureKind::Torus { operation, .. } => *operation != NewBody,
_ => true,
}
}
/// `base` rebuilt with `ids` suppressed: `Err(why)` when that fails a feature that built in
/// `base` or loses one of its bodies.
fn rebuild_without(base: &Design, ids: &BTreeSet<u64>) -> Result<std::result::Result<Design, String>> {
let history = base.history.as_deref().ok_or_else(|| Error::Invalid("the design has no feature timeline".into()))?;
let mut doc = history.doc.clone();
for &id in ids {
if let Some(f) = doc.feature_mut(id) {
f.suppressed = true;
}
}
let design = crate::design::rebuild(&doc)?;
let failed: Vec<String> = design
.errors
.iter()
.filter(|e| !base.errors.iter().any(|b| b.feature == e.feature))
.map(|e| format!("{} no longer builds: {}", e.feature, e.message))
.collect();
if !failed.is_empty() {
return Ok(Err(failed.join("; ")));
}
let lost: Vec<&str> = base.source.iter().map(|b| b.name.as_str()).filter(|n| design.source.iter().all(|b| b.name != *n)).collect();
if !lost.is_empty() {
return Ok(Err(format!("body {} would be gone", lost.join(", "))));
}
Ok(Ok(design))
}
/// The face of `body` (in `base` as opened) outside `faces` that `faces` share the longest edges
/// with; `None` when the body has none of them.
fn absorb(base: &Design, body: &str, faces: &[String]) -> Result<Option<String>> {
let Some(shape) = base.source_body(body) else { return Ok(None) };
for f in faces {
if shape.faces_named(f).is_empty() {
continue;
}
// A face of the feature may border only the feature's own faces (the middle of a
// fillet chain): the next one finds the outside.
let mut probe = shape.clone();
let others: Vec<String> = faces.iter().filter(|g| *g != f && !shape.faces_named(g).is_empty()).cloned().collect();
if !others.is_empty() {
let mut all = vec![f.clone()];
all.extend(others);
match shape.merge_faces(f, &all) {
Ok(m) => probe = m,
Err(_) => continue,
}
}
if let Ok(n) = probe.absorbing_neighbour(f) {
return Ok(Some(n));
}
}
Err(Error::Invalid("its faces have no neighbour to merge into".into()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sample::{Plate, plate_design};
fn plate() -> Design {
crate::open_design(&plate_design(&Plate::default()).unwrap()).unwrap()
}
fn hole(d: &Design) -> u64 {
d.features(None).unwrap()[0].features.iter().filter_map(candidate).find(|c| c.category == "hole").unwrap().id
}
/// Suppressing the plate's hole fills it (the body is a solid block, its faces keep their
/// names); an empty list brings it back.
#[test]
fn the_plate_without_its_hole_is_a_block() {
let d = plate();
let id = hole(&d);
let c = d.features(None).unwrap()[0].features.iter().filter_map(candidate).next().unwrap();
assert_eq!((c.category, c.size), ("hole", Plate::default().hole_diameter));
let def = [Defeature { name: "Holes".into(), features: vec![id] }];
let (filled, merges) = d.defeatured(&def).unwrap();
assert!(merges.is_empty());
let p = Plate::default();
let v = filled.bodies[0].measure().unwrap().volume;
assert!((v - p.width * p.depth * p.thickness).abs() < 1e-6 * v, "{v}");
assert_eq!(filled.bodies[0].faces().len(), 6);
assert!(filled.bodies[0].faces().iter().all(|f| d.bodies[0].faces().iter().any(|g| g.name == f.name)));
assert_eq!(filled.defeat_report(), [Defeated { defeature: "Holes".into(), feature: id, method: "suppressed", reason: None }]);
// The feature graph of the defeatured design leaves the hole out; the design as opened keeps it.
assert_eq!(filled.features(None).unwrap()[0].features.len(), 1);
assert_eq!(filled.undefeatured().features(None).unwrap()[0].features.len(), 2);
// Applied again with the same list: the same design, no rebuild; with none: as opened.
let (again, _) = filled.defeatured(&def).unwrap();
assert_eq!(again.bodies[0].faces().len(), 6);
let (back, _) = filled.defeatured(&[]).unwrap();
assert_eq!(back.bodies[0].faces().len(), d.bodies[0].faces().len());
assert!(back.defeat_report().is_empty());
}
/// A feature the timeline lacks, or the base extrude, is an error of the defeature, not of
/// the call.
#[test]
fn unknown_and_body_features_are_listed_errors() {
let d = plate();
let base = d.features(None).unwrap()[0].features[0].id;
let (out, _) = d.defeatured(&[Defeature { name: "Bad".into(), features: vec![base, 999] }]).unwrap();
let errors: Vec<&str> = out.errors.iter().filter(|e| e.feature == "defeature Bad").map(|e| e.message.as_str()).collect();
assert_eq!(errors.len(), 2, "{errors:?}");
assert!(errors[0].contains("builds a body") && errors[1].contains("no feature F999"), "{errors:?}");
assert_eq!(out.bodies[0].faces().len(), d.bodies[0].faces().len());
}
/// The bracket's fillet is suppressed (back to a sharp corner) and listed as one.
#[cfg(feature = "scripts")]
#[test]
fn a_bracket_fillet_goes_back_to_its_sharp_edge() {
let script = r#"[
{"command": "sketch.create", "params": {"plane": "XY", "name": "Profile"}},
{"command": "sketch.line", "params": {"points": [[0, 0], [60, 0], [60, 8], [8, 8], [8, 50], [0, 50]], "closed": true}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 40, "body_name": "Bracket"}},
{"command": "solid.fillet", "params": {"edges": [[8, 8, 20]], "radius": 6}},
{"command": "solid.hole", "params": {"position": [40, 8, 20], "diameter": 8}}
]"#;
let d = crate::open_design(&crate::script_design(script).unwrap()).unwrap();
let cands: Vec<Candidate> = d.features(None).unwrap()[0].features.iter().filter_map(candidate).collect();
assert_eq!(cands.iter().map(|c| (c.category, c.size)).collect::<Vec<_>>(), [("fillet", 6.0), ("hole", 8.0)]);
let fillet = cands[0].id;
let (sharp, merges) = d.defeatured(&[Defeature { name: "Small".into(), features: vec![fillet] }]).unwrap();
assert!(merges.is_empty());
let (before, after) = (d.bodies[0].measure().unwrap().volume, sharp.bodies[0].measure().unwrap().volume);
// The fillet's material comes out: (1 − π/4) r² × 40 in the inside corner.
let fill = (1.0 - std::f64::consts::FRAC_PI_4) * 36.0 * 40.0;
assert!((before - after - fill).abs() < 1e-3 * fill, "{before} {after}");
assert!(sharp.bodies[0].faces().iter().all(|f| !f.name.starts_with(&format!("F{fillet}:"))));
}
/// A hole whose rim a later fillet rounds cannot be suppressed alone (the fillet would lose
/// its edge): it is defeatured virtually, its faces merged into a neighbour, the B-rep kept.
#[cfg(feature = "scripts")]
#[test]
fn a_hole_a_fillet_needs_is_defeatured_virtually() {
let script = r#"[
{"command": "solid.box", "params": {"length": 40, "width": 40, "height": 10, "body_name": "Block"}},
{"command": "solid.hole", "params": {"position": [20, 20, 10], "diameter": 10}},
{"command": "solid.fillet", "params": {"edges": [[25, 20, 10]], "radius": 1}}
]"#;
let d = crate::open_design(&crate::script_design(script).unwrap()).unwrap();
assert!(d.errors.is_empty(), "{:?}", d.errors);
let graph = d.features(None).unwrap();
let hole = graph[0].features.iter().find(|f| f.kind == "hole").unwrap();
let (out, merges) = d.defeatured(&[Defeature { name: "H".into(), features: vec![hole.id] }]).unwrap();
let report = out.defeat_report();
assert_eq!((report.len(), report[0].method), (1, "virtual"), "{report:?}");
assert!(report[0].reason.as_deref().is_some_and(|r| r.contains("no longer builds")), "{report:?}");
assert_eq!(merges.len(), 1, "{merges:?}");
let m = &merges[0];
assert!(m.into.as_ref().is_some_and(|i| !hole.faces.contains(i)) && m.faces.len() == hole.faces.len() + 1, "{m:?}");
// The B-rep is the design as opened; the merge applies.
assert_eq!(out.bodies[0].faces().len(), d.bodies[0].faces().len());
let merged = out.merged(&merges).unwrap();
assert!(merged.errors.is_empty(), "{:?}", merged.errors);
assert_eq!(merged.bodies[0].faces().len(), d.bodies[0].faces().len() - hole.faces.len());
}
}
+4 -1
View File
@@ -32,6 +32,8 @@ pub struct Design {
pub(crate) opened: std::sync::Arc<Vec<Shape>>,
/// The timeline the design was rebuilt from ([`Design::features`]).
pub(crate) history: Option<std::sync::Arc<crate::features::History>>,
/// The defeatures applied ([`Design::defeatured`]) and the design as opened before them.
pub(crate) defeat: Option<std::sync::Arc<crate::defeature::Applied>>,
}
/// A feature of the design that failed to rebuild.
@@ -129,7 +131,7 @@ pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
.map(|(i, name)| Face { name: name.clone(), exact: naming::face_name_exact(b, i), members: Vec::new() })
.collect())
})?;
bodies.push(Shape { name: b.name.clone(), body: b.body.clone(), faces, face_map: Vec::new() });
bodies.push(Shape { name: b.name.clone(), body: b.body.clone(), faces, face_map: Vec::new(), cuts: Vec::new() });
}
// A feature that failed leaves a different solid than SolidCraft's: each one names the bodies
// it would have changed, so opening never hands back a silently different solid.
@@ -164,6 +166,7 @@ pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
errors,
cuts: Vec::new(),
history: Some(std::sync::Arc::new(history)),
defeat: None,
})
}
+402
View File
@@ -0,0 +1,402 @@
//! Virtual topology: a face split in two along its curve through a plane, for meshing only.
//!
//! A partition cuts whole bodies into cells with the kernel; a [`FaceSplit`] splits one face of
//! a body along the curve where a plane crosses it, in what the meshers take from the body (the
//! B-rep is unchanged): the face's triangles are cut along the plane, the pieces on the plane's
//! negative side are `split:<name>:a` and the others `split:<name>:b` (the partition's
//! convention), both on the face's surface, and the cut is a new edge between them, so the
//! meshers put a row of nodes along it. Edges of the face that the plane crosses are cut there
//! too. Splits apply after the merges ([`crate::virtual_faces`]), by persistent names, whenever
//! the design is opened; a merge cannot name a split face.
use std::collections::BTreeMap;
use crate::edges::EDGE_TOLERANCE;
use crate::{Design, Error, Face, FeatureError, Result, Shape, SurfaceMesh};
/// Most splits a design takes.
pub const MAX_SPLITS: usize = 1024;
/// A face of a body split by a plane.
#[derive(Debug, Clone, PartialEq)]
pub struct FaceSplit {
/// Names the two faces (`split:<name>:a|b`).
pub name: String,
pub body: String,
pub face: String,
/// A point on the plane and its normal (any length but zero).
pub point: [f64; 3],
pub normal: [f64; 3],
}
/// One split as a body applies it: the face it cuts (kept as the `a` side), the `b` face's
/// index, and the plane (unit normal).
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Cut {
a: u32,
b: u32,
point: [f64; 3],
normal: [f64; 3],
}
impl Cut {
pub(crate) fn faces(&self) -> (u32, u32) {
(self.a, self.b)
}
}
fn signed(c: &Cut, p: [f64; 3]) -> f64 {
(0..3).map(|k| (p[k] - c.point[k]) * c.normal[k]).sum()
}
impl Design {
/// The design with `splits` applied in order (after its partitions and merges). A split that
/// fails (a body or face that is not there, a plane that does not cross the face) is listed
/// in [`Design::errors`] (feature `split <name>`) and changes nothing.
pub fn split(mut self, splits: &[FaceSplit]) -> Result<Design> {
if splits.len() > MAX_SPLITS {
return Err(Error::Invalid(format!("a design takes at most {MAX_SPLITS} face splits")));
}
for sp in splits {
let feature = format!("split {}", sp.name);
let Some(body) = self.bodies.iter_mut().find(|b| b.name == sp.body) else {
self.errors.push(FeatureError { feature, message: format!("no body `{}`", sp.body), bodies: vec![sp.body.clone()] });
continue;
};
match body.split_face(&sp.name, &sp.face, sp.point, sp.normal) {
Ok(split) => *body = split,
Err(e) => self.errors.push(FeatureError { feature, message: e.to_string(), bodies: vec![sp.body.clone()] }),
}
}
if self.bodies.iter().any(|b| !b.cuts.is_empty()) {
let opened = self.source.iter().map(|s| self.bodies.iter().find(|b| b.name == s.name).unwrap_or(s).clone()).collect();
self.opened = std::sync::Arc::new(opened);
}
Ok(self)
}
}
impl Shape {
/// This shape with face `face` split along the plane through `point` normal to `normal`:
/// `split:<name>:a` on its negative side, `split:<name>:b` on the other.
pub fn split_face(&self, name: &str, face: &str, point: [f64; 3], normal: [f64; 3]) -> Result<Shape> {
let len = normal.iter().map(|v| v * v).sum::<f64>().sqrt();
if !(point.iter().chain(&normal).all(|v| v.is_finite() && v.abs() <= 1e9) && len > 1e-12) {
return Err(Error::Invalid("a split needs a plane with a finite point and a non-zero normal".into()));
}
let named = self.faces_named(face);
let [a] = named.as_slice() else {
return Err(Error::Invalid(format!("body `{}` has no face `{face}` (or several pieces of it: name one)", self.name)));
};
let (an, bn) = (format!("split:{name}:a"), format!("split:{name}:b"));
if self.faces.iter().any(|f| f.name == an || f.name == bn) {
return Err(Error::Invalid(format!("body `{}` already has the faces of a split `{name}`", self.name)));
}
let b = u32::try_from(self.faces.len()).map_err(|_| Error::Invalid("too many faces".into()))?;
let mut shape = self.clone();
let original = shape.faces.get(*a as usize).cloned().ok_or_else(|| Error::Invalid("no such face".into()))?;
let members = if original.members.is_empty() { vec![original.name.clone()] } else { original.members.clone() };
if let Some(f) = shape.faces.get_mut(*a as usize) {
*f = Face { name: an, exact: original.exact, members: members.clone() };
}
shape.faces.push(Face { name: bn, exact: original.exact, members });
shape.cuts.push(Cut { a: *a, b, point, normal: normal.map(|v| v / len) });
// The plane must cross the face: both pieces have triangles.
let mesh = shape.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?;
for side in [*a, b] {
if !mesh.tri_face.contains(&side) {
return Err(Error::Invalid(format!("the plane does not cross face `{face}` of body `{}`", self.name)));
}
}
Ok(shape)
}
}
/// `mesh` with each of `cuts` applied in order (see the module docs).
pub(crate) fn apply(mesh: &mut SurfaceMesh, cuts: &[Cut]) {
for c in cuts {
cut(mesh, c);
}
}
fn lerp(p: [f64; 3], q: [f64; 3], t: f64) -> [f64; 3] {
[p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1]), p[2] + t * (q[2] - p[2])]
}
fn cut(mesh: &mut SurfaceMesh, c: &Cut) {
let scale = mesh.positions.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs())).max(1.0);
let eps = 1e-12 * scale;
let side = |d: f64| {
if d > eps {
1
} else if d < -eps {
-1
} else {
0
}
};
// A new point on the side (u, v) where the plane crosses it, once per side.
let mut made: BTreeMap<(u32, u32), u32> = BTreeMap::new();
let mut segments: Vec<(u32, u32)> = Vec::new();
let n = mesh.triangles.len();
for t in 0..n {
let (Some(&tri), Some(&tag)) = (mesh.triangles.get(t), mesh.tri_face.get(t)) else { continue };
if tag != c.a {
continue;
}
let pos = |i: u32, m: &SurfaceMesh| m.positions.get(i as usize).copied().unwrap_or([0.0; 3]);
let s = tri.map(|i| side(signed(c, pos(i, mesh))));
let tag_of = |sg: i32| if sg > 0 { c.b } else { c.a };
if s.iter().all(|&x| x >= 0) || s.iter().all(|&x| x <= 0) {
let sg = s.iter().copied().find(|&x| x != 0).unwrap_or(-1);
if let Some(f) = mesh.tri_face.get_mut(t) {
*f = tag_of(sg);
}
// A side on the plane between pieces of both sides is the cut too (picked up when
// chaining from the other triangles' points; left to the edges otherwise).
continue;
}
let mut point_on = |u: u32, v: u32, m: &mut SurfaceMesh| -> u32 {
let key = (u.min(v), u.max(v));
if let Some(&i) = made.get(&key) {
return i;
}
let (p, q) = (pos(key.0, m), pos(key.1, m));
let (dp, dq) = (signed(c, p), signed(c, q));
let x = lerp(p, q, dp / (dp - dq));
let i = u32::try_from(m.positions.len()).unwrap_or(u32::MAX);
m.positions.push(x);
made.insert(key, i);
i
};
let rot = |k: usize| [tri[k], tri[(k + 1) % 3], tri[(k + 2) % 3]];
let rs = |k: usize| [s[k], s[(k + 1) % 3], s[(k + 2) % 3]];
let mut out: Vec<([u32; 3], i32)> = Vec::with_capacity(3);
if let Some(k) = (0..3).find(|&k| s[k] == 0) {
// A corner on the plane, the other two on either side.
let ([z, u, v], [_, su, sv]) = (rot(k), rs(k));
let m = point_on(u, v, mesh);
out.push(([z, u, m], su));
out.push(([z, m, v], sv));
segments.push((z, m));
} else {
// One corner alone on its side.
let k = (0..3).find(|&k| s[k] != s[(k + 1) % 3] && s[k] != s[(k + 2) % 3]).unwrap_or(0);
let ([l, u, v], [sl, su, _]) = (rot(k), rs(k));
let (pu, pv) = (point_on(l, u, mesh), point_on(l, v, mesh));
out.push(([l, pu, pv], sl));
out.push(([pu, u, v], su));
out.push(([pu, v, pv], su));
segments.push((pu, pv));
}
let mut first = true;
for (tri, sg) in out {
if first {
if let (Some(slot), Some(f)) = (mesh.triangles.get_mut(t), mesh.tri_face.get_mut(t)) {
*slot = tri;
*f = tag_of(sg);
}
first = false;
} else {
mesh.triangles.push(tri);
mesh.tri_face.push(tag_of(sg));
}
}
}
// The face's edges: cut where the plane crosses them, each piece on its side's face.
let mut edges = Vec::with_capacity(mesh.edges.len() + 1);
let mut edge_faces = Vec::with_capacity(mesh.edges.len() + 1);
for (points, faces) in std::mem::take(&mut mesh.edges).into_iter().zip(std::mem::take(&mut mesh.edge_faces)) {
if !faces.contains(&c.a) {
edges.push(points);
edge_faces.push(faces);
continue;
}
let mut piece: Vec<[f64; 3]> = Vec::new();
let mut piece_side = 0;
let mut flush = |piece: &mut Vec<[f64; 3]>, sg: i32| {
if piece.len() >= 2 {
let on = if sg > 0 { c.b } else { c.a };
edges.push(std::mem::take(piece));
edge_faces.push(faces.iter().map(|&f| if f == c.a { on } else { f }).collect());
}
piece.clear();
};
for (k, &p) in points.iter().enumerate() {
let sp = side(signed(c, p));
if k > 0
&& let Some(&q) = points.get(k - 1)
{
let sq = side(signed(c, q));
if sp != 0 && sq != 0 && sp != sq {
let (dq, dp) = (signed(c, q), signed(c, p));
let x = lerp(q, p, dq / (dq - dp));
piece.push(x);
flush(&mut piece, piece_side);
piece.push(x);
}
}
if sp != 0 {
piece_side = sp;
}
piece.push(p);
}
flush(&mut piece, piece_side);
}
// The cut: its segments chained into polylines, between the two faces. Copies of one point
// (a tessellation may repeat points) are one point for the chaining.
let close = (2.0 * mesh.weld_distance).max(eps);
let mut canon: BTreeMap<u32, u32> = BTreeMap::new();
let mut reps: Vec<(u32, [f64; 3])> = Vec::new();
for &i in segments.iter().flat_map(|(u, v)| [u, v]) {
if canon.contains_key(&i) {
continue;
}
let p = mesh.positions.get(i as usize).copied().unwrap_or([0.0; 3]);
let r = match reps.iter().find(|(_, q)| (0..3).map(|k| (p[k] - q[k]).powi(2)).sum::<f64>().sqrt() <= close) {
Some((r, _)) => *r,
None => {
reps.push((i, p));
i
}
};
canon.insert(i, r);
}
let segments: Vec<(u32, u32)> =
segments.iter().map(|(u, v)| (canon.get(u).copied().unwrap_or(*u), canon.get(v).copied().unwrap_or(*v))).filter(|(u, v)| u != v).collect();
for line in chain(&segments) {
let points: Vec<[f64; 3]> = line.iter().filter_map(|&i| mesh.positions.get(i as usize).copied()).collect();
if points.len() >= 2 {
edges.push(points);
edge_faces.push(vec![c.a, c.b]);
}
}
mesh.edges = edges;
mesh.edge_faces = edge_faces;
}
/// Segments (point index pairs) chained end to end into polylines, in a fixed order.
fn chain(segments: &[(u32, u32)]) -> Vec<Vec<u32>> {
let mut at: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
for (i, &(u, v)) in segments.iter().enumerate() {
at.entry(u).or_default().push(i);
at.entry(v).or_default().push(i);
}
let mut used = vec![false; segments.len()];
let mut lines = Vec::new();
// Start at ends (points on one segment) first, then what is left (closed loops).
let ends: Vec<usize> = at.values().filter(|s| s.len() == 1).flatten().copied().collect();
let starts: Vec<usize> = ends.into_iter().chain(0..segments.len()).collect();
for s in starts {
if used.get(s).copied().unwrap_or(true) {
continue;
}
let Some(&(u, v)) = segments.get(s) else { continue };
// From an end segment, start at its free point.
let (mut line, mut last) = if at.get(&v).is_some_and(|x| x.len() == 1) { (vec![v, u], u) } else { (vec![u, v], v) };
if let Some(x) = used.get_mut(s) {
*x = true;
}
while let Some(&next) = at.get(&last).and_then(|x| x.iter().find(|&&i| !used.get(i).copied().unwrap_or(true))) {
if let Some(x) = used.get_mut(next) {
*x = true;
}
let Some(&(a, b)) = segments.get(next) else { break };
last = if a == last { b } else { a };
line.push(last);
}
lines.push(line);
}
lines
}
#[cfg(test)]
mod tests {
use crate::open_design;
use crate::sample::{self, Plate};
use super::*;
/// The plate's top (z = 10, 40 × 30 with the Ø10 hole at (15, 15)) split at x = 30: two
/// faces on the top's plane, a straight cut edge between them along x = 30, the top's rim
/// edges at y = 0 and y = 30 cut there; triangles all on one side of the plane each.
#[test]
fn the_plates_top_splits_along_a_plane() {
let d = open_design(&sample::plate_design(&Plate::default()).unwrap()).unwrap();
let body = d.bodies[0].name.clone();
let b = &d.bodies[0];
let surfaces = b.face_surfaces().unwrap();
let top = b
.faces()
.iter()
.zip(&surfaces)
.find(|(_, s)| matches!(s, Some(crate::FaceSurface::Plane { normal, point }) if normal[2] > 0.5 && point[2] > 9.0))
.unwrap()
.0
.name
.clone();
let split = |x: f64| FaceSplit { name: "S".into(), body: body.clone(), face: top.clone(), point: [x, 0.0, 0.0], normal: [1.0, 0.0, 0.0] };
let d = d.split(&[split(30.0)]).unwrap();
assert!(d.errors.is_empty(), "{:?}", d.errors);
let b = &d.bodies[0];
assert_eq!(b.faces().len(), 9);
let (fa, fb) = (b.face_index("split:S:a").unwrap(), b.face_index("split:S:b").unwrap());
let s = b.face_surfaces().unwrap();
assert_eq!(s[fa as usize], s[fb as usize]);
let mesh = b.tessellate(0.01).unwrap();
for (t, &f) in mesh.triangles.iter().zip(&mesh.tri_face) {
let x = t.map(|i| mesh.positions[i as usize][0]);
if f == fa {
assert!(x.iter().all(|&v| v <= 30.0 + 1e-9), "{x:?}");
} else if f == fb {
assert!(x.iter().all(|&v| v >= 30.0 - 1e-9), "{x:?}");
}
}
// The area is kept: 40 × 30 less the hole, split 30 × 30 − π25 and 10 × 30.
let area = |f: u32| -> f64 {
mesh.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, g)| **g == f)
.map(|(t, _)| {
let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
let (u, v) = ([b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]]);
0.5 * ((u[1] * v[2] - u[2] * v[1]).powi(2) + (u[2] * v[0] - u[0] * v[2]).powi(2) + (u[0] * v[1] - u[1] * v[0]).powi(2)).sqrt()
})
.sum()
};
assert!((area(fb) - 300.0).abs() < 1e-6, "{}", area(fb));
assert!((area(fa) - (900.0 - std::f64::consts::PI * 25.0)).abs() < 0.5, "{}", area(fa));
let edges = b.edges().unwrap();
let cut: Vec<_> = edges.iter().filter(|e| e.faces.contains(&fa) && e.faces.contains(&fb)).collect();
assert_eq!(cut.len(), 1, "{:?}", edges.iter().map(|e| &e.name).collect::<Vec<_>>());
assert!((cut[0].length() - 30.0).abs() < 1e-9 && cut[0].points.iter().all(|p| (p[0] - 30.0).abs() < 1e-9));
// The top's rim edges along x are cut at x = 30: four pieces where there were two.
let rim = edges.iter().filter(|e| e.faces.iter().any(|&f| f == fa || f == fb) && !(e.faces.contains(&fa) && e.faces.contains(&fb))).count();
assert_eq!(rim, 2 + 2 + 2 + 2, "four sides, the two along x in two pieces, and the hole's two rim arcs");
}
#[test]
fn bad_splits_are_listed() {
let d = open_design(&sample::plate_design(&Plate::default()).unwrap()).unwrap();
let body = d.bodies[0].name.clone();
let face = d.bodies[0].faces()[0].name.clone();
let sp = |n: &str, b: &str, f: &str, x: f64, normal: [f64; 3]| FaceSplit {
name: n.into(),
body: b.into(),
face: f.into(),
point: [x, 0.0, 0.0],
normal,
};
let d = d
.split(&[
sp("Far", &body, &face, 1000.0, [1.0, 0.0, 0.0]),
sp("NoFace", &body, "nope", 20.0, [1.0, 0.0, 0.0]),
sp("NoBody", "x", &face, 20.0, [1.0, 0.0, 0.0]),
sp("Zero", &body, &face, 20.0, [0.0; 3]),
])
.unwrap();
assert_eq!(d.errors.len(), 4, "{:?}", d.errors);
assert_eq!(d.bodies[0].faces().len(), 8);
}
}
+29 -6
View File
@@ -18,8 +18,10 @@
#![forbid(unsafe_code)]
pub mod check;
pub mod defeature;
mod design;
mod edges;
pub mod face_split;
pub mod features;
pub mod partition;
pub mod sample;
@@ -70,6 +72,8 @@ pub struct Shape {
faces: Vec<Face>,
/// The virtual face (index into `faces`) of each B-rep face; empty: they are the same.
face_map: Vec<u32>,
/// Faces split along a plane ([`face_split`]), applied after the merges, in order.
cuts: Vec<face_split::Cut>,
}
/// A B-rep face's persistent identity.
@@ -156,7 +160,7 @@ fn xyz(v: solidcraft_geom::Vec3) -> [f64; 3] {
pub fn block(a: [f64; 3], b: [f64; 3]) -> Result<Shape> {
let v = |p: [f64; 3]| solidcraft_geom::Vec3 { x: p[0], y: p[1], z: p[2] };
let body = guard("block", || solidcraft_kernel::box_solid(v(a), v(b)))?;
Ok(Shape { name: "Block".into(), body, faces: Vec::new(), face_map: Vec::new() })
Ok(Shape { name: "Block".into(), body, faces: Vec::new(), face_map: Vec::new(), cuts: Vec::new() })
}
impl Shape {
@@ -203,8 +207,8 @@ impl Shape {
self.virtual_surfaces(brep)
}
/// [`Shape::face_surfaces`] by B-rep face index.
pub(crate) fn brep_face_surfaces(&self) -> Result<Vec<Option<FaceSurface>>> {
/// [`Shape::face_surfaces`] by B-rep face index (the pieces of [`Shape::tessellate_pieces`]).
pub fn brep_face_surfaces(&self) -> Result<Vec<Option<FaceSurface>>> {
guard("face surfaces", || {
Ok((0..self.body.face_count())
.map(|f| {
@@ -246,7 +250,7 @@ impl Shape {
centroid: xyz(m.centroid),
bbox_min: xyz(m.bbox.min),
bbox_max: xyz(m.bbox.max),
faces: if self.face_map.is_empty() { m.faces } else { self.faces.len() },
faces: if self.face_map.is_empty() && self.cuts.is_empty() { m.faces } else { self.faces.len() },
})
}
@@ -254,18 +258,37 @@ impl Shape {
/// by face index ([`Shape::faces`]): merged faces are one face, without the edges between
/// their B-rep faces.
pub fn tessellate(&self, tolerance: f64) -> Result<SurfaceMesh> {
Ok(self.virtual_mesh(self.tessellate_brep(tolerance)?))
}
/// [`Shape::tessellate`], and per triangle the B-rep face it lies on: its piece of a merged
/// face, whose surface is in [`Shape::brep_face_surfaces`] (the same as its tag when no face
/// is merged).
pub fn tessellate_pieces(&self, tolerance: f64) -> Result<(SurfaceMesh, Vec<u32>)> {
let brep = self.tessellate_brep(tolerance)?;
let pieces = brep.tri_face.clone();
Ok((self.virtual_mesh(brep), pieces))
}
/// The B-rep's own tessellation, tagged by B-rep face.
fn tessellate_brep(&self, tolerance: f64) -> Result<SurfaceMesh> {
if !(tolerance > 0.0 && tolerance.is_finite()) {
return Err(Error::Invalid(format!("tessellation tolerance must be a positive number, got {tolerance}")));
}
let m = guard("tessellate", || self.body.tessellate(tolerance))?;
Ok(self.virtual_mesh(SurfaceMesh {
Ok(SurfaceMesh {
positions: m.positions.into_iter().map(xyz).collect(),
triangles: m.triangles,
tri_face: m.tri_face,
weld_distance: 2.0 * LINEAR_TOLERANCE,
edges: m.edges.into_iter().map(|e| e.into_iter().map(xyz).collect()).collect(),
edge_faces: m.edge_faces,
}))
})
}
/// Whether some face is made of several B-rep faces ([`virtual_faces`]).
pub fn has_merged_faces(&self) -> bool {
!self.face_map.is_empty()
}
}
+2 -1
View File
@@ -178,6 +178,7 @@ impl Design {
source: std::sync::Arc::clone(&self.source),
opened: std::sync::Arc::clone(&self.source),
history: self.history.clone(),
defeat: self.defeat.clone(),
})
}
}
@@ -277,7 +278,7 @@ fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result<Split> {
let parent = Faces::of(body, tol)?;
let mut cells: Vec<(bool, [f64; 3], Shape)> = Vec::with_capacity(parts.len());
for part in parts {
let mut shape = Shape { name: String::new(), body: part, faces: Vec::new(), face_map: Vec::new() };
let mut shape = Shape { name: String::new(), body: part, faces: Vec::new(), face_map: Vec::new(), cuts: Vec::new() };
let m = shape.measure()?;
let negative = dot(sub(m.centroid, cut.point), n) < 0.0;
shape.faces = (0..m.faces).map(|_| Face { name: String::new(), exact: false, members: Vec::new() }).collect();
+204 -35
View File
@@ -6,12 +6,15 @@
//! the body ([`Shape::tessellate`], [`Shape::faces`], [`Shape::face_surfaces`], edge and vertex
//! names): the triangles of its B-rep faces carry one tag, the edges between them are gone (an
//! edge they leave split in pieces along a neighbour is joined into one), and its surface is the
//! one its B-rep faces share (none when they lie on different surfaces: meshers then keep to the
//! tessellation). The B-rep itself is unchanged.
//! one its B-rep faces share (none when they lie on different surfaces: the projection mesher and
//! node relaxation then keep to each B-rep piece's own surface, [`Shape::tessellate_pieces`],
//! the other meshers to the tessellation). The B-rep itself is unchanged.
//!
//! Merges live in the FieldCraft model, by persistent face names, and are applied again after the
//! partitions whenever the design is opened, so an edit of the design followed by a re-open
//! replays them. A merge whose faces are gone or no longer touch is listed in [`Design::errors`]
//! replays them. On a body the partitions cut (a mesh plan's symmetry and corner cuts), a merge
//! applies to each cell: the pieces of its faces that touch there are one face of the cell. A
//! merge whose faces are gone or no longer touch is listed in [`Design::errors`]
//! (feature `merge <name>`), as a feature that failed to rebuild; the others still apply.
//!
//! The merged face is named `merge:<name>`. A face name in a merge selects the face of that name,
@@ -50,43 +53,132 @@ pub fn merged_face_name(name: &str) -> String {
/// Whether a feature error comes from the model's partitions or merges (applied again from the
/// design as opened) rather than from the design's own timeline.
pub(crate) fn derived(feature: &str) -> bool {
feature.starts_with("partition ") || feature.starts_with("merge ")
feature.starts_with("partition ") || feature.starts_with("merge ") || feature.starts_with("split ")
}
impl Design {
/// The design with `merges` applied in order, on top of its partitions. A merge that fails on
/// this design (a body or face that is not there, faces that do not touch) is listed in
/// [`Design::errors`] (feature `merge <name>`) and changes nothing; the rest still apply. A
/// body no partition cut is also merged as opened ([`Design::source_body`], the feature graph).
/// The design with `merges` applied in order, on top of its partitions. A merge on a body
/// the partitions cut applies to each of its cells: the faces it names that a cell has
/// (their pieces included) and that touch there are made one face of that cell ([`cell_merges`]).
/// A merge that fails on this design (a body or face that is not there, faces that do not
/// touch, in any cell) is listed in [`Design::errors`] (feature `merge <name>`) and changes
/// nothing; the rest still apply. The bodies as opened are merged too
/// ([`Design::source_body`], the feature graph), whether partitions cut them or not.
pub fn merged(mut self, merges: &[Merge]) -> Result<Design> {
if merges.len() > MAX_MERGES {
return Err(Error::Invalid(format!("a design takes at most {MAX_MERGES} face merges")));
}
let mut any = false;
for m in merges {
let feature = format!("merge {}", m.name);
let Some(body) = self.bodies.iter_mut().find(|b| b.name == m.body) else {
self.errors.push(FeatureError { feature, message: format!("no body `{}`", m.body), bodies: vec![m.body.clone()] });
continue;
};
let name = m.into.clone().unwrap_or_else(|| merged_face_name(&m.name));
if m.into.as_ref().is_some_and(|i| !m.faces.contains(i)) {
let message = format!("the face it keeps, `{name}`, is not one it merges");
self.errors.push(FeatureError { feature, message, bodies: vec![m.body.clone()] });
let fail = |message: String| FeatureError { feature: feature.clone(), message, bodies: vec![m.body.clone()] };
if let Some(i) = m.into.as_ref().filter(|i| !m.faces.contains(i)) {
self.errors.push(fail(format!("the face it keeps, `{i}`, is not one it merges")));
continue;
}
match body.merge_faces(&name, &m.faces) {
Ok(merged) => *body = merged,
Err(e) => self.errors.push(FeatureError { feature, message: e.to_string(), bodies: vec![m.body.clone()] }),
if let Some(body) = self.bodies.iter_mut().find(|b| b.name == m.body) {
let name = m.into.clone().unwrap_or_else(|| merged_face_name(&m.name));
match body.merge_faces(&name, &m.faces) {
Ok(merged) => {
*body = merged;
any = true;
}
Err(e) => self.errors.push(fail(e.to_string())),
}
continue;
}
let prefix = format!("{}/", m.body);
let cells: Vec<usize> = (0..self.bodies.len()).filter(|&i| self.bodies.get(i).is_some_and(|b| b.name.starts_with(&prefix))).collect();
if cells.is_empty() {
self.errors.push(fail(format!("no body `{}`", m.body)));
continue;
}
// Each cell merged on its own; the merge fails as a whole when no cell has two of its
// faces touching, or one of them refuses it.
let mut next = Vec::with_capacity(cells.len());
let mut error = None;
for &i in &cells {
let Some(cell) = self.bodies.get(i) else { continue };
match cell_merges(cell, m) {
Ok(Some(merged)) => next.push((i, merged)),
Ok(None) => {}
Err(e) => {
error = Some(format!("cell `{}`: {e}", cell.name));
break;
}
}
}
match (error, next.is_empty()) {
(Some(e), _) => self.errors.push(fail(e)),
(None, true) => self.errors.push(fail(format!("no cell of `{}` has two of its faces touching", m.body))),
(None, false) => {
for (i, merged) in next {
if let Some(slot) = self.bodies.get_mut(i) {
*slot = merged;
}
}
any = true;
}
}
}
if self.bodies.iter().any(|b| !b.face_map.is_empty()) {
let opened = self.source.iter().map(|s| self.bodies.iter().find(|b| b.name == s.name).unwrap_or(s).clone()).collect();
if any {
let opened = self
.source
.iter()
.map(|s| match self.bodies.iter().find(|b| b.name == s.name) {
Some(b) => b.clone(),
// Cut into cells: the body as opened with its own merges (each one that
// applies; the cells reported the rest).
None => merges.iter().filter(|m| m.body == s.name).fold(s.clone(), |shape, m| {
let name = m.into.clone().unwrap_or_else(|| merged_face_name(&m.name));
shape.merge_faces(&name, &m.faces).unwrap_or(shape)
}),
})
.collect();
self.opened = std::sync::Arc::new(opened);
}
Ok(self)
}
}
/// Merge `m` on a cell partitions cut from its body: the faces it names that the cell has (or
/// pieces of them), in the groups that touch there, each group of two or more made one face —
/// named after the face it keeps (`into`; that face's piece in the cell), else `merge:<name>`
/// (`merge:<name>#0`, `#1`, … when the cell has several groups, as a partition names pieces).
/// `None` when the cell has no two of them touching.
fn cell_merges(cell: &Shape, m: &Merge) -> Result<Option<Shape>> {
let mut members = BTreeSet::new();
for f in &m.faces {
members.extend(cell.faces_named(f));
}
let groups: Vec<BTreeSet<u32>> = cell.components(&members)?.into_iter().filter(|g| g.len() > 1).collect();
if groups.is_empty() {
return Ok(None);
}
let kept: BTreeSet<u32> = m.into.as_deref().map(|i| cell.faces_named(i).into_iter().collect()).unwrap_or_default();
// Names first (indices change as faces merge), then the merges by those names.
let unnamed = groups.iter().filter(|g| g.is_disjoint(&kept)).count();
let mut k = 0;
let mut plan = Vec::with_capacity(groups.len());
for g in &groups {
let names: Vec<String> = g.iter().map(|&f| cell.face_label(f)).collect();
let name = match g.intersection(&kept).next() {
Some(&f) => cell.face_label(f),
None if unnamed == 1 => merged_face_name(&m.name),
None => {
k += 1;
format!("{}#{}", merged_face_name(&m.name), k - 1)
}
};
plan.push((name, names));
}
let mut shape = cell.clone();
for (name, faces) in plan {
shape = shape.merge_faces(&name, &faces)?;
}
Ok(Some(shape))
}
impl Shape {
/// The faces with this name: the face itself, else the pieces a partition cut it into
/// (`<name>#k`).
@@ -119,6 +211,9 @@ impl Shape {
if faces.len() > MAX_MERGE_FACES {
return Err(Error::Invalid(format!("a merge takes at most {MAX_MERGE_FACES} faces")));
}
if !self.cuts.is_empty() {
return Err(Error::Invalid("merges come before splits: a merge cannot take a split face".into()));
}
let mut members = BTreeSet::new();
for f in faces {
let found = self.faces_named(f);
@@ -162,7 +257,7 @@ impl Shape {
}
let brep = if self.face_map.is_empty() { (0..u32::try_from(self.faces.len()).unwrap_or(0)).collect() } else { self.face_map.clone() };
let face_map = brep.iter().map(|&v| renumber.get(v as usize).copied().unwrap_or(v)).collect();
Ok(Shape { name: self.name.clone(), body: self.body.clone(), faces: kept, face_map })
Ok(Shape { name: self.name.clone(), body: self.body.clone(), faces: kept, face_map, cuts: Vec::new() })
}
/// The neighbour face `face` shares the longest edges with (by name): where a tiny or sliver
@@ -189,18 +284,8 @@ impl Shape {
/// An error unless `members` touch one another along edges, all in one piece.
fn connected(&self, members: &BTreeSet<u32>) -> Result<()> {
let mesh = self.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?;
let mut reached = BTreeSet::new();
let mut todo: Vec<u32> = members.first().copied().into_iter().collect();
while let Some(f) = todo.pop() {
if !reached.insert(f) {
continue;
}
for faces in mesh.edge_faces.iter().filter(|fs| fs.contains(&f)) {
todo.extend(faces.iter().filter(|g| members.contains(g) && !reached.contains(g)));
}
}
match members.iter().find(|f| !reached.contains(f)) {
let groups = self.components(members)?;
match groups.get(1).and_then(|g| g.first()) {
None => Ok(()),
Some(&f) => Err(Error::Invalid(format!(
"face `{}` of body `{}` does not touch the others along an edge: a merge makes adjacent faces one",
@@ -210,10 +295,38 @@ impl Shape {
}
}
/// `members` in groups that touch along edges (the group of the first member first).
fn components(&self, members: &BTreeSet<u32>) -> Result<Vec<BTreeSet<u32>>> {
if members.is_empty() {
return Ok(Vec::new());
}
let mesh = self.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?;
let mut groups: Vec<BTreeSet<u32>> = Vec::new();
for &start in members {
if groups.iter().any(|g| g.contains(&start)) {
continue;
}
let mut reached = BTreeSet::new();
let mut todo = vec![start];
while let Some(f) = todo.pop() {
if !reached.insert(f) {
continue;
}
for faces in mesh.edge_faces.iter().filter(|fs| fs.contains(&f)) {
todo.extend(faces.iter().filter(|g| members.contains(g) && !reached.contains(g)));
}
}
groups.push(reached);
}
Ok(groups)
}
/// The surfaces of the B-rep faces face `face` is made of (one for a face not merged); `None`
/// for a free-form one.
pub fn member_surfaces(&self, face: u32) -> Result<Vec<Option<FaceSurface>>> {
let brep = self.brep_face_surfaces()?;
// The b side of a split is made of the face it was split from.
let face = self.cuts.iter().rev().fold(face, |f, c| if c.faces().1 == f { c.faces().0 } else { f });
if self.face_map.is_empty() {
return Ok(brep.get(face as usize).copied().into_iter().collect());
}
@@ -223,6 +336,21 @@ impl Shape {
/// `brep` (surfaces by B-rep face) by face: a merged face has its B-rep faces' surface when
/// they share one, else none.
pub(crate) fn virtual_surfaces(&self, brep: Vec<Option<FaceSurface>>) -> Result<Vec<Option<FaceSurface>>> {
let mut out = self.merged_surfaces(brep)?;
for c in &self.cuts {
let (a, b) = c.faces();
let s = out.get(a as usize).copied().flatten();
if out.len() <= b as usize {
out.resize(b as usize + 1, None);
}
if let Some(slot) = out.get_mut(b as usize) {
*slot = s;
}
}
Ok(out)
}
fn merged_surfaces(&self, brep: Vec<Option<FaceSurface>>) -> Result<Vec<Option<FaceSurface>>> {
if self.face_map.is_empty() {
return Ok(brep);
}
@@ -243,7 +371,13 @@ impl Shape {
/// `mesh` (a tessellation of the B-rep) with the merged faces: triangles tagged by face,
/// the edges inside a merged face dropped and the pieces of an edge it leaves joined.
pub(crate) fn virtual_mesh(&self, mut mesh: SurfaceMesh) -> SurfaceMesh {
pub(crate) fn virtual_mesh(&self, mesh: SurfaceMesh) -> SurfaceMesh {
let mut mesh = self.merged_mesh(mesh);
crate::face_split::apply(&mut mesh, &self.cuts);
mesh
}
fn merged_mesh(&self, mut mesh: SurfaceMesh) -> SurfaceMesh {
if self.face_map.is_empty() {
return mesh;
}
@@ -446,6 +580,41 @@ mod tests {
assert_eq!(b.face_surfaces().unwrap()[f as usize], None);
}
/// A merge on a body the partitions cut applies to each cell: the plate cut across its hole
/// leaves a piece of each half in each cell, and each cell's two pieces are one face there,
/// named after the face kept; the body as opened keeps the merge too.
#[test]
fn a_merge_applies_to_the_cells_of_a_partitioned_body() {
let d = plate();
let body = d.bodies[0].name.clone();
let hole = halves(&d);
let cut = crate::partition::Cut { name: "Mid".into(), point: [0.0, 0.0, 5.0], normal: [0.0, 0.0, 1.0], bodies: Vec::new() };
let absorb = Merge { name: "A".into(), body: body.clone(), faces: hole.clone(), into: Some(hole[1].clone()) };
let d = d.partitioned(&[cut]).unwrap().merged(&[absorb]).unwrap();
assert!(d.errors.is_empty(), "{:?}", d.errors);
assert_eq!(d.bodies.len(), 2);
for cell in &d.bodies {
let [(f, 2)] = cell.merged_faces()[..] else { panic!("{}: {:?}", cell.name, cell.merged_faces()) };
assert_eq!(cell.faces()[f as usize].name, hole[1], "{}", cell.name);
assert!(cell.faces_named(&hole[0]).is_empty(), "{}", cell.name);
}
let opened = d.source_body(&body).unwrap();
assert_eq!(opened.merged_faces().len(), 1);
// A merge whose faces touch in no cell is listed.
let names: Vec<String> = plate().bodies[0].faces().iter().map(|f| f.name.clone()).collect();
let cut = crate::partition::Cut { name: "Mid".into(), point: [0.0, 0.0, 5.0], normal: [0.0, 0.0, 1.0], bodies: Vec::new() };
let s = plate().bodies[0].face_surfaces().unwrap();
let caps: Vec<String> = names
.iter()
.zip(&s)
.filter(|(_, s)| matches!(s, Some(FaceSurface::Plane { normal, .. }) if normal[2].abs() > 0.5))
.map(|(n, _)| n.clone())
.collect();
let apart = Merge { name: "Apart".into(), body, faces: caps, into: None };
let d = plate().partitioned(&[cut]).unwrap().merged(&[apart]).unwrap();
assert_eq!(d.errors.iter().map(|e| e.feature.as_str()).collect::<Vec<_>>(), ["merge Apart"]);
}
/// The neighbour a half of the hole is best absorbed into is the other half (two 10 mm seams,
/// against a 15.7 mm rim arc with each cap); absorbing it keeps the other half's name.
#[test]
+4
View File
@@ -10,6 +10,10 @@ workspace = true
[dependencies]
fieldcraft-model = { workspace = true }
# Pure-Rust maths (a port of musl's libm) for sin, cos, log, exp, pow, hypot, acos: the same
# bits on every platform, where the system library's are not correctly rounded and differ (the
# plate's hex mesh differed between macOS and Linux, #438). atan2 and cbrt are crate::vec3's.
libm = "0.2"
thiserror = { workspace = true }
[dev-dependencies]
+4 -4
View File
@@ -146,7 +146,7 @@ impl Path {
if norm(sub(back, p)) > 1e-9 * la.max(lb) || ((c * c + s * s).sqrt() - 1.0).abs() > 1e-9 {
return Err(Error::Invalid(format!("corner {p:?} is not on the edge's ellipse or arc about {center:?}")));
}
Ok(s.atan2(c))
Ok(crate::vec3::atan2(s, c))
};
let start = angle(from)?;
let mut sweep = angle(to)? - start;
@@ -170,7 +170,7 @@ impl Path {
Path::Line { from, to } => add_scaled(from, s, sub(to, from)),
Path::Ellipse { center, a, b, start, sweep, .. } => {
let t = start + s * sweep;
add_scaled(add_scaled(center, t.cos(), a), t.sin(), b)
add_scaled(add_scaled(center, libm::cos(t), a), libm::sin(t), b)
}
}
}
@@ -179,7 +179,7 @@ impl Path {
/// The parameters of `n` elements along one direction with last/first size ratio `bias`, at
/// `order` points per element (`order · n + 1` values from 0 to 1; mid-side values halfway).
fn parameters(n: usize, bias: f64, order: usize) -> Vec<f64> {
let q = if n > 1 { bias.powf(1.0 / (n - 1) as f64) } else { 1.0 };
let q = if n > 1 { libm::pow(bias, 1.0 / (n - 1) as f64) } else { 1.0 };
let mut ends = Vec::with_capacity(n + 1);
let (mut sum, mut size) = (0.0, 1.0);
ends.push(0.0);
@@ -645,7 +645,7 @@ mod tests {
];
let m = block(&Block { kind: ElementKind::Hex20, corners, divisions: [3, 6, 2], bias: [1.0; 3], curves }).unwrap();
for p in &m.points {
let r = p[0].hypot(p[1]);
let r = libm::hypot(p[0], p[1]);
assert!(r > a - 1e-12 && r < b + 1e-12, "{p:?}");
}
let exact = std::f64::consts::PI / 4.0 * (b * b - a * a) * 0.5;
+1 -1
View File
@@ -372,7 +372,7 @@ fn composite_tags(surface: &BoundarySurface, surfaces: &[Option<Surface>]) -> Ve
unit(cross(sub(b, a), sub(c, a)))
})
};
let cos_min = SMOOTH_ANGLE.cos();
let cos_min = libm::cos(SMOOTH_ANGLE);
// Per pair of faces (low tag, high tag): whether every segment between them is smooth.
let mut pairs: BTreeMap<(u32, u32), bool> = BTreeMap::new();
for fe in surface.feature_edges().iter().filter(|e| e.kind == FeatureKind::FaceBoundary) {
+8 -5
View File
@@ -166,7 +166,7 @@ fn sub(a: Q, b: Q) -> Q {
}
fn len(a: Q) -> f64 {
a[0].hypot(a[1])
libm::hypot(a[0], a[1])
}
fn cross(a: Q, b: Q) -> f64 {
@@ -186,14 +186,17 @@ fn unit(a: Q) -> Option<Q> {
/// region on the left: π on a straight line, less at a convex corner, more at a concave one.
fn interior(a: Q, o: Q, b: Q) -> f64 {
let (u, v) = (sub(o, a), sub(b, o));
std::f64::consts::PI - cross(u, v).atan2(dot(u, v))
std::f64::consts::PI - crate::vec3::atan2(cross(u, v), dot(u, v))
}
/// The inward bisector at a corner of interior angle `theta` whose outgoing edge runs along unit
/// `w`: `w` turned left by half the angle.
fn bisector(w: Q, theta: f64) -> Q {
let half = 0.5 * theta;
[w[0] * half.cos() - w[1] * half.sin(), w[0] * half.sin() + w[1] * half.cos()]
{
let (s, c) = (libm::sin(half), libm::cos(half));
[w[0] * c - w[1] * s, w[0] * s + w[1] * c]
}
}
fn centroid(pts: &[Q]) -> Q {
@@ -1320,7 +1323,7 @@ mod tests {
/// The half crank's cross-section (y ≤ 0) as the extrusion projects it: the outline, the
/// bosses' circles where they rise above the arm, and the pocket's outline.
fn crank_half() -> (Profile, Vec<Vec<Q>>) {
let deg = |p: Q, c: Q| (p[1] - c[1]).atan2(p[0] - c[0]).to_degrees();
let deg = |p: Q, c: Q| crate::vec3::atan2(p[1] - c[1], p[0] - c[0]).to_degrees();
let spindle = [0.0, 0.0];
let pedal = [170.0, 0.0];
let j1 = [13.455063072993756, -18.65372021077189];
@@ -1344,7 +1347,7 @@ mod tests {
};
let outer = vec![
a(spindle, 23.0, [-23.0, 0.0], j1, true),
a(c1, (j1[0] - c1[0]).hypot(j1[1] - c1[1]), j1, r1, false),
a(c1, libm::hypot(j1[0] - c1[0], j1[1] - c1[1]), j1, r1, false),
line(r1, r2),
a(c2, 6.0, r2, j2, false),
a(pedal, 15.0, j2, [185.0, 0.0], true),
+1 -1
View File
@@ -410,7 +410,7 @@ fn section_boundary(quads: &[[u32; 4]]) -> Result<SectionBoundary> {
/// The layer parameters `0 = t_0 < … < t_n = 1`, thicknesses in geometric progression with the
/// last over the first equal to `bias`.
pub(crate) fn parameters(n: usize, bias: f64) -> Vec<f64> {
let r = if n > 1 { bias.powf(1.0 / (n - 1) as f64) } else { 1.0 };
let r = if n > 1 { libm::pow(bias, 1.0 / (n - 1) as f64) } else { 1.0 };
let mut out = Vec::with_capacity(n + 1);
let (mut sum, mut h) = (0.0, 1.0);
out.push(0.0);
+3 -3
View File
@@ -101,7 +101,7 @@ impl Surface {
let h = dot(w, axis);
let radial = unit(sub(w, scale(axis, h)))?;
let rho = norm(sub(w, scale(axis, h)));
let (c, s) = (half_angle.cos(), half_angle.sin());
let (c, s) = (libm::cos(half_angle), libm::sin(half_angle));
let t = (h * c + rho * s).max(0.0);
add(apex, add(scale(axis, t * c), scale(radial, t * s)))
}
@@ -128,7 +128,7 @@ impl Surface {
Surface::Cone { apex, axis, half_angle } => {
let w = sub(p, apex);
let radial = unit(sub(w, scale(axis, dot(w, axis))))?;
unit(sub(scale(radial, half_angle.cos()), scale(axis, half_angle.sin())))
unit(sub(scale(radial, libm::cos(half_angle)), scale(axis, libm::sin(half_angle))))
}
Surface::Sphere { center, .. } => unit(sub(p, center)),
Surface::Torus { center, axis, major, .. } => {
@@ -383,7 +383,7 @@ impl<'a> Inside<'a> {
let (a, b, c) = (sub(*a, x), sub(*b, x), sub(*c, x));
let (la, lb, lc) = (norm(a), norm(b), norm(c));
let den = la * lb * lc + dot(a, b) * lc + dot(a, c) * lb + dot(b, c) * la;
sum += 2.0 * dot(a, cross(b, c)).atan2(den);
sum += 2.0 * crate::vec3::atan2(dot(a, cross(b, c)), den);
}
Ok(sum / (4.0 * std::f64::consts::PI))
}
+3 -3
View File
@@ -98,8 +98,8 @@ impl Frame {
let w = sub(p, o);
let z = dot(w, a);
let (x, y) = (dot(w, e1), dot(w, e2));
let t = y.atan2(x);
([z, x.hypot(y)], if t < 0.0 { t + TAU } else { t })
let t = crate::vec3::atan2(y, x);
([z, libm::hypot(x, y)], if t < 0.0 { t + TAU } else { t })
}
}
}
@@ -107,7 +107,7 @@ impl Frame {
pub(crate) fn point(&self, q: Q, h: f64) -> P {
match *self {
Frame::Translate { o, u, v, d } => add(o, add(add(scale(u, q[0]), scale(v, q[1])), scale(d, h))),
Frame::Rotate { o, a, e1, e2 } => add(o, add(scale(a, q[0]), scale(add(scale(e1, h.cos()), scale(e2, h.sin())), q[1]))),
Frame::Rotate { o, a, e1, e2 } => add(o, add(scale(a, q[0]), scale(add(scale(e1, libm::cos(h)), scale(e2, libm::sin(h))), q[1]))),
}
}
+1 -1
View File
@@ -479,7 +479,7 @@ impl CellGrid {
}
(lo, hi)
});
let per_axis = ((ctxs.len() as f64).cbrt().ceil() as usize * 2).clamp(1, MAX_GRID_DIM);
let per_axis = (crate::vec3::cbrt(ctxs.len() as f64).ceil() as usize * 2).clamp(1, MAX_GRID_DIM);
let extent = (0..3).map(|k| hi[k] - lo[k]).fold(0.0, f64::max);
let step = if extent > 0.0 && extent.is_finite() { extent / per_axis as f64 } else { 1.0 };
let dim = |k: usize| (((hi[k] - lo[k]) / step).ceil() as usize).clamp(1, MAX_GRID_DIM);
+6 -3
View File
@@ -68,7 +68,7 @@ fn angles(lp: &[Vec<Q>], ccw: bool) -> Option<Vec<f64>> {
let next = lp.get(k)?;
let (a, o, b) = (*prev.get(prev.len().checked_sub(2)?)?, *next.first()?, *next.get(1)?);
let (u, v) = ([o[0] - a[0], o[1] - a[1]], [b[0] - o[0], b[1] - o[1]]);
let turn = (u[0] * v[1] - u[1] * v[0]).atan2(u[0] * v[0] + u[1] * v[1]);
let turn = crate::vec3::atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]);
out.push(PI - if ccw { turn } else { -turn });
}
Some(out)
@@ -443,8 +443,11 @@ fn disk(loops: &[Vec<Q>], size: &dyn SizeField, opts: &QuadOptions) -> Result<Op
// ∫y², ∫x² (both × 12) and ∫xy (× 24), signed as the loop: the region's long direction is
// the principal axis of the largest ∫ along², the angle of 0.5 · atan2(2 ∫xy, ∫x² − ∫y²).
let sign = a2.signum();
let theta = 0.5 * (sign * ixy).atan2(sign * (iyy - ixx));
let (u, v) = ([theta.cos(), theta.sin()], [-theta.sin(), theta.cos()]);
let theta = 0.5 * crate::vec3::atan2(sign * ixy, sign * (iyy - ixx));
let (u, v) = {
let (s, c) = (libm::sin(theta), libm::cos(theta));
([c, s], [-s, c])
};
let local = |p: Q| {
let d = [p[0] - centre[0], p[1] - centre[1]];
[d[0] * u[0] + d[1] * u[1], d[0] * v[0] + d[1] * v[1]]
+29 -23
View File
@@ -249,25 +249,6 @@ impl<'a> Geometry<'a> {
for e in surface.feature_edges().iter().filter(kept) {
edge_set.insert((e.vertices[0], e.vertices[1]), e.triangles);
}
let mut adj: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
for &(a, b) in edge_set.keys() {
adj.entry(a).or_default().push(b);
adj.entry(b).or_default().push(a);
}
let mut corners: Vec<u32> = adj
.iter()
.filter(|(v, n)| {
if n.len() != 2 {
return true;
}
let (p, a, b) = (xyz(**v), xyz(n[0]), xyz(n[1]));
let (u, w) = (sub(p, a), sub(b, p));
crate::vec3::angle_deg(u, w) > CORNER_TURN
})
.map(|(v, _)| *v)
.collect();
corners.sort_unstable();
// Patches: flood fill across non-feature edges.
let neighbors = surface.neighbors();
let mut tri_patch = vec![u32::MAX; tris.len()];
@@ -305,6 +286,16 @@ impl<'a> Geometry<'a> {
}
patch.faces.sort_unstable();
patch.faces.dedup();
patches.push(patch);
}
// A crease with the same patch on both sides (one inside a face that does not run from
// feature to feature, such as a tessellation crease across a fillet) separates nothing:
// no region border can follow it and its open ends could never be mapped, so it is not a
// feature.
edge_set.retain(|_, t| t.map(|t| tri_patch.get(t as usize).copied()).iter().collect::<std::collections::BTreeSet<_>>().len() == 2);
// The widths count only the curves that bound the patches.
for patch in &mut patches {
let mut perimeter = 0.0;
for &t in &patch.triangles {
let tri = tris.get(t as usize).copied().unwrap_or([0; 3]);
@@ -315,11 +306,26 @@ impl<'a> Geometry<'a> {
}
}
}
if perimeter > 0.0 {
patch.width = 2.0 * patch.area / perimeter;
}
patches.push(patch);
patch.width = if perimeter > 0.0 { 2.0 * patch.area / perimeter } else { f64::INFINITY };
}
let mut adj: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
for &(a, b) in edge_set.keys() {
adj.entry(a).or_default().push(b);
adj.entry(b).or_default().push(a);
}
let mut corners: Vec<u32> = adj
.iter()
.filter(|(v, n)| {
if n.len() != 2 {
return true;
}
let (p, a, b) = (xyz(**v), xyz(n[0]), xyz(n[1]));
let (u, w) = (sub(p, a), sub(b, p));
crate::vec3::angle_deg(u, w) > CORNER_TURN
})
.map(|(v, _)| *v)
.collect();
corners.sort_unstable();
// Curves: chains from corners, then closed loops.
let mut used: BTreeMap<(u32, u32), bool> = edge_set.keys().map(|k| (*k, false)).collect();
+29
View File
@@ -88,6 +88,35 @@ impl Tree {
self.leaves
}
/// Allow at most `max_leaves` leaves from now on (an error when there are more already).
pub fn set_max_leaves(&mut self, max_leaves: usize) -> Result<()> {
if self.leaves > max_leaves {
return Err(Error::Limit(format!("the grid has {} cells; the limit is {max_leaves}", self.leaves)));
}
self.max_leaves = max_leaves;
Ok(())
}
/// Make `depth` the deepest level allowed (an error when a leaf is deeper already).
pub fn set_depth(&mut self, depth: u8) -> Result<()> {
if depth > MAX_LEVEL || self.nodes.keys().any(|k| k.0 > depth) {
return Err(Error::Invalid(format!("grid depth {depth} is shallower than its leaves")));
}
self.depth = depth;
Ok(())
}
/// Leaves per level, root level first.
pub fn level_counts(&self) -> Vec<usize> {
let mut out = vec![0; usize::from(self.depth) + 1];
for (k, _) in self.nodes.iter().filter(|(_, leaf)| **leaf) {
if let Some(n) = out.get_mut(usize::from(k.0)) {
*n += 1;
}
}
out
}
/// Whether `key` is a leaf.
pub fn is_leaf(&self, key: &Key) -> bool {
self.nodes.get(key).copied().unwrap_or(false)
+328 -79
View File
@@ -8,12 +8,14 @@
//! patches when they are orthogonal and cover more area than the coordinate axes do, the
//! coordinate axes otherwise (octree methods are orientation dependent; N. Pietroni et al.,
//! *Hex-mesh generation and processing: a survey*, ACM TOG 42(2), 2022, §4.6).
//! 2. **Grid** ([`grid`]). Root cubes of the target size over the part's box, refined as a
//! 27-tree where a B-rep face curves (its normals turn by more than
//! [`OctreeOptions::curvature_angle`] inside a cell), where the part is thin (fewer than
//! [`OctreeOptions::thickness_cells`] cells across, measured along the inward normal of each
//! boundary triangle) and where a patch is narrow, then strongly balanced and closed under the
//! transition templates.
//! 2. **Grid** ([`grid`]). Root cubes over the part's box, refined as a 27-tree where a B-rep
//! face curves (its normals turn by more than [`OctreeOptions::curvature_angle`] inside a
//! cell), where the part is thin (fewer than [`OctreeOptions::thickness_cells`] cells across a
//! wall, measured along the inward normal of each boundary triangle to a face that faces it)
//! and where a patch is narrow, then strongly balanced and closed under the transition
//! templates. The levels the thinnest wall and narrowest patch need are found before the grid
//! is built, and the root size is chosen with them (see [`octree_hex_mesh`]'s plan): a part
//! that needs more levels or cells than allowed fails at once, with the numbers.
//! 3. **Conforming hexahedra** ([`templates`]): the 3-refinement node-marking templates
//! (R. Schneiders, 1996; Y. Ito, A. M. Shih, B. K. Soni, IJNME 77(13), 2009), so every grid
//! leaf becomes 1, 4, 11, 22 or 27 hexahedra and the grid is conforming by construction.
@@ -57,9 +59,16 @@
//! of a finer region is split, and the split spreads to the region's orthogonal hull (H. Tong,
//! Y. J. Zhang, *MCHex*, arXiv 2511.02064, 2025, meet the same limit with the same templates).
//! Curvature therefore refines only [`OctreeOptions::curvature_levels`] levels; deeper levels
//! follow thickness and narrow faces, whose refined regions are mostly convex. Schneiders'
//! modified edge rule (every edge with a marked end split in three) or the dual schemes are the
//! upgrade path.
//! follow thickness and narrow faces, whose refined regions are mostly convex. On curved parts
//! this closure is most of the grid: the corpus's rounded box goes from 8 266 refined cells to
//! 85 460 after closure, the loft from 6 650 to 80 438, the vase from 4 206 to 53 346 (thin walls
//! cost little: the I-beam's 469 304 cells are all refinement). Schneiders' modified edge rule
//! (an edge with a marked end split in three; R. Schneiders, VKI lecture series 2000-4, §5.3)
//! makes every concave face a full 3 × 3 face and the seven-marked cell the 27-split, but the
//! cell with six marks (a concave edge) then needs a template whose two faces at the unmarked
//! edge both fold their three columns into that edge, and no valid construction of it with
//! usable element quality is published; the dual schemes need a 2-refinement grid. Both remain
//! the upgrade path.
//!
//! # Determinism and bounds
//! The same boundary and options give the same mesh: grid cells are processed in sorted order,
@@ -96,7 +105,9 @@ use smooth::{Constraint, Optimizer};
/// Options and caps of [`octree_hex_mesh`].
#[derive(Debug, Clone, PartialEq)]
pub struct OctreeOptions {
/// Refinement levels below the root cells (each one a third of the size), at most 8.
/// The most refinement levels below the root cells (each one a third of the size), at most
/// 8. The levels the part's walls and narrow faces need are found before the grid is built
/// (see [`octree_hex_mesh`]); a part that needs more fails at once.
pub max_depth: u8,
/// The most grid cells and the most output elements.
pub max_elements: usize,
@@ -124,7 +135,7 @@ pub struct OctreeOptions {
impl Default for OctreeOptions {
fn default() -> Self {
OctreeOptions {
max_depth: 2,
max_depth: 4,
max_elements: 5_000_000,
curvature_angle: 25.0,
curvature_levels: 1,
@@ -145,6 +156,17 @@ pub struct OctreeReport {
pub attempts: usize,
/// Grid leaves of the last build.
pub grid_cells: usize,
/// The root cells' edge and the deepest level of the grid (see [`octree_hex_mesh`]).
pub root_size: f64,
pub levels: u8,
/// The thinnest wall and the narrowest patch the grid was chosen for (`∞` when none).
pub thinnest_wall: f64,
pub narrowest_patch: f64,
/// Grid leaves per level (root level first) of the last build: after the refinement
/// criteria (size, curvature, thickness, narrow patches and retry hints), and after balance
/// and closure.
pub refined_cells: Vec<usize>,
pub closed_cells: Vec<usize>,
/// Core elements (from the grid) and boundary-layer elements.
pub core_elements: usize,
pub layer_elements: usize,
@@ -192,6 +214,11 @@ const FIT_STAGES: &[f64] = &[1e-3, 1e-2, 1e-1, 1.0, 10.0, 100.0];
const MIN_SNAP_QUALITY: f64 = 0.05;
/// Elements below this corner scaled Jacobian are improved by compass search.
const IMPROVE_BELOW: f64 = 0.3;
/// The two faces of a wall open by at most this angle (degrees) from facing each other. Where a
/// convex edge is sharper (a wedge), the part is thinner at a distance `d` from the edge than
/// `2 d tan(30°) ≈ 1.15 d`, so the cells that resolve the edge do not resolve its thickness; at
/// blunter edges they do, and the edge is a feature, not a wall.
const WALL_OPENING: f64 = 60.0;
/// Closure rounds of the grid (each splits at least one leaf; the cell cap bounds them too).
const CLOSURE_ROUNDS: usize = 1000;
/// Rounds of the core clean-up (each drops at least one element).
@@ -209,45 +236,14 @@ enum Attempt {
}
/// Mesh the solid bounded by `surface` with hexahedra of about `field`'s size (the root cell
/// size is the field at the centre of the part's box; finer cells follow the part's curvature,
/// size is the field's largest size ([`SizeField::range`]), else the field at the centre of the
/// part's box; cells are refined where the field is smaller than they are; finer cells follow the part's curvature,
/// thickness and narrow faces). See the module docs.
pub fn octree_hex_mesh(surface: &BoundarySurface, field: &dyn SizeField, options: &OctreeOptions) -> Result<OctreeHexMesh> {
check_options(options)?;
let g = Geometry::new(surface, options.sharp_angle, options.blade_angle)?;
let frame = choose_frame(&g);
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for &p in surface.points() {
let u = to_frame(&frame, p);
for k in 0..3 {
lo[k] = lo[k].min(u[k]);
hi[k] = hi[k].max(u[k]);
}
}
let centre_u = [0, 1, 2].map(|k| 0.5 * (lo[k] + hi[k]));
let h = checked_size(field, from_frame(&frame, centre_u))?;
let mut roots = [0i64; 3];
for k in 0..3 {
let n = ((hi[k] - lo[k]) / h + 1.0).ceil();
if !(n.is_finite() && n >= 1.0 && n <= options.max_elements as f64) {
return Err(Error::Limit(format!("a size of {h} needs {n} root cells along an axis")));
}
roots[k] = n as i64;
}
let origin = [0, 1, 2].map(|k| centre_u[k] - 0.5 * roots[k] as f64 * h);
let ctx = Context {
g: &g,
field,
options,
frame,
origin,
h,
roots,
thickness: triangle_thickness(&g),
outside_memo: RefCell::new(HashMap::new()),
last_leaves: Cell::new(0),
too_thin: Cell::new(f64::INFINITY),
};
let thickness = triangle_thickness(&g);
let ctx = plan(&g, field, options, &thickness)?;
let mut hints: Vec<P> = Vec::new();
let mut retries: Vec<String> = Vec::new();
for attempt in 0..options.attempts {
@@ -279,6 +275,162 @@ pub fn octree_hex_mesh(surface: &BoundarySurface, field: &dyn SizeField, options
Err(Error::Failed(format!("octree hex meshing failed after {} builds: {}", retries.len(), retries.join("; "))))
}
/// What the part's walls and narrow patches ask of the grid, measured before it is built.
#[derive(Debug, Clone, Copy, PartialEq)]
struct Needs {
/// The thinnest wall (`∞` when no two faces face each other).
wall: f64,
/// The narrowest patch (`∞` when no patch is bounded by curves).
patch: f64,
/// The largest cell edge that puts [`OctreeOptions::thickness_cells`] cells across every wall
/// and two across every patch.
edge: f64,
}
impl Needs {
fn of(g: &Geometry, thickness: &[f64], options: &OctreeOptions) -> Needs {
let wall = thickness.iter().copied().filter(|t| t.is_finite() && *t > 0.0).fold(f64::INFINITY, f64::min);
let patch = g.patches.iter().map(|p| p.width).filter(|w| w.is_finite() && *w > 0.0).fold(f64::INFINITY, f64::min);
let by_wall = if options.thickness_cells > 0.0 { wall / options.thickness_cells } else { f64::INFINITY };
Needs { wall, patch, edge: by_wall.min(0.5 * patch) }
}
/// Why the cells must be as small as [`Needs::edge`].
fn reason(&self, options: &OctreeOptions) -> String {
if options.thickness_cells > 0.0 && self.wall / options.thickness_cells <= 0.5 * self.patch {
format!("the part has walls {:.4} thick, which {} cells across need cells of {:.4}", self.wall, options.thickness_cells, self.edge)
} else {
format!("the part has a face {:.4} wide, which two cells across need cells of {:.4}", self.patch, self.edge)
}
}
}
/// The levels below a root cell of edge `h` down to cells no larger than `edge` (`None` past
/// [`grid::MAX_LEVEL`]).
fn levels_below(h: f64, edge: f64) -> Option<u8> {
(0..=grid::MAX_LEVEL).find(|&d| h / 3f64.powi(i32::from(d)) <= edge * (1.0 + 1e-9))
}
/// Choose the grid before building it. An octree's cell sizes are the root size divided by
/// powers of 3, so the cells across the thinnest wall come out up to 3 times smaller than the
/// wall needs, and the cells along every wall are counted in cubes of that. The root cells at
/// the requested size, with the walls refined as deep as they need, come first; when that does
/// not fit the levels or cells allowed, the root cells are shrunk (by less than 3 times) so that
/// one level fewer reaches exactly the size the walls need, which puts far fewer cells along
/// them. When neither fits, meshing fails here, before anything is built, naming the wall, the
/// levels and the cells each would take. Rebuilds after a failure refine within the levels the
/// plan reached, and at least one level below [`OctreeOptions::curvature_levels`].
fn plan<'a>(g: &'a Geometry<'a>, field: &'a dyn SizeField, options: &'a OctreeOptions, thickness: &'a [f64]) -> Result<Context<'a>> {
let frame = choose_frame(g);
let (lo, hi) = frame_box(g, &frame);
let centre = from_frame(&frame, [0, 1, 2].map(|k| 0.5 * (lo[k] + hi[k])));
// The root cells at the largest size; the depth reaches the smallest (size controls).
let h = match field.range() {
Some((_, coarsest)) if coarsest > 0.0 && coarsest.is_finite() => coarsest,
_ => checked_size(field, centre)?,
};
if let Some((finest, _)) = field.range()
&& finest > 0.0
&& finest < h
{
match levels_below(h, finest) {
Some(d) if d <= options.max_depth => {}
d => {
let n = d.map_or_else(|| format!("more than {}", grid::MAX_LEVEL), |l| l.to_string());
return Err(Error::Limit(format!(
"a size of {finest:.4} below the root size {h:.4} needs {n} levels (each a third); `max_depth` is {}: raise it, or use a larger local size",
options.max_depth
)));
}
}
}
let needs = Needs::of(g, thickness, options);
let deep = levels_below(h, needs.edge);
let mut anchors: Vec<(f64, Option<u8>)> = vec![(h, deep)];
if let Some(d) = deep.filter(|&d| d >= 1) {
anchors.push((needs.edge * 3f64.powi(i32::from(d) - 1), Some(d - 1)));
}
let mut best: Option<(usize, Context<'a>)> = None;
let mut refused: Vec<String> = Vec::new();
for (root, levels) in anchors {
let Some(levels) = levels.filter(|&l| l <= options.max_depth) else {
let n = levels.map_or_else(|| format!("more than {}", grid::MAX_LEVEL), |l| l.to_string());
refused.push(format!("{n} levels below a size of {root:.4}"));
continue;
};
let ctx = Context::new(g, field, options, thickness, frame, root, (lo, hi), needs)?;
match ctx.refine(options.max_elements)? {
Refined::Within(tree) => {
let mut closed = tree.clone();
match closed.close(CLOSURE_ROUNDS, &|k| ctx.outside(k)) {
Ok(()) => {
let n = closed.leaf_count();
// Rebuilds refine within the levels the plan reached, and one level
// below the curvature levels.
let deepest = closed.level_counts().iter().rposition(|&c| c > 0).and_then(|l| u8::try_from(l).ok()).unwrap_or(0);
let depth = deepest.max(options.curvature_levels.saturating_add(1)).min(options.max_depth);
let mut tree = tree;
tree.set_depth(depth)?;
closed.set_depth(depth)?;
*ctx.refined_cells.borrow_mut() = tree.level_counts();
*ctx.refined.borrow_mut() = Some(tree);
*ctx.closed.borrow_mut() = Some(closed);
best = Some((n, ctx));
break;
}
Err(Error::Limit(_)) => {
refused.push(format!(
"{levels} levels below a size of {root:.4}, {} grid cells refined and more than {} after balancing and closure",
tree.leaf_count(),
options.max_elements
));
}
Err(e) => return Err(e),
}
}
Refined::Over { level, at_least } => {
refused.push(format!("{levels} levels below a size of {root:.4}, more than {at_least} grid cells by level {level}"));
}
}
}
match best {
Some((_, ctx)) => Ok(ctx),
None if needs.edge.is_finite() => Err(Error::Limit(format!(
"{}: {}; the limits are {} levels (`max_depth`) and {} cells (`max_elements`); use a smaller size, more levels or cells, or a structured (swept) hex mesh",
needs.reason(options),
refused.join("; or "),
options.max_depth,
options.max_elements
))),
None => Err(Error::Limit(format!(
"the grid at a size of {h:.4} needs {}; the limits are {} levels (`max_depth`) and {} cells (`max_elements`)",
refused.join("; or "),
options.max_depth,
options.max_elements
))),
}
}
/// The part's box in the frame's coordinates.
fn frame_box(g: &Geometry, frame: &[[f64; 3]; 3]) -> (P, P) {
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for &p in g.surface.points() {
let u = to_frame(frame, p);
for k in 0..3 {
lo[k] = lo[k].min(u[k]);
hi[k] = hi[k].max(u[k]);
}
}
(lo, hi)
}
/// A grid refined by the criteria, or how far the refinement got before the cell limit.
enum Refined {
Within(Tree),
Over { level: u8, at_least: usize },
}
fn check_options(o: &OctreeOptions) -> Result<()> {
let ok = o.max_depth <= 8
&& o.curvature_levels <= o.max_depth
@@ -310,7 +462,7 @@ fn to_frame(f: &[[f64; 3]; 3], p: P) -> P {
/// The grid axes (rows): the coordinate axes, or the normals of the two largest orthogonal planar
/// patches when more boundary area is aligned with them.
fn choose_frame(g: &Geometry) -> [[f64; 3]; 3] {
let tol = 1f64.to_radians().sin();
let tol = libm::sin(1f64.to_radians());
let normal = |t: u32| g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
let mut planar: Vec<(f64, usize, P)> = Vec::new();
for (i, p) in g.patches.iter().enumerate() {
@@ -343,13 +495,20 @@ fn choose_frame(g: &Geometry) -> [[f64; 3]; 3] {
}
/// The distance through the part from each boundary triangle: a ray from its centre along the
/// inward normal to the next triangle (`∞` when none).
/// inward normal to the far side of the wall, a triangle whose normal opens from the opposite of
/// this one by at most [`WALL_OPENING`] (`∞` when the ray first leaves through another face). A
/// ray that starts near a convex edge leaves through the face beyond the edge after a distance
/// that only measures how near the edge it started; the faces of a wall face each other.
fn triangle_thickness(g: &Geometry) -> Vec<f64> {
let far = 2.0 * norm(sub(g.bounds.hi, g.bounds.lo)) + 1.0;
let opposed = -libm::cos(WALL_OPENING.to_radians());
(0..g.surface.triangles().len() as u32)
.map(|t| {
let n = g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
g.ray(g.triangle_centre(t), n.map(|x| -x), far, t).map_or(f64::INFINITY, |(_, s)| s)
match g.ray(g.triangle_centre(t), n.map(|x| -x), far, t) {
Some((hit, s)) if g.normals.get(hit as usize).is_some_and(|&m| dot(m, n) <= opposed) => s,
_ => f64::INFINITY,
}
})
.collect()
}
@@ -374,12 +533,17 @@ struct Context<'a> {
origin: P,
h: f64,
roots: [i64; 3],
thickness: Vec<f64>,
thickness: &'a [f64],
needs: Needs,
outside_memo: RefCell<HashMap<grid::Key, bool>>,
/// The grid refined by the criteria (before hints and closure), and closed, kept from the
/// plan: the first build takes the closed grid, rebuilds refine the other near their hints.
refined: RefCell<Option<Tree>>,
closed: RefCell<Option<Tree>>,
/// Grid leaves of the last build.
last_leaves: Cell<usize>,
/// The thinnest wall the deepest cells cannot cross in `thickness_cells` (∞ when none).
too_thin: Cell<f64>,
/// Leaves per level of the last build after the refinement criteria.
refined_cells: RefCell<Vec<usize>>,
}
/// The mesh while its boundary layers are built: points, elements, their target sizes and how
@@ -392,6 +556,51 @@ struct Work {
core_count: usize,
}
impl<'a> Context<'a> {
/// Root cells of edge `h` over the part's box `(lo, hi)` (in the frame's coordinates).
#[allow(clippy::too_many_arguments)]
fn new(
g: &'a Geometry<'a>,
field: &'a dyn SizeField,
options: &'a OctreeOptions,
thickness: &'a [f64],
frame: [[f64; 3]; 3],
h: f64,
(lo, hi): (P, P),
needs: Needs,
) -> Result<Context<'a>> {
if !(h.is_finite() && h > 0.0) {
return Err(Error::Invalid(format!("a root cell size of {h}")));
}
let centre_u = [0, 1, 2].map(|k| 0.5 * (lo[k] + hi[k]));
let mut roots = [0i64; 3];
for k in 0..3 {
let n = ((hi[k] - lo[k]) / h + 1.0).ceil();
if !(n.is_finite() && n >= 1.0 && n <= options.max_elements as f64) {
return Err(Error::Limit(format!("a size of {h} needs {n} root cells along an axis")));
}
roots[k] = n as i64;
}
let origin = [0, 1, 2].map(|k| centre_u[k] - 0.5 * roots[k] as f64 * h);
Ok(Context {
g,
field,
options,
frame,
origin,
h,
roots,
thickness,
needs,
outside_memo: RefCell::new(HashMap::new()),
refined: RefCell::new(None),
closed: RefCell::new(None),
last_leaves: Cell::new(0),
refined_cells: RefCell::new(Vec::new()),
})
}
}
impl Context<'_> {
fn edge(&self, level: u8) -> f64 {
self.h / 3f64.powi(i32::from(level))
@@ -416,7 +625,7 @@ impl Context<'_> {
return Ok(true);
}
let reach = 0.5 * 3f64.sqrt() * e * 1.05;
let cos = self.options.curvature_angle.to_radians().cos();
let cos = libm::cos(self.options.curvature_angle.to_radians());
let mut first: BTreeMap<u32, P> = BTreeMap::new();
for t in self.g.overlapping(&bx) {
let [a, b, c] = self.g.triangle(t);
@@ -429,16 +638,12 @@ impl Context<'_> {
if key.0 < self.options.curvature_levels && dot(f, n) < cos {
return Ok(true);
}
if let Some(&th) = self.thickness.get(t as usize)
&& th < self.options.thickness_cells * e
{
if key.0 + 1 >= self.options.max_depth && th < self.options.thickness_cells * e / 3.0 {
self.too_thin.set(self.too_thin.get().min(th));
}
// (With a relative margin, so cells planned at exactly the size a wall needs stay.)
if self.thickness.get(t as usize).is_some_and(|&th| th * (1.0 + 1e-9) < self.options.thickness_cells * e) {
return Ok(true);
}
// Narrow patches (a hole's wall, a thin rim) need cells across them.
if self.g.tri_patch.get(t as usize).and_then(|&p| self.g.patches.get(p as usize)).is_some_and(|pt| pt.width < 2.0 * e) {
if self.g.tri_patch.get(t as usize).and_then(|&p| self.g.patches.get(p as usize)).is_some_and(|pt| pt.width * (1.0 + 1e-9) < 2.0 * e) {
return Ok(true);
}
}
@@ -477,25 +682,69 @@ impl Context<'_> {
out
}
/// The refined, balanced and closed grid; leaves near `hints` (where an earlier build
/// failed) are split once more.
fn tree(&self, hints: &[P], max_leaves: usize) -> Result<Tree> {
let mut tree = Tree::new(self.roots, self.options.max_depth, max_leaves)?;
/// The grid refined level by level where [`Context::needs_split`] asks, within `max_leaves`
/// cells; past them, how far it got and a lower bound on the cells it needs (the cells so far
/// and the splits the level still asks for).
fn refine(&self, max_leaves: usize) -> Result<Refined> {
let roots = self.roots.iter().try_fold(1usize, |a, &n| usize::try_from(n).ok().and_then(|n| a.checked_mul(n))).unwrap_or(usize::MAX);
let mut tree = match Tree::new(self.roots, self.options.max_depth, max_leaves) {
Err(Error::Limit(_)) => return Ok(Refined::Over { level: 0, at_least: roots }),
other => other?,
};
for level in 0..self.options.max_depth {
for key in tree.sorted_leaves() {
if key.0 == level && self.needs_split(key)? {
tree.split(key)?;
fieldcraft_model::progress::check()?;
let keys: Vec<grid::Key> = tree.sorted_leaves().into_iter().filter(|k| k.0 == level).collect();
for (i, &key) in keys.iter().enumerate() {
if !self.needs_split(key)? {
continue;
}
match tree.split(key) {
Ok(()) => {}
Err(Error::Limit(_)) if tree.leaf_count() + 26 > max_leaves => {
let mut more = 1usize;
for &k in keys.get(i + 1..).unwrap_or(&[]) {
if self.needs_split(k)? {
more += 1;
}
}
return Ok(Refined::Over { level: level + 1, at_least: tree.leaf_count().saturating_add(26usize.saturating_mul(more)) });
}
Err(e) => return Err(e),
}
}
}
Ok(Refined::Within(tree))
}
/// The refined, balanced and closed grid; leaves near `hints` (where an earlier build
/// failed) are split once more.
fn tree(&self, hints: &[P], max_leaves: usize) -> Result<Tree> {
if hints.is_empty()
&& let Some(t) = self.closed.borrow_mut().take()
&& t.leaf_count() <= max_leaves
{
return Ok(t);
}
let kept = self.refined.borrow().clone();
let mut tree = match kept {
Some(t) => t,
None => match self.refine(max_leaves)? {
Refined::Within(t) => t,
Refined::Over { level, at_least } => {
return Err(Error::Limit(format!("the grid needs more than {at_least} cells by level {level}; the limit is {max_leaves}")));
}
},
};
tree.set_max_leaves(max_leaves)?;
if !hints.is_empty() {
let near: Vec<HashSet<grid::Key>> = (0..self.options.max_depth).map(|l| self.hint_cells(hints, l)).collect();
let near: Vec<HashSet<grid::Key>> = (0..tree.depth).map(|l| self.hint_cells(hints, l)).collect();
let split: Vec<grid::Key> =
tree.sorted_leaves().into_iter().filter(|k| near.get(usize::from(k.0)).is_some_and(|s| s.contains(k))).collect();
for key in split {
tree.split(key)?;
}
}
*self.refined_cells.borrow_mut() = tree.level_counts();
tree.close(CLOSURE_ROUNDS, &|k| self.outside(k))?;
Ok(tree)
}
@@ -508,8 +757,11 @@ impl Context<'_> {
if lattice.hexes.len() > self.options.max_elements {
return Err(Error::Limit(format!("the grid has {} elements; the limit is {}", lattice.hexes.len(), self.options.max_elements)));
}
let unit = self.h / tree.span(0) as f64;
let points: Vec<P> = lattice.points.iter().map(|q| from_frame(&self.frame, [0, 1, 2].map(|k| self.origin[k] + q[k] as f64 * unit))).collect();
// A lattice coordinate over the root span is an exact ratio, rounded once, so a point's
// position does not depend on how deep the lattice is.
let span = tree.span(0) as f64;
let points: Vec<P> =
lattice.points.iter().map(|q| from_frame(&self.frame, [0, 1, 2].map(|k| self.origin[k] + q[k] as f64 / span * self.h))).collect();
// Core.
let dist: Vec<f64> = points.iter().map(|&p| g.nearest(p, |_| true).map_or(0.0, |x| x.2.sqrt())).collect();
@@ -529,15 +781,6 @@ impl Context<'_> {
let qb = topo::boundary(&w.hexes);
let q: Vec<[u32; 4]> = qb.iter().map(|x| x.1).collect();
if let Some(r) = check_topology(g, &q) {
let thin = self.too_thin.get();
if thin.is_finite() {
return Err(Error::Failed(format!(
"{}: the part has walls {thin:.3} thick, thinner than {} cells of the deepest level ({:.3}); use a smaller size, more levels, or a structured (swept) hex mesh",
r.why,
self.options.thickness_cells,
self.edge(self.options.max_depth)
)));
}
return Ok(Attempt::Retry(r));
}
@@ -747,6 +990,12 @@ impl Context<'_> {
let report = OctreeReport {
attempts: 0,
grid_cells: tree.leaf_count(),
root_size: self.h,
levels: tree.level_counts().iter().rposition(|&n| n > 0).and_then(|l| u8::try_from(l).ok()).unwrap_or(0),
thinnest_wall: self.needs.wall,
narrowest_patch: self.needs.patch,
refined_cells: self.refined_cells.borrow().clone(),
closed_cells: tree.level_counts(),
core_elements: w.core_count,
layer_elements: w.hexes.len() - w.core_count,
frame: self.frame,
+86
View File
@@ -96,3 +96,89 @@ fn a_sphere_meshes() {
let r = &m.report;
assert!(((r.volume - r.surface_volume) / r.surface_volume).abs() < 0.01, "{r:?}");
}
#[test]
fn a_slab_gets_the_levels_its_thickness_needs_up_front() {
// 1 mm thick: three cells across need cells of 1/3, two levels below a size of 3.
let s = box_surface([0.0; 3], [12.0, 9.0, 1.0]);
let m = octree_hex_mesh(&s, &UniformSize(3.0), &OctreeOptions::default()).unwrap();
let r = &m.report;
assert_eq!(r.thinnest_wall, 1.0, "{r:?}");
assert_eq!(r.root_size, 3.0, "{r:?}");
assert_eq!(r.levels, 2, "{r:?}");
assert_eq!(r.attempts, 1, "{r:?}");
assert!((r.volume - 108.0).abs() < 1e-9 * 108.0, "{r:?}");
}
#[test]
fn walls_beyond_the_limits_fail_before_anything_is_built() {
let s = box_surface([0.0; 3], [12.0, 9.0, 1.0]);
// Too few levels: the requested size needs 2, the shrunk root 1.
let shallow = OctreeOptions { max_depth: 0, curvature_levels: 0, ..OctreeOptions::default() };
let e = octree_hex_mesh(&s, &UniformSize(3.0), &shallow).unwrap_err().to_string();
assert!(e.contains("walls 1.0000 thick") && e.contains("levels"), "{e}");
// Too few cells: refused with the count, at once.
let small = OctreeOptions { max_elements: 50, ..OctreeOptions::default() };
let start = std::time::Instant::now();
let e = octree_hex_mesh(&s, &UniformSize(3.0), &small).unwrap_err().to_string();
assert!(e.contains("walls 1.0000 thick") && e.contains("more than"), "{e}");
assert!(start.elapsed().as_secs_f64() < 5.0);
}
#[test]
fn the_root_shrinks_when_the_requested_size_would_need_too_many_cells() {
// A size of 8 needs 3 levels for a 1 mm wall (cells of 0.30); a root of 3 needs 2 levels
// with cells of exactly 1/3. With 2 levels allowed only the shrunk root fits.
let s = box_surface([0.0; 3], [12.0, 9.0, 1.0]);
let o = OctreeOptions { max_depth: 2, ..OctreeOptions::default() };
let m = octree_hex_mesh(&s, &UniformSize(8.0), &o).unwrap();
assert!((m.report.root_size - 3.0).abs() < 1e-12, "{:?}", m.report);
assert_eq!(m.report.levels, 2);
}
#[test]
fn hostile_options_are_errors() {
let s = box_surface([0.0; 3], [12.0, 9.0, 1.0]);
for o in [
OctreeOptions { max_depth: 9, ..OctreeOptions::default() },
OctreeOptions { max_elements: 0, ..OctreeOptions::default() },
OctreeOptions { max_elements: 1, ..OctreeOptions::default() },
OctreeOptions { thickness_cells: f64::NAN, ..OctreeOptions::default() },
] {
assert!(octree_hex_mesh(&s, &UniformSize(3.0), &o).is_err(), "{o:?}");
}
// The deepest grid allowed on a size far below the part: refused by the cell limit.
let deep = OctreeOptions { max_depth: 5, max_elements: 10_000, ..OctreeOptions::default() };
assert!(octree_hex_mesh(&s, &UniformSize(0.05), &deep).is_err());
}
#[test]
fn meshing_is_deterministic() {
let s = cylinder_surface(5.0, 8.0, 64);
let a = octree_hex_mesh(&s, &UniformSize(2.0), &OctreeOptions::default()).unwrap();
let b = octree_hex_mesh(&s, &UniformSize(2.0), &OctreeOptions::default()).unwrap();
assert_eq!(a, b);
}
/// A size box refines the grid inside it to the level its size needs (closure may carry that
/// level further), and the root cells keep the global size; a size deeper than `max_depth` allows is refused before meshing.
#[test]
fn a_size_box_refines_the_grid_inside_it() {
use crate::size::{LocalSize, Region, RegionSize};
let s = box_surface([0.0; 3], [18.0, 18.0, 18.0]);
let local = LocalSize { region: Region::Box { lo: [0.0; 3], hi: [6.0; 3] }, size: 2.0 };
let field = RegionSize::new(6.0, 0.3, vec![local.clone()]).unwrap();
let m = octree_hex_mesh(&s, &field, &OctreeOptions::default()).unwrap();
let edge = |h: &[u32; 8]| {
let (a, b) = (m.nodes[h[0] as usize], m.nodes[h[1] as usize]);
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
};
let centre = |h: &[u32; 8]| [0, 1, 2].map(|k| h.iter().map(|&v| m.nodes[v as usize][k]).sum::<f64>() / 8.0);
let inside: Vec<f64> = m.hexes.iter().filter(|h| centre(h).iter().all(|&c| (1.0..5.0).contains(&c))).map(edge).collect();
assert!(!inside.is_empty() && inside.iter().all(|&e| e < 2.0 * 1.2), "{inside:?}");
assert_eq!((m.report.root_size, m.report.levels), (6.0, 1), "{:?}", m.report);
let shallow = OctreeOptions { max_depth: 1, curvature_levels: 1, ..OctreeOptions::default() };
let deep = RegionSize::new(6.0, 0.3, vec![LocalSize { size: 0.5, ..local }]).unwrap();
let e = octree_hex_mesh(&s, &deep, &shallow).unwrap_err().to_string();
assert!(e.contains("max_depth"), "{e}");
}
+1 -1
View File
@@ -266,7 +266,7 @@ pub fn face_boundary(positions: &[[f64; 3]], triangles: &[[u32; 3]], tri_face: &
.map(|i| {
let (p, q, r) = (at(i + n - 1), at(i), at(i + 1));
let (u, w) = ([q[0] - p[0], q[1] - p[1]], [r[0] - q[0], r[1] - q[1]]);
let turn = (u[0] * w[1] - u[1] * w[0]).atan2(u[0] * w[0] + u[1] * w[1]).abs();
let turn = crate::vec3::atan2(u[0] * w[1] - u[1] * w[0], u[0] * w[0] + u[1] * w[1]).abs();
ids[i] != ids[(i + n - 1) % n] || turn > CORNER_ANGLE
})
.collect();
+39 -12
View File
@@ -72,8 +72,9 @@ const MAX_STRETCH: f64 = 2.0;
#[derive(Debug, Clone, PartialEq)]
pub struct ProjectOptions {
/// The element size: each class of parallel block edges gets `round(length / size)`
/// divisions (at least one), `length` the geometric mean of its projected edges' lengths,
/// and enough that no element is longer than twice the size.
/// divisions (at least one), `length / size` the geometric mean of its projected edges'
/// lengths in sizes (measured by `field` when there is one), and enough that no element is
/// longer than twice the size.
pub size: f64,
/// The largest angle (radians) the surface may turn through across one element on a curved
/// target face: a class takes enough divisions that its elements are no longer than the
@@ -98,6 +99,10 @@ pub struct ProjectOptions {
/// corners lie on one (within a tenth of the size) keeps its nodes on it through smoothing, so
/// the mesh can be cut there into two meshes with planar faces.
pub planes: Vec<(P, P)>,
/// A graded size field (size controls) in place of the uniform `size` when the divisions are
/// chosen: an edge's length is then measured in units of the field ([`crate::size::ideal_divisions`]).
/// Its global size is `size`.
pub field: Option<crate::size::RegionSize>,
}
impl Default for ProjectOptions {
@@ -113,6 +118,7 @@ impl Default for ProjectOptions {
min_quality: DEFAULT_MIN_QUALITY,
fixed: Vec::new(),
planes: Vec::new(),
field: None,
}
}
}
@@ -508,10 +514,17 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
// lengths). A class runs through every block its edges are parallel in, so its edges can
// differ a lot in length (a cup's wall, 4 mm thick, and its floor's radial edges, twice
// that); the longest edge alone would cut the thin blocks into flat slivers. No element is
// longer than [`MAX_STRETCH`] sizes, so a coarse size still follows the geometry.
// longer than [`MAX_STRETCH`] sizes, so a coarse size still follows the geometry. An edge
// that bends with a curved target is the exception: its elements' sides are chords of the
// surface, and one longer than the size cuts off more of the part than the size allows (a
// butterfly's rim shares its class with the shorter sides of its middle square), so a class
// takes at least the geometric mean of its bent edges' lengths in sizes (rounded,
// as the mean; an annulus's rim and bore balance each other as its straight edges do).
let (classes, nclass) = edge_classes(t);
let mut class_log = vec![(0.0f64, 0usize); nclass];
// Per class, its longest edge in element sizes.
let mut class_max = vec![0.0f64; nclass];
let mut class_bent = vec![(0.0f64, 0usize); nclass];
let mut seen: BTreeSet<[u32; 2]> = BTreeSet::new();
for (block, cls) in t.blocks.iter().zip(&classes) {
for d in 0..3 {
@@ -528,17 +541,29 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
let (pa, pb) = (vpos.get(a as usize).copied().unwrap_or_default(), vpos.get(b as usize).copied().unwrap_or_default());
let c = edge_c(edge_key(a, b));
let mut len = 0.0;
let mut prev = pa;
let mut line = Vec::with_capacity(17);
line.push(pa);
for q in 1..=16 {
let x = if q == 16 { pb } else { place(g, &targets, c, lerp(pa, pb, q as f64 / 16.0), tol) };
len += dist(prev, x);
prev = x;
len += dist(*line.last().unwrap_or(&pa), x);
line.push(x);
}
// The edge's length in element sizes.
let measured = match &opts.field {
Some(f) if len > tol => crate::size::ideal_divisions(f, &line)?,
_ => len / opts.size,
};
if let Some(m) = class_max.get_mut(cls[d]) {
*m = m.max(len);
*m = m.max(measured);
}
if len - dist(pa, pb) > tol
&& let Some(l) = class_bent.get_mut(cls[d])
{
l.0 += libm::log(measured);
l.1 += 1;
}
if let Some(l) = class_log.get_mut(cls[d]).filter(|_| len > tol) {
l.0 += (len / opts.size).ln();
l.0 += libm::log(measured);
l.1 += 1;
}
}
@@ -585,7 +610,7 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
let Some(nx) = g.normal(fa, qx) else { continue };
let h = dist(pa, qx);
if h > 0.5 * probe {
kappa = kappa.max(crate::vec3::dot(na, nx).clamp(-1.0, 1.0).acos() / h);
kappa = kappa.max(libm::acos(crate::vec3::dot(na, nx).clamp(-1.0, 1.0)) / h);
}
}
}
@@ -602,9 +627,11 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
.iter()
.zip(&class_max)
.zip(&class_arc)
.map(|((&(sum, k), &longest), &arc)| {
let mean = if k == 0 { 1.0 } else { (sum / k as f64).exp().round() };
(mean.max((longest / (MAX_STRETCH * opts.size)).ceil()) as usize).max(arc).clamp(1, crate::block::MAX_DIVISIONS)
.zip(&class_bent)
.map(|(((&(sum, k), &longest), &arc), &(bent_sum, bent_k))| {
let mean = if k == 0 { 1.0 } else { libm::exp(sum / k as f64).round() };
let bent = if bent_k == 0 { 0 } else { libm::exp(bent_sum / bent_k as f64).round().min(crate::block::MAX_DIVISIONS as f64) as usize };
(mean.max((longest / MAX_STRETCH).ceil()) as usize).max(arc).max(bent).clamp(1, crate::block::MAX_DIVISIONS)
})
.collect();
for &(b, d, n) in &opts.divisions {
+1 -1
View File
@@ -199,6 +199,6 @@ pub(crate) fn pyramid_scaled_jacobian(p: [P; 5]) -> f64 {
pub(crate) fn jacobian_metrics(a: P, b: P, c: P) -> (f64, f64) {
let d = det(a, b, c);
let frob = dot(a, a) + dot(b, b) + dot(c, c);
let shape = if d > 0.0 { ratio(3.0 * d.cbrt().powi(2), frob, 0.0) } else { 0.0 };
let shape = if d > 0.0 { ratio(3.0 * crate::vec3::cbrt(d).powi(2), frob, 0.0) } else { 0.0 };
(corner_det(a, b, c), shape)
}
+1 -1
View File
@@ -939,7 +939,7 @@ pub fn apply_field(
}
mod strong;
pub use strong::{MAX_ROUNDS, MorphOptions, Morphed, Round, morph};
pub use strong::{DEFAULT_REACH_LENGTHS, Guards, MAX_ROUNDS, MorphOptions, Morphed, Round, morph};
#[cfg(test)]
mod tests;
+80 -25
View File
@@ -12,9 +12,10 @@
//! B (2 − q) for an invalid element (B large),
//! ```
//!
//! growing without bound as an element degenerates (a barrier: no step that lowers `F` inverts an
//! element), while the invalid branch falls as an inverted element untangles and lies far above
//! any valid configuration. The size term is there because the scaled Jacobian does not see an
//! φ continuing as its tangent line below q = 0.3, so it rises steeply but stays finite as an
//! element degenerates, while the invalid branch falls as an inverted element untangles and lies
//! above every valid element's term (a barrier: no step that lowers `F` inverts an element unless
//! it untangles worse ones; and passing through q → 0⁺ never costs more than staying inverted). The size term is there because the scaled Jacobian does not see an
//! element flattening along one direction: without it the optimiser squeezed elements against
//! the boundary to square up their neighbours (the Pi 5 base's largest edge ratio went from 5 to
//! 34). For the same reason an element longer than the region's largest edge ratio before costs
@@ -43,8 +44,14 @@ use fieldcraft_model::Model;
use super::{Constraints, Elem, Freedom, P, RegionQuality, State, add, dist, dot, measure, region_quality, scale, sub};
use crate::{Error, Result};
/// Weight of an invalid element in the objective.
const INVALID: f64 = 1e8;
/// Weight of an invalid element in the objective: above every valid element's term (the shape
/// term is at most [`shape`] at 0, a few 1e9, plus the size term), so no step that untangles an
/// element ever looks worse than staying inverted.
const INVALID: f64 = 1e11;
/// Below this quality the shape term continues as its tangent line, so it stays finite (and
/// below [`INVALID`]) as an element degenerates instead of growing without bound.
const KNEE: f64 = 0.3;
/// Weight of the size term.
const SIZE_WEIGHT: f64 = 0.5;
@@ -65,17 +72,31 @@ enum Phase {
/// The objective's term for one element of quality `q` whose size went from `v0` to `v`.
fn phi(phase: Phase, q: f64, valid: bool, v: f64, v0: f64) -> f64 {
if valid && q > 0.0 {
let g = if v > 0.0 && v0 > 0.0 { (v / v0).ln() } else { 0.0 };
let shape = match phase {
Phase::Smooth => (1.0 / q - 1.0).powi(2),
Phase::MinMax => q.powi(-MIN_MAX_POWER) - 1.0,
};
shape + SIZE_WEIGHT * g * g
let g = if v > 0.0 && v0 > 0.0 { libm::log(v / v0) } else { 0.0 };
shape(phase, q) + SIZE_WEIGHT * g * g
} else {
INVALID * (2.0 - q.min(1.0))
}
}
/// The shape term of a valid element of quality `q > 0`: `φ(q)` from [`KNEE`] up, its tangent
/// line below. Unbounded, `q⁻¹⁶` reached 1e96 as an element neared flat, so far above an
/// invalid element's term that the descent kept elements inverted rather than pass through
/// q → 0⁺ (#438: the distorted plate stayed tangled).
fn shape(phase: Phase, q: f64) -> f64 {
// φ and its derivative at `x`.
let at = |x: f64| match phase {
Phase::Smooth => ((1.0 / x - 1.0).powi(2), -2.0 * (1.0 / x - 1.0) / (x * x)),
Phase::MinMax => (x.powi(-MIN_MAX_POWER) - 1.0, -f64::from(MIN_MAX_POWER) * x.powi(-MIN_MAX_POWER - 1)),
};
if q >= KNEE {
at(q).0
} else {
let (v, d) = at(KNEE);
v + d * (q - KNEE)
}
}
/// A linear hexahedron's scaled Jacobian at its corners and centre (the samples
/// [`crate::quality::element_quality`] takes), straight from the trilinear map: the sweeps'
/// inner loop, where the certified check would dominate. A state is certified before it is kept.
@@ -233,21 +254,37 @@ impl State<'_> {
}
}
/// When [`morph`] keeps a cluster's morph.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Guards {
/// Kept whenever no element it touches inverts (more invalid elements than before).
#[default]
Valid,
/// Kept (at full or a halved scale) only when, besides, the worst element it touches is no
/// worse, the smooth objective there drops and no element gets longer than the longest was.
Strict,
}
/// Default reach of [`morph`], in mean edge lengths of the bad elements.
pub const DEFAULT_REACH_LENGTHS: f64 = 5.0;
/// What [`morph`] does.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MorphOptions {
/// Elements below this quality are the bad ones.
pub below: f64,
/// The radius round the bad nodes whose nodes follow them; `None`: three times the bad
/// elements' mean edge length.
/// The radius round the bad nodes whose nodes follow them; `None`:
/// [`DEFAULT_REACH_LENGTHS`] times the bad elements' mean edge length.
pub reach: Option<f64>,
/// Most rounds (bad nodes moved, the region morphed after them).
pub rounds: usize,
/// When a cluster's morph is kept.
pub guards: Guards,
}
impl Default for MorphOptions {
fn default() -> Self {
MorphOptions { below: 0.7, reach: None, rounds: 20 }
MorphOptions { below: 0.7, reach: None, rounds: 20, guards: Guards::Valid }
}
}
@@ -359,8 +396,9 @@ impl State<'_> {
/// Morph round one cluster of bad nodes: their own optimisation (the rest held) gives their
/// displacements, the harmonic extension carries them to the nodes within `reach`, applied at
/// full or a halved scale when that improves the elements it touches. The region's size and
/// whether it was kept.
fn morph_cluster(&mut self, bad: &BTreeSet<u32>, reach: f64, movable: &dyn Fn(&State<'_>, u32) -> bool) -> Result<(usize, bool)> {
/// whether it was kept. With [`Guards::Valid`] the full morph is kept unless an element
/// it touches inverts; with [`Guards::Strict`] only a scale that improves the elements is.
fn morph_cluster(&mut self, bad: &BTreeSet<u32>, reach: f64, guards: Guards, movable: &dyn Fn(&State<'_>, u32) -> bool) -> Result<(usize, bool)> {
let start: BTreeMap<u32, P> = bad.iter().map(|&n| (n, self.at(n))).collect();
let mut undo = Vec::new();
let mut steps: BTreeMap<u32, f64> = bad.iter().map(|&n| (n, 0.1)).collect();
@@ -397,7 +435,11 @@ impl State<'_> {
}
}
let now = self.certified(&touched);
if now.improves(&was) && now.ratio <= self.ratio_cap.max(was.ratio) {
let keep = match guards {
Guards::Valid => now.invalid <= was.invalid,
Guards::Strict => now.improves(&was) && now.ratio <= self.ratio_cap.max(was.ratio),
};
if keep {
return Ok((region.len(), true));
}
self.restore(undo);
@@ -473,10 +515,11 @@ impl State<'_> {
/// the reach by harmonic interpolation (the graph Laplacian solved with the bad nodes'
/// displacements and zero beyond the reach and at held nodes). Face nodes are put back on their
/// face, edge and vertex nodes do not move, nodes outside the reach stay exactly as they are. A
/// cluster's morph is kept, at full or a halved scale, only if no more elements are invalid, no element
/// gets longer than the longest one was, the worst element it touches is no worse and the smooth
/// objective there drops. Only nodes of `nodes`
/// move. The model is not changed: the result has the new positions.
/// cluster's morph is kept as long as no element it touches inverts ([`Guards::Valid`]), or, with
/// [`Guards::Strict`], at full or a halved scale only if besides no element gets longer than the
/// longest one was, the worst element it touches is no worse and the smooth objective there drops.
/// The result is the round with the highest smallest quality (never below the start). Only nodes
/// of `nodes` move. The model is not changed: the result has the new positions.
pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geometry: &dyn Constraints) -> Result<Morphed> {
if !(options.below > 0.0 && options.below <= 1.0) {
return Err(Error::Invalid(format!("below must be in (0, 1], got {}", options.below)));
@@ -497,6 +540,14 @@ pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geomet
let (_, _, mean_before) = state.qualities();
let movable = |s: &State<'_>, n: u32| nodes.contains(&n) && s.around.contains_key(&n) && s.freedom(n) != Freedom::Fixed;
let mut rounds = Vec::new();
// The best state of the run: the highest smallest quality, then the fewest elements below
// `below`, then the highest mean; the start until a round beats it.
let score = |s: &State<'_>| {
let (q, min, mean) = s.qualities();
(min, q.iter().filter(|v| **v < options.below).count(), mean)
};
let better = |a: (f64, usize, f64), b: (f64, usize, f64)| a.0 > b.0 || (a.0 == b.0 && (a.1 < b.1 || (a.1 == b.1 && a.2 > b.2)));
let mut best = (score(&state), original.clone());
for round in 0..options.rounds {
fieldcraft_model::progress::stage(&format!("morph round {}", round + 1), Some(options.rounds as f64), "rounds");
fieldcraft_model::progress::count(round as f64);
@@ -518,7 +569,7 @@ pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geomet
if bad.is_empty() {
break;
}
let reach = options.reach.unwrap_or(if edge_sum.1 > 0 { 3.0 * edge_sum.0 / edge_sum.1 as f64 } else { 0.0 });
let reach = options.reach.unwrap_or(if edge_sum.1 > 0 { DEFAULT_REACH_LENGTHS * edge_sum.0 / edge_sum.1 as f64 } else { 0.0 });
if !(reach > 0.0) {
break;
}
@@ -527,18 +578,22 @@ pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geomet
let mut round_out = Round { bad_elements: bad_elems.len(), bad_nodes: bad.len(), reach, clusters: clusters.len(), ..Round::default() };
for cluster in &clusters {
fieldcraft_model::progress::check()?;
let (region, kept) = state.morph_cluster(cluster, reach, &|s: &State<'_>, n| movable(s, n))?;
let (region, kept) = state.morph_cluster(cluster, reach, options.guards, &|s: &State<'_>, n| movable(s, n))?;
round_out.region_nodes += region;
round_out.kept += usize::from(kept);
}
let (_, min, mean) = state.qualities();
(round_out.min_quality, round_out.mean_quality) = (min, mean);
let now = score(&state);
(round_out.min_quality, round_out.mean_quality) = (now.0, now.2);
if better(now, best.0) {
best = (now, state.pos.clone());
}
let any = round_out.kept > 0;
rounds.push(round_out);
if !any {
break;
}
}
state.pos = best.1;
let mut after = region_quality(&state);
let (_, _, mut mean_after) = state.qualities();
// Never worse than the start.
+18 -2
View File
@@ -360,7 +360,7 @@ fn strong_relaxation_of_a_distorted_block_keeps_faces_and_edges() {
let (original, m) = distorted_annulus(6, 0.06);
let geo = Geo { model: original.clone() };
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
let options = MorphOptions { below: 0.9, reach: None, rounds: 40 };
let options = MorphOptions { below: 0.9, reach: None, rounds: 40, ..MorphOptions::default() };
let r = morph(&m, &nodes, options, &geo).unwrap();
assert!(r.after.min_quality > r.before.min_quality + 0.2, "{:?} → {:?}: {:?}", r.before.min_quality, r.after.min_quality, r.rounds);
assert!(r.mean_after > r.mean_before, "mean {} → {}", r.mean_before, r.mean_after);
@@ -389,7 +389,7 @@ fn strong_relaxation_moves_only_nodes_within_the_reach() {
let geo = Geo { model: original };
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
let reach = 0.4;
let r = morph(&m, &nodes, MorphOptions { below: 0.8, reach: Some(reach), rounds: 20 }, &geo).unwrap();
let r = morph(&m, &nodes, MorphOptions { below: 0.8, reach: Some(reach), rounds: 20, ..MorphOptions::default() }, &geo).unwrap();
assert!(r.after.min_quality > r.before.min_quality, "{:?}", r.rounds);
for n in r.positions.keys() {
assert!(dist(m.node(*n).unwrap().xyz, CENTRE) < 0.2 + 2.0 * reach, "node {n} far from the bad spot moved");
@@ -413,6 +413,22 @@ fn strong_relaxation_untangles_inverted_elements() {
assert!(r.after.min_quality > 0.5, "{:?}", r.after);
}
/// Both guards keep the result valid and never below the start; the valid guards (keep a morph
/// unless it inverts an element) lift the worst element at least as far as the strict ones.
#[test]
fn strong_relaxation_guards_valid_and_strict() {
let (original, m) = distorted_annulus(6, 0.06);
let geo = Geo { model: original };
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
let run = |guards| morph(&m, &nodes, MorphOptions { below: 0.9, rounds: 40, guards, ..MorphOptions::default() }, &geo).unwrap();
let (valid, strict) = (run(Guards::Valid), run(Guards::Strict));
for r in [&valid, &strict] {
assert_eq!(r.after.invalid, 0, "{:?}", r.after);
assert!(r.after.min_quality >= r.before.min_quality, "{:?} → {:?}", r.before.min_quality, r.after.min_quality);
}
assert!(valid.after.min_quality >= strict.after.min_quality - 1e-9, "valid {:?}, strict {:?}", valid.after, strict.after);
}
/// A good mesh is left as it is; bad options are errors.
#[test]
fn strong_relaxation_keeps_a_good_mesh_and_refuses_bad_options() {
+200
View File
@@ -29,6 +29,13 @@ use crate::{Error, Result};
/// treat anything else as invalid input.
pub trait SizeField {
fn size(&self, p: [f64; 3]) -> f64;
/// The smallest and largest size anywhere, when the field knows them (a mesher that builds
/// from a root size, such as the octree, starts from the largest and plans its depth from
/// the smallest).
fn range(&self) -> Option<(f64, f64)> {
None
}
}
impl<F: Fn([f64; 3]) -> f64> SizeField for F {
@@ -45,6 +52,10 @@ impl SizeField for UniformSize {
fn size(&self, _: [f64; 3]) -> f64 {
self.0
}
fn range(&self) -> Option<(f64, f64)> {
Some((self.0, self.0))
}
}
/// The largest division count of one curve.
@@ -206,6 +217,154 @@ pub fn divide_polyline(field: &dyn SizeField, polyline: &[[f64; 3]], n: usize) -
Ok(out)
}
/// The most triangles the local sizes of one field hold.
pub const MAX_REGION_TRIANGLES: usize = 1 << 20;
/// Where a [`LocalSize`] applies.
#[derive(Debug, Clone, PartialEq)]
pub enum Region {
/// An axis-aligned box.
Box { lo: [f64; 3], hi: [f64; 3] },
/// A ball.
Sphere { center: [f64; 3], radius: f64 },
/// A surface, as triangles (a B-rep face's tessellation).
Triangles(Vec<[[f64; 3]; 3]>),
}
impl Region {
/// The distance from `p` to the region (0 inside a box or a ball, and on a surface).
pub fn distance(&self, p: [f64; 3]) -> f64 {
match self {
Region::Box { lo, hi } => {
let d = [0, 1, 2].map(|k| (lo[k] - p[k]).max(p[k] - hi[k]).max(0.0));
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
}
Region::Sphere { center, radius } => (dist(p, *center) - radius).max(0.0),
Region::Triangles(tris) => {
tris.iter().map(|[a, b, c]| dist(p, crate::tet::geometry::closest_on_triangle(p, *a, *b, *c))).fold(f64::INFINITY, f64::min)
}
}
}
/// The region's bounding box.
pub fn bounds(&self) -> ([f64; 3], [f64; 3]) {
match self {
Region::Box { lo, hi } => (*lo, *hi),
Region::Sphere { center, radius } => (center.map(|c| c - radius), center.map(|c| c + radius)),
Region::Triangles(tris) => {
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for p in tris.iter().flatten() {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
(lo, hi)
}
}
}
}
/// A local size: elements of about `size` in `region`.
#[derive(Debug, Clone, PartialEq)]
pub struct LocalSize {
pub region: Region,
pub size: f64,
}
/// A global size refined by local sizes and graded: the size at `p` is
/// `min(global, min_i (h_i + g · d_i(p)))` over the local sizes `h_i` with `d_i` the distance to
/// their regions, so it changes by at most `g` per unit length. Exact (no background grid):
/// meant for the structured meshers, which query it along curves; the tetrahedral refinement
/// bakes the same local sizes into its graded grid ([`crate::tet::geometry::GradedSize`]).
#[derive(Debug, Clone, PartialEq)]
pub struct RegionSize {
global: f64,
growth: f64,
/// Each local size with its region's bounds grown by the reach of its refinement.
locals: Vec<(LocalSize, [f64; 3], [f64; 3])>,
}
impl RegionSize {
/// A checked field: a finite, positive `global` and local sizes, `growth` finite and
/// positive, regions with finite coordinates and at most [`MAX_REGION_TRIANGLES`] triangles
/// in all. Local sizes not below `global` change nothing and are dropped.
pub fn new(global: f64, growth: f64, locals: Vec<LocalSize>) -> Result<RegionSize> {
let positive = |what: &str, v: f64| {
if v > 0.0 && v.is_finite() { Ok(v) } else { Err(Error::Invalid(format!("{what} must be finite and positive, got {v}"))) }
};
positive("the global size", global)?;
positive("the growth rate", growth)?;
let triangles: usize = locals.iter().map(|l| if let Region::Triangles(t) = &l.region { t.len() } else { 0 }).sum();
if triangles > MAX_REGION_TRIANGLES {
return Err(Error::Limit(format!("size regions hold {triangles} triangles; the limit is {MAX_REGION_TRIANGLES}")));
}
let mut out = Vec::with_capacity(locals.len());
for l in locals {
positive("a local size", l.size)?;
match &l.region {
Region::Box { lo, hi } => fieldcraft_model::check_coordinates(&[lo.as_slice(), hi.as_slice()].concat())?,
Region::Sphere { center, radius } => {
fieldcraft_model::check_coordinates(center)?;
positive("a sphere's radius", *radius)?;
}
Region::Triangles(t) => fieldcraft_model::check_coordinates(t.as_flattened().as_flattened())?,
}
if l.size >= global {
continue;
}
let (lo, hi) = l.region.bounds();
if (0..3).any(|k| !(lo[k] <= hi[k])) {
continue; // no triangles
}
let reach = (global - l.size) / growth;
out.push((l, lo.map(|c| c - reach), hi.map(|c| c + reach)));
}
Ok(RegionSize { global, growth, locals: out })
}
pub fn global(&self) -> f64 {
self.global
}
pub fn growth(&self) -> f64 {
self.growth
}
/// Whether any local size refines the global one.
pub fn is_uniform(&self) -> bool {
self.locals.is_empty()
}
/// The local sizes that refine the global one.
pub fn locals(&self) -> impl Iterator<Item = &LocalSize> {
self.locals.iter().map(|l| &l.0)
}
/// The smallest size anywhere.
pub fn finest(&self) -> f64 {
self.locals().map(|l| l.size).fold(self.global, f64::min)
}
}
impl SizeField for RegionSize {
fn size(&self, p: [f64; 3]) -> f64 {
let mut h = self.global;
for (l, lo, hi) in &self.locals {
if (0..3).any(|k| p[k] < lo[k] || p[k] > hi[k]) {
continue;
}
h = h.min(l.size + self.growth * l.region.distance(p));
}
h
}
fn range(&self) -> Option<(f64, f64)> {
Some((self.finest(), self.global))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -260,4 +419,45 @@ mod tests {
assert!(loop_divisions(&[f64::INFINITY]).is_err());
assert!(even_divisions(f64::NAN).is_err());
}
#[test]
fn region_size_refines_inside_and_grows_away() {
let boxed = LocalSize { region: Region::Box { lo: [0.0; 3], hi: [2.0; 3] }, size: 0.5 };
let ball = LocalSize { region: Region::Sphere { center: [10.0, 0.0, 0.0], radius: 1.0 }, size: 0.25 };
let face = LocalSize { region: Region::Triangles(vec![[[0.0, 10.0, 0.0], [1.0, 10.0, 0.0], [0.0, 11.0, 0.0]]]), size: 1.0 };
let coarse = LocalSize { region: Region::Box { lo: [0.0; 3], hi: [1.0; 3] }, size: 5.0 };
let f = RegionSize::new(2.0, 0.5, vec![boxed, ball, face, coarse]).unwrap();
assert_eq!(f.locals().count(), 3, "the coarser control is dropped");
assert_eq!(f.size([1.0, 1.0, 1.0]), 0.5);
assert_eq!(f.size([3.0, 1.0, 1.0]), 1.0, "one unit out: 0.5 + 0.5");
assert_eq!(f.size([20.0, 20.0, 20.0]), 2.0);
assert_eq!(f.size([10.5, 0.0, 0.0]), 0.25);
assert_eq!(f.size([0.2, 10.2, 0.0]), 1.0);
assert!((f.size([0.2, 10.2, 1.0]) - 1.5).abs() < 1e-12);
assert_eq!(f.finest(), 0.25);
// Graded: the size never changes faster than the growth rate.
let pts: Vec<[f64; 3]> = (0..400).map(|i| [-5.0 + 0.05 * i as f64, 1.0, 1.0]).collect();
for w in pts.windows(2) {
assert!((f.size(w[1]) - f.size(w[0])).abs() <= 0.5 * 0.05 + 1e-12);
}
// Divisions follow it: a line through the box is cut finer inside.
let line = [[-4.0, 1.0, 1.0], [6.0, 1.0, 1.0]];
let n = edge_divisions(&f, &line).unwrap();
let pts = divide_polyline(&f, &line, n).unwrap();
let inside = pts.windows(2).filter(|w| w[0][0] >= 0.0 && w[1][0] <= 2.0).map(|w| w[1][0] - w[0][0]).fold(0.0, f64::max);
assert!(inside < 0.6, "{inside}");
}
#[test]
fn hostile_region_sizes_are_refused() {
let ok = || LocalSize { region: Region::Sphere { center: [0.0; 3], radius: 1.0 }, size: 0.5 };
assert!(RegionSize::new(0.0, 0.3, vec![]).is_err());
assert!(RegionSize::new(1.0, f64::NAN, vec![]).is_err());
assert!(RegionSize::new(1.0, 0.3, vec![LocalSize { size: -1.0, ..ok() }]).is_err());
assert!(RegionSize::new(1.0, 0.3, vec![LocalSize { region: Region::Sphere { center: [f64::NAN; 3], radius: 1.0 }, size: 0.5 }]).is_err());
assert!(RegionSize::new(1.0, 0.3, vec![LocalSize { region: Region::Triangles(vec![[[f64::INFINITY; 3]; 3]]), size: 0.5 }]).is_err());
let empty = RegionSize::new(1.0, 0.3, vec![LocalSize { region: Region::Triangles(vec![]), size: 0.5 }]).unwrap();
assert!(empty.is_uniform());
assert!(RegionSize::new(1.0, 0.3, vec![ok()]).unwrap().size([f64::NAN; 3]) > 0.0);
}
}
+1 -1
View File
@@ -236,7 +236,7 @@ fn solid_angle(s: &Surface, t: usize, x: [f64; 3]) -> f64 {
let [a, b, c] = s.tri_xyz(t).map(|p| sub(p, x));
let (la, lb, lc) = (norm(a), norm(b), norm(c));
let den = la * lb * lc + dot(a, b) * lc + dot(a, c) * lb + dot(b, c) * la;
2.0 * det(a, b, c).atan2(den)
2.0 * crate::vec3::atan2(det(a, b, c), den)
}
/// Edges between different B-rep faces, and edges inside a face whose normals turn by more than
+152
View File
@@ -0,0 +1,152 @@
//! The canonical form of a boundary triangle soup: the tetrahedral mesh of a part depends on its
//! geometry only, never on the order in which the CAD kernel lists its faces, triangles and
//! vertices.
//!
//! A kernel may enumerate the faces of one solid in a different order from run to run (its
//! internal maps are hash maps), which reorders the triangle soup and renumbers the face tags
//! while every coordinate stays the same. Welding keeps the first copy of a point it meets, and
//! every later stage visits vertices and faces in id order, so a reordered soup gives a different
//! mesh. [`CanonicalSoup`] sorts the triangles by their coordinates (rotated to start at their
//! smallest corner, which keeps their orientation), numbers the vertices by first use in that
//! order and the face tags by first appearance, and maps the tags back afterwards.
use std::collections::BTreeMap;
use crate::{Error, Result};
use super::QualityMesh;
use super::complex::NONE;
use super::geometry::AnalyticSurface;
/// A triangle soup in canonical order, and the original face tag of each canonical one.
#[derive(Debug, Clone, PartialEq)]
pub struct CanonicalSoup {
pub positions: Vec<[f64; 3]>,
pub triangles: Vec<[u32; 3]>,
pub tri_face: Vec<u32>,
/// `faces[c]`: the original tag of canonical tag `c`.
pub faces: Vec<u32>,
}
/// A coordinate as an integer whose order is the numeric order of the coordinate (−0 before +0;
/// the coordinates of a valid soup are finite).
fn ordered(x: f64) -> u64 {
let b = x.to_bits();
if b >> 63 == 1 { !b } else { b | (1 << 63) }
}
fn point_key(p: [f64; 3]) -> [u64; 3] {
p.map(ordered)
}
impl CanonicalSoup {
/// The canonical form of `triangles` over `positions`, each triangle on face `tri_face[i]`
/// (all on face 0 when `tri_face` is empty).
pub fn new(positions: &[[f64; 3]], triangles: &[[u32; 3]], tri_face: &[u32]) -> Result<CanonicalSoup> {
if !tri_face.is_empty() && tri_face.len() != triangles.len() {
return Err(Error::Invalid(format!("{} face tags for {} triangles", tri_face.len(), triangles.len())));
}
let point = |v: u32| positions.get(v as usize).copied().ok_or_else(|| Error::Invalid(format!("triangle vertex {v} does not exist")));
// Each triangle rotated to start at its smallest corner, keyed by its corners' coordinates.
let mut order: Vec<([[u64; 3]; 3], [u32; 3], u32)> = Vec::with_capacity(triangles.len());
for (i, t) in triangles.iter().enumerate() {
let keys = [point_key(point(t[0])?), point_key(point(t[1])?), point_key(point(t[2])?)];
let first = (0..3).min_by(|&a, &b| keys[a].cmp(&keys[b])).unwrap_or(0);
let r = |k: usize| (first + k) % 3;
let tag = tri_face.get(i).copied().unwrap_or(0);
order.push(([keys[r(0)], keys[r(1)], keys[r(2)]], [t[r(0)], t[r(1)], t[r(2)]], tag));
}
// Equal keys are the same triangle twice; the original tag breaks the tie only between
// copies on different faces, which no valid soup has.
order.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.2.cmp(&b.2)));
let mut id = vec![NONE; positions.len()];
let mut face_id: BTreeMap<u32, u32> = BTreeMap::new();
let mut out = CanonicalSoup {
positions: Vec::with_capacity(positions.len()),
triangles: Vec::with_capacity(triangles.len()),
tri_face: Vec::with_capacity(triangles.len()),
faces: Vec::new(),
};
for (_, t, tag) in order {
let mut nt = [0u32; 3];
for (slot, v) in nt.iter_mut().zip(t) {
let s = id.get_mut(v as usize).ok_or_else(|| Error::Invalid(format!("triangle vertex {v} does not exist")))?;
if *s == NONE {
*s = out.positions.len() as u32;
out.positions.push(point(v)?);
}
*slot = *s;
}
let c = *face_id.entry(tag).or_insert_with(|| {
out.faces.push(tag);
out.faces.len() as u32 - 1
});
out.triangles.push(nt);
out.tri_face.push(c);
}
Ok(out)
}
/// The canonical tag of an original face tag.
pub fn canonical_tag(&self, original: u32) -> Option<u32> {
self.faces.iter().position(|&f| f == original).map(|c| c as u32)
}
/// The exact surfaces by original tag, reordered by canonical tag.
pub fn analytic(&self, by_original: &[Option<AnalyticSurface>]) -> Vec<Option<AnalyticSurface>> {
self.faces.iter().map(|&f| by_original.get(f as usize).copied().flatten()).collect()
}
/// Give a mesh of the canonical soup its original face tags back.
pub fn restore_tags(&self, mesh: &mut QualityMesh) {
for b in &mut mesh.boundary {
if let Some(&f) = self.faces.get(b.face as usize) {
b.face = f;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A tetrahedron's surface listed in two orders, with its vertices and faces renumbered,
/// has one canonical form, and the tags map back.
#[test]
fn reordered_soups_have_one_canonical_form() {
let p = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
let t = [[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]];
let a = CanonicalSoup::new(&p, &t, &[10, 11, 12, 13]).unwrap();
// Vertices reversed, triangles reversed and rotated, tags permuted.
let q: Vec<[f64; 3]> = p.iter().rev().copied().collect();
let map = |v: u32| 3 - v;
let u: Vec<[u32; 3]> = t.iter().rev().map(|f| [map(f[1]), map(f[2]), map(f[0])]).collect();
let b = CanonicalSoup::new(&q, &u, &[3, 2, 1, 0]).unwrap();
assert_eq!((&a.positions, &a.triangles, &a.tri_face), (&b.positions, &b.triangles, &b.tri_face));
for c in 0..4u32 {
// Triangle k of `t` is on face 10 + k in a and, listed as u[3 − k], on face k in b.
assert_eq!(a.faces[c as usize] - 10, b.faces[c as usize]);
assert_eq!(a.canonical_tag(a.faces[c as usize]), Some(c));
}
// Orientation is kept: the enclosed volume has the same sign.
let vol = |s: &CanonicalSoup| -> f64 {
s.triangles
.iter()
.map(|f| {
let [x, y, z] = f.map(|v| s.positions[v as usize]);
x[0] * (y[1] * z[2] - y[2] * z[1]) - x[1] * (y[0] * z[2] - y[2] * z[0]) + x[2] * (y[0] * z[1] - y[1] * z[0])
})
.sum()
};
assert!(vol(&a) != 0.0 && vol(&a) == vol(&b));
}
#[test]
fn hostile_soups_are_refused() {
let p = [[0.0, 0.0, 0.0]];
assert!(CanonicalSoup::new(&p, &[[0, 0, 7]], &[]).is_err());
assert!(CanonicalSoup::new(&p, &[[0, 0, 0]], &[1, 2]).is_err());
assert_eq!(CanonicalSoup::new(&p, &[], &[]).unwrap().triangles.len(), 0);
}
}
+179 -6
View File
@@ -170,7 +170,9 @@ pub enum Step {
pub struct Raw {
slots: Vec<(u32, [u32; 4], [u32; 4], [u32; 4])>,
tets_len: usize,
free: Vec<u32>,
/// The free list's length, and its last entries (those the change may take).
free_len: usize,
free_tail: Vec<u32>,
vtets: Vec<(u32, Vec<u32>)>,
live: usize,
}
@@ -199,6 +201,21 @@ pub struct TetComplex {
frozen: Vec<u32>,
mark: Vec<u32>,
epoch: u32,
/// Change clock: `touched[v]` is the clock of the last change to a tetrahedron of `v`'s star
/// (made, removed, reshaped or restored), `failed[t]` the clock at which every topological
/// operation on slot `t` last failed ([`Self::known_to_fail`]).
clock: u64,
touched: Vec<u64>,
failed: Vec<u64>,
/// The clock at which removing an edge `(a, b)` (sorted), or a face from apex `a`'s side to apex `b` (sorted face)
/// last failed: the operation reads only the tetrahedra holding both apexes.
failed_edges: IdMap<(u32, u32), u64>,
failed_faces: IdMap<(u32, u32, [u32; 3]), u64>,
/// Per slot: the quality ([`super::measure::quality`]) and one more than the clock it was
/// computed at; current while no vertex of the tetrahedron was touched since.
quality_cache: std::cell::RefCell<Vec<(u64, f64)>>,
/// The clock at which smoothing a vertex last found no move.
failed_smoothing: Vec<u64>,
live: usize,
max_tets: usize,
max_points: usize,
@@ -281,6 +298,13 @@ impl TetComplex {
frozen: Vec::new(),
mark: vec![0; tets.len()],
epoch: 0,
clock: 0,
touched: vec![0; points.len()],
failed: Vec::new(),
failed_edges: IdMap::default(),
failed_faces: IdMap::default(),
failed_smoothing: Vec::new(),
quality_cache: std::cell::RefCell::new(Vec::new()),
live: tets.len(),
max_tets,
max_points,
@@ -355,6 +379,125 @@ impl TetComplex {
if let Some(s) = self.kinds.get_mut(v as usize) {
*s = k;
}
self.touch_star(v);
}
// ---- change clock ----
fn touch(&mut self, vs: &[u32]) {
self.clock += 1;
for &v in vs {
if let Some(s) = self.touched.get_mut(v as usize) {
*s = self.clock;
}
}
}
/// Touch `v` and every vertex of its star (moving `v` reshapes those tetrahedra).
fn touch_star(&mut self, v: u32) {
let mut vs = vec![v];
for &t in self.vtets.get(v as usize).map(Vec::as_slice).unwrap_or_default() {
vs.extend(self.tets.get(t as usize).copied().unwrap_or([NONE; 4]));
}
self.touch(&vs);
}
/// The last change near tetrahedron `t`: in the stars of its vertices and of its neighbours'
/// vertices, the region every topological operation on `t` reads.
fn stamp(&self, t: u32) -> Result<u64> {
let mut m = 0;
let mut look = |vs: [u32; 4]| {
for v in vs {
m = m.max(self.touched.get(v as usize).copied().unwrap_or(u64::MAX));
}
};
look(self.tet(t)?);
for n in self.adj(t)? {
if n != NONE {
look(self.tet(n)?);
}
}
Ok(m)
}
fn touched_at(&self, v: u32) -> u64 {
self.touched.get(v as usize).copied().unwrap_or(u64::MAX)
}
/// Record that removing edge `ab` failed now.
pub fn mark_edge_failed(&mut self, a: u32, b: u32) {
self.failed_edges.insert(sorted2(a, b), self.clock);
}
/// Whether removing edge `ab` failed and no tetrahedron around `a` or `b` changed since.
pub fn edge_known_to_fail(&self, a: u32, b: u32) -> bool {
self.failed_edges.get(&sorted2(a, b)).is_some_and(|&f| self.touched_at(a).max(self.touched_at(b)) <= f)
}
/// Record that removing `face` between apexes `a` and `b` failed now.
pub fn mark_face_failed(&mut self, a: u32, b: u32, face: [u32; 3]) {
self.failed_faces.insert((a, b, sorted3(face)), self.clock);
}
/// Whether removing `face` between `a` and `b` failed and nothing around them changed since.
pub fn face_known_to_fail(&self, a: u32, b: u32, face: [u32; 3]) -> bool {
self.failed_faces.get(&(a, b, sorted3(face))).is_some_and(|&f| self.touched_at(a).max(self.touched_at(b)) <= f)
}
/// The quality of live tetrahedron `t`, computed once per change of its vertices.
pub fn slot_quality(&self, t: u32) -> Result<f64> {
let vs = self.tet(t)?;
let stamp = vs.iter().map(|&v| self.touched_at(v)).max().unwrap_or(u64::MAX);
let i = t as usize;
if let Some(&(c, q)) = self.quality_cache.borrow().get(i)
&& c > stamp
{
return Ok(q);
}
let q = super::measure::quality(&self.coords(vs)?);
let mut cache = self.quality_cache.borrow_mut();
if cache.len() <= i {
cache.resize(i + 1, (0, 0.0));
}
if let Some(e) = cache.get_mut(i) {
*e = (self.clock + 1, q);
}
Ok(q)
}
/// Record that smoothing `v` found no move now.
pub fn mark_smoothing_failed(&mut self, v: u32) {
let i = v as usize;
if self.failed_smoothing.len() <= i {
self.failed_smoothing.resize(i + 1, 0);
}
if let Some(f) = self.failed_smoothing.get_mut(i) {
*f = self.clock;
}
}
/// Whether smoothing `v` found no move and no tetrahedron around it changed since.
pub fn smoothing_known_to_fail(&self, v: u32) -> bool {
self.failed_smoothing.get(v as usize).is_some_and(|&f| f > 0 && self.touched_at(v) <= f)
}
/// Record that every topological operation on `t` failed now.
pub fn mark_failed(&mut self, t: u32) {
let i = t as usize;
if self.failed.len() <= i {
self.failed.resize(i + 1, 0);
}
if let Some(f) = self.failed.get_mut(i) {
*f = self.clock;
}
}
/// Whether the topological operations on `t` failed and nothing they read has changed
/// since, so trying again would fail the same way (they are deterministic functions of that
/// region).
pub fn known_to_fail(&self, t: u32) -> Result<bool> {
let Some(&f) = self.failed.get(t as usize) else { return Ok(false) };
Ok(f > 0 && self.stamp(t)? <= f)
}
pub fn slots(&self) -> usize {
@@ -424,6 +567,7 @@ impl TetComplex {
pub fn set_curve(&mut self, a: u32, b: u32, curve: Option<u32>) {
let key = sorted2(a, b);
self.touch(&[a, b]);
let before = match curve {
Some(c) => self.curves.insert(key, c),
None => self.curves.remove(&key),
@@ -688,6 +832,7 @@ impl TetComplex {
self.pts.push(p);
self.kinds.push(kind);
self.vtets.push(Vec::new());
self.touched.push(self.clock);
if self.journal.recording {
self.journal.steps.push(Step::Added { v });
}
@@ -695,7 +840,7 @@ impl TetComplex {
}
/// The data a change of `slots` and of the incidence of `verts` would overwrite.
fn snapshot(&self, slots: &[u32], verts: &[u32]) -> Raw {
fn snapshot(&self, slots: &[u32], verts: &[u32], takes: usize) -> Raw {
let mut ids: Vec<u32> = slots.to_vec();
ids.sort_unstable();
ids.dedup();
@@ -711,7 +856,8 @@ impl TetComplex {
})
.collect(),
tets_len: self.tets.len(),
free: self.free.clone(),
free_len: self.free.len(),
free_tail: self.free.get(self.free.len().saturating_sub(takes)..).unwrap_or_default().to_vec(),
vtets: vs.into_iter().filter_map(|v| Some((v, self.vtets.get(v as usize)?.clone()))).collect(),
live: self.live,
}
@@ -719,6 +865,12 @@ impl TetComplex {
/// Put back what [`Self::snapshot`] saved.
fn restore(&mut self, raw: Raw) {
let mut touched: Vec<u32> = raw.vtets.iter().map(|(v, _)| *v).collect();
for (t, vs, _, _) in &raw.slots {
touched.extend(self.tets.get(*t as usize).copied().unwrap_or([NONE; 4]));
touched.extend(vs);
}
self.touch(&touched);
for (v, l) in raw.vtets {
if let Some(s) = self.vtets.get_mut(v as usize) {
*s = l;
@@ -734,7 +886,8 @@ impl TetComplex {
self.adj.truncate(raw.tets_len);
self.tags.truncate(raw.tets_len);
self.mark.truncate(raw.tets_len);
self.free = raw.free;
self.free.truncate(raw.free_len.saturating_sub(raw.free_tail.len()));
self.free.extend(raw.free_tail);
self.live = raw.live;
}
@@ -744,6 +897,7 @@ impl TetComplex {
self.pts.pop();
self.kinds.pop();
self.vtets.pop();
self.touched.pop();
return Ok(());
}
Err(corrupt("dropping a vertex in use"))
@@ -755,6 +909,7 @@ impl TetComplex {
let slot = self.pts.get_mut(v as usize).ok_or_else(|| corrupt("vertex id"))?;
let from = *slot;
*slot = p;
self.touch_star(v);
if self.journal.recording {
self.journal.steps.push(Step::Moved { v, from });
}
@@ -962,7 +1117,7 @@ impl TetComplex {
for &t in old {
verts.extend(self.tet(t)?);
}
Some(Box::new(self.snapshot(&slots, &verts)))
Some(Box::new(self.snapshot(&slots, &verts, change.new.len())))
} else {
None
};
@@ -1002,6 +1157,9 @@ impl TetComplex {
}
}
}
let mut changed: Vec<u32> = removed.iter().flat_map(|r| r.vs).collect();
changed.extend(change.new.iter().flatten());
self.touch(&changed);
self.live = self.live - old.len() + ids.len();
if self.journal.recording {
self.journal.steps.push(Step::Replaced { removed, added: change.new.clone(), raw: raw.unwrap_or_default() });
@@ -1033,7 +1191,18 @@ impl TetComplex {
if self.curve(vs[k], vs[l]).is_some() {
return Ok(false);
}
let raw = self.journal.recording.then(|| Box::new(self.snapshot(&[t, ns[k], ns[l]], &vs)));
// Each neighbour gains a boundary face (both, when they are one tetrahedron); one left
// with every face on the boundary would float free of the mesh, joined to it by its
// vertices alone (a singular stiffness).
for m in [k, l] {
let n = ns[m];
let gained = if ns[k] == ns[l] { 2 } else { 1 };
let open = self.adj(n)?.iter().filter(|&&x| x == NONE).count();
if open + gained >= 4 {
return Ok(false);
}
}
let raw = self.journal.recording.then(|| Box::new(self.snapshot(&[t, ns[k], ns[l]], &vs, 0)));
for m in [k, l] {
let n = ns[m];
let nn = self.adj(n)?;
@@ -1044,6 +1213,7 @@ impl TetComplex {
}
}
self.unlink(t, vs);
self.touch(&vs);
if let Some(s) = self.tets.get_mut(t as usize) {
*s = [DEAD; 4];
}
@@ -1111,15 +1281,18 @@ impl TetComplex {
if let Some(s) = self.pts.get_mut(v as usize) {
*s = from;
}
self.touch_star(v);
}
Step::Added { v } => {
if v as usize + 1 == self.pts.len() {
self.pts.pop();
self.kinds.pop();
self.vtets.pop();
self.touched.pop();
}
}
Step::Curve { edge, curve } => {
self.touch(&[edge.0, edge.1]);
match curve {
Some(c) => self.curves.insert(edge, c),
None => self.curves.remove(&edge),
+151 -42
View File
@@ -13,7 +13,7 @@
//! analytic (von Neumann's alternating projections converge to the intersection for surfaces
//! meeting transversally); the result is kept only when it stays close to the polyline.
use crate::size::SizeField;
use crate::size::{LocalSize, Region, SizeField};
use crate::surface::BoundarySurface;
use super::measure::{self, P, add, cross, dot, norm, scale, sub};
@@ -429,6 +429,20 @@ pub struct Tessellation {
/// A tree over each face pair's segments.
pair_trees: std::collections::BTreeMap<[u32; 2], Tree>,
analytic: Vec<Option<AnalyticSurface>>,
/// Faces made of several B-rep faces (virtual topology): each triangle's B-rep face (its
/// piece) and each piece's exact surface, for a face with no one surface of its own. Empty
/// when no face is merged.
tri_piece: Vec<u32>,
piece_analytic: Vec<Option<AnalyticSurface>>,
}
/// The B-rep faces of merged faces: per triangle of the surface a [`Tessellation`] is built on
/// (as given to [`BoundarySurface::new`]) the B-rep face it lies on, and each B-rep face's exact
/// surface when known.
#[derive(Debug, Clone, Default)]
pub struct Pieces {
pub tri_piece: Vec<u32>,
pub analytic: Vec<Option<AnalyticSurface>>,
}
impl Tessellation {
@@ -480,7 +494,31 @@ impl Tessellation {
by_pair.entry(*pair).or_default().push(i as u32);
}
let pair_trees = by_pair.into_iter().map(|(pair, items)| (pair, Tree::build_over(&seg_boxes, items))).collect();
Tessellation { points, triangles, tri_face, tri_tree, face_trees, segments, seg_tree, pair_trees, analytic }
Tessellation {
points,
triangles,
tri_face,
tri_tree,
face_trees,
segments,
seg_tree,
pair_trees,
analytic,
tri_piece: Vec::new(),
piece_analytic: Vec::new(),
}
}
/// The same target with the B-rep pieces of its merged faces (`surface` the boundary
/// surface it was built on): a point on a merged face that lies on no one surface goes onto
/// the exact surface of the piece it is on, trimmed to that piece, instead of its triangles.
pub fn with_pieces(mut self, surface: &BoundarySurface, pieces: Pieces) -> Tessellation {
if pieces.tri_piece.is_empty() {
return self;
}
self.tri_piece = surface.source_triangle().iter().map(|&t| pieces.tri_piece.get(t as usize).copied().unwrap_or(u32::MAX)).collect();
self.piece_analytic = pieces.analytic;
self
}
fn pt(&self, v: u32) -> P {
@@ -492,19 +530,42 @@ impl Tessellation {
self.analytic.get(face as usize).and_then(|a| a.as_ref())
}
/// The closest point on the face's triangles, and the longest edge of the triangle it is
/// on (the local scale of the tessellation).
fn on_tessellation(&self, face: u32, p: P) -> Option<(P, f64)> {
/// The exact surface of a point of face `face` on its triangle `t`: the face's own, else
/// that of the B-rep piece of a merged face the triangle lies on.
fn exact(&self, face: u32, t: u32) -> Option<&AnalyticSurface> {
self.analytic(face).or_else(|| {
let piece = *self.tri_piece.get(t as usize)?;
self.piece_analytic.get(piece as usize).and_then(|a| a.as_ref())
})
}
/// Whether triangle `u` lies on the same piece of face `face` as triangle `t` (always, for
/// a face with a surface of its own or without pieces).
fn same_piece(&self, face: u32, t: u32, u: u32) -> bool {
self.analytic(face).is_some() || self.tri_piece.get(t as usize) == self.tri_piece.get(u as usize)
}
/// The nearest triangle of face `face` to `p` and the closest point on it.
fn nearest_triangle(&self, face: u32, p: P) -> Option<(P, u32)> {
let accept = |t: u32| self.tri_face.get(t as usize) == Some(&face);
let dist = |t: u32| {
let tri = self.triangles.get(t as usize).copied().unwrap_or([0; 3]);
let (a, b, c) = (self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2]));
let q = closest_on_triangle(p, a, b, c);
let q = closest_on_triangle(p, self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2]));
let d = sub(q, p);
let longest = measure::dist(a, b).max(measure::dist(b, c)).max(measure::dist(c, a));
(dot(d, d), [q[0], q[1], q[2], longest])
// The triangle index travels through the f64 exactly (counts are capped far below
// 2^53).
(dot(d, d), [q[0], q[1], q[2], f64::from(t)])
};
self.face_tree(face).nearest4(p, &accept, &dist).map(|r| ([r[0], r[1], r[2]], r[3]))
self.face_tree(face).nearest4(p, &accept, &dist).map(|r| ([r[0], r[1], r[2]], r[3] as u32))
}
/// The closest point on the face's triangles, the longest edge of the triangle it is on
/// (the local scale of the tessellation) and that triangle.
fn on_tessellation(&self, face: u32, p: P) -> Option<(P, f64, u32)> {
let (q, t) = self.nearest_triangle(face, p)?;
let tri = self.triangles.get(t as usize).copied().unwrap_or([0; 3]);
let (a, b, c) = (self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2]));
Some((q, measure::dist(a, b).max(measure::dist(b, c)).max(measure::dist(c, a)), t))
}
/// The B-rep face whose triangles lie nearest to `p`.
@@ -552,24 +613,17 @@ impl Tessellation {
/// closest point of the face's triangles. Unlike [`BoundaryGeometry::project_face`] it never
/// returns a point of the surface's extension beyond the face's boundary, whatever the
/// distance from `p` — nor of the same surface's other faces: the pieces of one fillet split
/// by a cut share their cylinder, and a point on one piece is off the others.
/// by a cut share their cylinder, and a point on one piece is off the others. On a merged
/// face lying on no one surface, the surface is that of the piece its nearest triangle is
/// on, trimmed to that piece.
pub fn project_trimmed(&self, face: u32, p: P) -> Option<P> {
let accept = |t: u32| self.tri_face.get(t as usize) == Some(&face);
let corners = |t: u32| {
let tri = self.triangles.get(t as usize).copied().unwrap_or([0; 3]);
(self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2]))
};
let near = {
let dist = |t: u32| {
let (a, b, c) = corners(t);
let q = closest_on_triangle(p, a, b, c);
let d = sub(q, p);
(dot(d, d), [q[0], q[1], q[2], 0.0])
};
let r = self.face_tree(face).nearest4(p, &accept, &dist)?;
[r[0], r[1], r[2]]
};
let Some(surface) = self.analytic(face) else { return Some(near) };
let (near, nearest) = self.nearest_triangle(face, p)?;
let accept = |t: u32| self.tri_face.get(t as usize) == Some(&face) && self.same_piece(face, nearest, t);
let Some(surface) = self.exact(face, nearest) else { return Some(near) };
let Some(q) = surface.project(p) else { return Some(near) };
// The nearest triangle to the surface's point, with its slack: the largest gap between
// the triangle and the surface (at its edges' midpoints and centre: a chord's deepest
@@ -589,19 +643,14 @@ impl Tessellation {
}
/// The outward unit normal of B-rep face `face` near `p`: the exact surface's (oriented like
/// the nearest triangle) when known, else the nearest triangle's.
/// the nearest triangle; on a merged face, its piece's) when known, else the nearest
/// triangle's.
pub fn face_normal(&self, face: u32, p: P) -> Option<P> {
let accept = |t: u32| self.tri_face.get(t as usize) == Some(&face);
let dist = |t: u32| {
let tri = self.triangles.get(t as usize).copied().unwrap_or([0; 3]);
let (a, b, c) = (self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2]));
let d = sub(closest_on_triangle(p, a, b, c), p);
let n = unit(cross(sub(b, a), sub(c, a))).unwrap_or([0.0; 3]);
(dot(d, d), [n[0], n[1], n[2], 0.0])
};
let r = self.face_tree(face).nearest4(p, &accept, &dist)?;
let flat = unit([r[0], r[1], r[2]])?;
match self.analytic(face).and_then(|s| s.normal_at(p)) {
let (_, t) = self.nearest_triangle(face, p)?;
let tri = self.triangles.get(t as usize).copied().unwrap_or([0; 3]);
let (a, b, c) = (self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2]));
let flat = unit(cross(sub(b, a), sub(c, a)))?;
match self.exact(face, t).and_then(|s| s.normal_at(p)) {
Some(n) if dot(n, flat) < 0.0 => Some(scale(n, -1.0)),
Some(n) => Some(n),
None => Some(flat),
@@ -611,8 +660,8 @@ impl Tessellation {
impl BoundaryGeometry for Tessellation {
fn project_face(&self, face: u32, p: P) -> Option<P> {
let (near, scale_len) = self.on_tessellation(face, p)?;
match self.analytic(face) {
let (near, scale_len, t) = self.on_tessellation(face, p)?;
match self.exact(face, t) {
// The exact surface's closest point, kept when it is near the tessellation's: the
// tessellation deviates from its surface by far less than half its triangles' size,
// so a point further away is a projection onto another part of the surface (the far
@@ -664,8 +713,8 @@ impl Tessellation {
/// tessellation's chordal tolerance, as [`BoundaryGeometry::project_face`]).
pub fn slide_face(&self, face: u32, p: P, d: P) -> Option<P> {
let target = add(p, d);
let (near, scale_len) = self.on_tessellation(face, target)?;
if let Some(q) = self.analytic(face).and_then(|s| s.slide(p, d)) {
let (near, scale_len, t) = self.on_tessellation(face, target)?;
if let Some(q) = self.exact(face, t).and_then(|s| s.slide(p, d)) {
let tol = (0.5 * scale_len).max(2.0 * measure::dist(target, near));
if measure::dist(q, near) <= tol {
return Some(q);
@@ -793,9 +842,13 @@ pub const MAX_GRID_NODES: usize = 1 << 21;
impl GradedSize {
/// `samples`: points with their local size. `grading`: the largest change of size per unit
/// length.
pub fn new(global: f64, samples: &[(P, f64)], lo: P, hi: P, grading: f64) -> GradedSize {
///
/// `locals`: regions with their size ([`crate::size::LocalSize`]), seeded at every grid node
/// within a cell of the region (boxes and balls: inside too) at the local size plus the
/// grading times the distance, then graded with the rest.
pub fn new(global: f64, samples: &[(P, f64)], locals: &[LocalSize], lo: P, hi: P, grading: f64) -> GradedSize {
let diag = measure::dist(lo, hi).max(f64::MIN_POSITIVE);
let finest = samples.iter().map(|s| s.1).fold(global, f64::min).max(diag * 1e-4);
let finest = samples.iter().map(|s| s.1).chain(locals.iter().map(|l| l.size)).filter(|h| *h > 0.0).fold(global, f64::min).max(diag * 1e-4);
let mut cell = finest.max(diag / 64.0) * 0.5;
let dims_for = |c: f64| [0, 1, 2].map(|k| (((hi[k] - lo[k]) / c).ceil().max(1.0) as usize) + 2);
let mut dims = dims_for(cell);
@@ -830,6 +883,47 @@ impl GradedSize {
}
}
}
// Seed: the grid nodes near each local region.
let reach = cell * 3f64.sqrt();
let range = |a: f64, b: f64, k: usize| -> (usize, usize) {
let first = ((a - reach - lo[k]) / cell).ceil().max(0.0);
let last = ((b + reach - lo[k]) / cell).floor().min((dims[k] - 1) as f64);
if first.is_finite() && last.is_finite() && first <= last { (first as usize, last as usize) } else { (1, 0) }
};
let mut seed_near = |a: P, b: P, h: f64, distance: &dyn Fn(P) -> f64| {
let r = [0, 1, 2].map(|k| range(a[k], b[k], k));
for k in r[2].0..=r[2].1 {
for j in r[1].0..=r[1].1 {
for i in r[0].0..=r[0].1 {
let node = [lo[0] + i as f64 * cell, lo[1] + j as f64 * cell, lo[2] + k as f64 * cell];
let d = distance(node);
if d <= reach
&& let Some(s) = values.get_mut(idx(i, j, k))
{
*s = s.min(h + g * d);
}
}
}
}
};
for l in locals {
if !(l.size > 0.0 && l.size.is_finite() && l.size < global) {
continue;
}
match &l.region {
Region::Triangles(tris) => {
for t in tris {
let a = [0, 1, 2].map(|k| t[0][k].min(t[1][k]).min(t[2][k]));
let b = [0, 1, 2].map(|k| t[0][k].max(t[1][k]).max(t[2][k]));
seed_near(a, b, l.size, &|p| measure::dist(p, closest_on_triangle(p, t[0], t[1], t[2])));
}
}
region => {
let (a, b) = region.bounds();
seed_near(a, b, l.size, &|p| region.distance(p));
}
}
}
// Fast sweeping: eight sweep orders, each relaxing against the three axis neighbours
// behind the sweep direction (and diagonals through them over passes).
if g > 0.0 {
@@ -934,9 +1028,24 @@ mod tests {
assert_eq!(closest_on_segment([0.5, 2.0, 0.0], a, b), [0.5, 0.0, 0.0]);
}
#[test]
fn graded_size_takes_local_regions() {
let boxed = LocalSize { region: Region::Box { lo: [8.0; 3], hi: [12.0; 3] }, size: 2.0 };
let face = LocalSize { region: Region::Triangles(vec![[[0.0, 0.0, 20.0], [20.0, 0.0, 20.0], [0.0, 20.0, 20.0]]]), size: 1.0 };
let f = GradedSize::new(8.0, &[], &[boxed, face], [0.0; 3], [20.0, 20.0, 20.0], 0.5);
let inside = f.size([10.0, 10.0, 10.0]);
assert!((inside - 2.0).abs() < 0.3, "{inside}");
let on_face = f.size([5.0, 5.0, 20.0]);
assert!((on_face - 1.0).abs() < 0.3, "{on_face}");
// Four units off the box: about 2 + 0.5 × 4.
let off = f.size([10.0, 10.0, 4.0]);
assert!((off - 4.0).abs() < 0.6, "{off}");
assert_eq!(f.size([20.0, 20.0, 0.0]), 8.0);
}
#[test]
fn graded_size_grows_away_from_samples() {
let f = GradedSize::new(10.0, &[([0.0; 3], 1.0)], [0.0; 3], [20.0, 20.0, 20.0], 0.5);
let f = GradedSize::new(10.0, &[([0.0; 3], 1.0)], &[], [0.0; 3], [20.0, 20.0, 20.0], 0.5);
let near = f.size([0.0; 3]);
let mid = f.size([4.0, 0.0, 0.0]);
let far = f.size([20.0, 20.0, 20.0]);
+38 -23
View File
@@ -24,7 +24,7 @@ use super::complex::{EDGES, IdMap, NONE, Step, TetComplex, VertexKind, Walk, fac
use super::measure::{self, P};
use super::ops::{self, Site, remove_edge_at, remove_face, tet_quality};
use super::refine::{Context, MAX_CAVITY, MIN_GAP};
use super::smooth::smooth_vertex;
use super::smooth::{smooth_vertex, smooth_vertex_below};
/// Settings of the schedule.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -53,6 +53,8 @@ pub struct ImproveStats {
pub smoothed: usize,
pub topological: usize,
pub inserted: usize,
/// Insertions tried (kept or undone).
pub attempted: usize,
/// The worst quality (smallest dihedral sine) after each pass.
pub worst: Vec<f64>,
pub stopped: Option<String>,
@@ -81,8 +83,8 @@ fn indicators(cx: &TetComplex) -> Result<[f64; 8]> {
let mut out = [0.0; 8];
out[0] = f64::INFINITY;
let mut n = 0usize;
for (_, vs) in cx.live_tets() {
let q = tet_quality(cx, vs)?;
for (t, _) in cx.live_tets() {
let q = cx.slot_quality(t)?;
out[0] = out[0].min(q);
for (i, s) in SIN_DEG.iter().enumerate() {
out[i + 1] += q.min(*s);
@@ -102,8 +104,8 @@ fn progressed(now: &[f64; 8], before: &[f64; 8]) -> bool {
/// Smooth every movable vertex of a tetrahedron below `good`. Returns the moves.
fn smoothing_pass(cx: &mut TetComplex, ctx: &Context, good: f64) -> Result<usize> {
let mut want = vec![false; cx.point_count()];
for (_, vs) in cx.live_tets() {
if tet_quality(cx, vs)? < good {
for (t, vs) in cx.live_tets() {
if cx.slot_quality(t)? < good {
for v in vs {
if let Some(w) = want.get_mut(v as usize) {
*w = true;
@@ -149,7 +151,7 @@ fn delete_flat(cx: &mut TetComplex, t: u32) -> Result<bool> {
/// edge ([`ops::collapse`]), which takes the tetrahedron away.
fn collapse_into_crease(cx: &mut TetComplex, t: u32) -> Result<bool> {
let vs = cx.tet(t)?;
if tet_quality(cx, vs)? > 0.1 {
if cx.slot_quality(t)? > 0.1 {
return Ok(false);
}
let (ns, tags) = (cx.adj(t)?, cx.tags(t)?);
@@ -182,7 +184,7 @@ fn collapse_into_crease(cx: &mut TetComplex, t: u32) -> Result<bool> {
pub fn topological_pass(cx: &mut TetComplex, list: &[u32]) -> Result<usize> {
let mut changes = 0;
for &t in list {
if !cx.is_live(t) {
if !cx.is_live(t) || cx.known_to_fail(t)? {
continue;
}
let vs = cx.tet(t)?;
@@ -201,20 +203,25 @@ pub fn topological_pass(cx: &mut TetComplex, list: &[u32]) -> Result<usize> {
changes += 1;
continue;
}
let mut removed = false;
for k in 0..4 {
if remove_face(cx, t, k)? {
changes += 1;
removed = true;
break;
}
}
if !removed {
cx.mark_failed(t);
}
}
Ok(changes)
}
fn below(cx: &TetComplex, good: f64) -> Result<Vec<u32>> {
let mut out = Vec::new();
for (t, vs) in cx.live_tets() {
if tet_quality(cx, vs)? < good {
for (t, _) in cx.live_tets() {
if cx.slot_quality(t)? < good {
out.push(t);
}
}
@@ -288,7 +295,8 @@ fn change_quality(cx: &mut TetComplex, len: usize) -> Result<(f64, f64)> {
}
/// The composite insertion of §3.5 (see the module docs). Returns whether it was kept.
fn attempt_insert(cx: &mut TetComplex, ctx: &Context, p: Point3, kind: VertexKind, site: Site) -> Result<bool> {
fn attempt_insert(cx: &mut TetComplex, ctx: &Context, attempted: &mut usize, good: f64, p: Point3, kind: VertexKind, site: Site) -> Result<bool> {
*attempted += 1;
let was = cx.journal.recording;
cx.journal.recording = true;
let len = cx.journal.steps.len();
@@ -303,8 +311,15 @@ fn attempt_insert(cx: &mut TetComplex, ctx: &Context, p: Point3, kind: VertexKin
if !cx.is_used(v) {
break;
}
let star = cx.star(v)?;
if topological_pass(cx, &star)? == 0 {
// Only the tetrahedra below the threshold, as in the passes.
let mut star = cx.star(v)?;
let mut keep = Vec::with_capacity(star.len());
for &t in &star {
keep.push(cx.slot_quality(t)? < good);
}
let mut flags = keep.into_iter();
star.retain(|_| flags.next().unwrap_or(false));
if star.is_empty() || topological_pass(cx, &star)? == 0 {
break;
}
}
@@ -312,7 +327,7 @@ fn attempt_insert(cx: &mut TetComplex, ctx: &Context, p: Point3, kind: VertexKin
let mut around: Vec<u32> = cx.neighbours(v)?;
around.push(v);
for u in around {
smooth_vertex(cx, ctx, u)?;
smooth_vertex_below(cx, ctx, u, good)?;
}
}
let (before, after) = change_quality(cx, len)?;
@@ -337,7 +352,7 @@ fn attempt_insert(cx: &mut TetComplex, ctx: &Context, p: Point3, kind: VertexKin
}
/// Try inserting a vertex in each tetrahedron of `list` (§4.2). Returns the insertions kept.
fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usize, good: f64) -> Result<usize> {
fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usize, good: f64, attempted: &mut usize) -> Result<usize> {
let mut kept = 0;
for &t in list {
if kept >= budget {
@@ -347,7 +362,7 @@ fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usiz
continue;
}
let vs = cx.tet(t)?;
if tet_quality(cx, vs)? >= good {
if cx.slot_quality(t)? >= good {
continue;
}
let ns = cx.adj(t)?;
@@ -374,7 +389,7 @@ fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usiz
let target = measure::add(measure::mid(pa, pb), measure::scale(inward, 0.4 * l / len));
if let Ok(p) = Point3::try_from(target)
&& let Some(Walk::Inside(at)) = cx.walk(t, &p, 1 << 12)?
&& attempt_insert(cx, ctx, p, VertexKind::Interior, Site::Interior(at))?
&& attempt_insert(cx, ctx, attempted, good, p, VertexKind::Interior, Site::Interior(at))?
{
done = true;
}
@@ -391,7 +406,7 @@ fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usiz
let kind = VertexKind::Face(tags[k]);
if let Some(p) = ctx.place(kind, g)
&& cx.is_live(t)
&& attempt_insert(cx, ctx, p, kind, Site::Face(t, k))?
&& attempt_insert(cx, ctx, attempted, good, p, kind, Site::Face(t, k))?
{
done = true;
}
@@ -400,7 +415,7 @@ fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usiz
if !done && cx.is_live(t) && cx.tet(t)? == vs {
let g = measure::centroid(&cx.coords(vs)?);
if let Ok(p) = Point3::try_from(g)
&& attempt_insert(cx, ctx, p, VertexKind::Interior, Site::Interior(t))?
&& attempt_insert(cx, ctx, attempted, good, p, VertexKind::Interior, Site::Interior(t))?
{
done = true;
}
@@ -430,7 +445,7 @@ fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usiz
};
let m = measure::mid(cx.xyz(a)?, cx.xyz(b)?);
if let Some(p) = ctx.place(kind, m)
&& attempt_insert(cx, ctx, p, kind, Site::Edge(a, b))?
&& attempt_insert(cx, ctx, attempted, good, p, kind, Site::Edge(a, b))?
{
done = true;
break;
@@ -479,8 +494,8 @@ pub fn improve(cx: &mut TetComplex, ctx: &Context, o: &ImproveOptions) -> Result
// The worst 3.5% of the tetrahedra, or (desperation pass, §4.2) every one with a
// dihedral angle below 40° or above 140° (sine below sin 40°).
let mut scored: Vec<(f64, u32)> = Vec::new();
for (t, vs) in cx.live_tets() {
scored.push((tet_quality(cx, vs)?, t));
for (t, _) in cx.live_tets() {
scored.push((cx.slot_quality(t)?, t));
}
scored.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
let list: Vec<u32> = if failed == 1 {
@@ -490,7 +505,7 @@ pub fn improve(cx: &mut TetComplex, ctx: &Context, o: &ImproveOptions) -> Result
scored.iter().take(n).map(|s| s.1).collect()
};
let budget = o.max_insertions - stats.inserted;
stats.inserted += insertion_pass(cx, ctx, &list, budget, o.good)?;
stats.inserted += insertion_pass(cx, ctx, &list, budget, o.good, &mut stats.attempted)?;
}
let now = indicators(cx)?;
carried = Some(now);
@@ -548,7 +563,7 @@ mod tests {
let q = Point3::try_from(q).unwrap();
if let Some(Walk::Inside(at)) = cx.walk(t, &q, 1 << 12).unwrap() {
tried += 1;
assert!(!attempt_insert(&mut cx, &ctx, q, VertexKind::Interior, Site::Interior(at)).unwrap());
assert!(!attempt_insert(&mut cx, &ctx, &mut 0, SIN_40, q, VertexKind::Interior, Site::Interior(at)).unwrap());
}
}
}
+193 -6
View File
@@ -74,12 +74,41 @@ pub fn dihedral_cosines(x: &[P; 4]) -> [f64; 6] {
})
}
/// The quality objective: the smallest signed dihedral sine (1 is impossible; the regular
/// tetrahedron scores √(8/9) ≈ 0.943). Positive for a valid tetrahedron, 0 for a flat one,
/// negative for an inverted one (then the sine of largest magnitude, so worse inversions score
/// lower). With `sin θ_ij = 6V ℓ_ij / (2A_k · 2A_l)`, the extreme sine is found on squared
/// lengths and areas, with one square root.
/// The quality objective of a valid tetrahedron: the smaller of its smallest dihedral sine and
/// its weighted scaled Jacobian ([`JACOBIAN_WEIGHT`]; the regular tetrahedron scores
/// √(8/9) ≈ 0.943). The dihedral sine
/// catches slivers and caps; it does not see needles, whose dihedral angles can all be fine
/// while a corner is nearly flat (Shewchuk 2002), and the scaled Jacobian
/// `√2 · 6V / (ℓ₁ℓ₂ℓ₃)` at the worst corner, the measure analysts check (the skill's ≥ 0.2,
/// [`crate::quality`]), does. 0 for a flat tetrahedron; for an inverted one the negative sine of
/// largest magnitude, so worse inversions score lower. Extremes are found on squared lengths and
/// areas, with one square root each.
pub fn quality(x: &[P; 4]) -> f64 {
quality_in_order(&canonical_order(x))
}
/// The corners reordered by an even permutation that depends only on the corners' positions
/// (sorted by coordinates, the last two swapped back when the sort was odd), so a measure
/// computed on it gives the same bits for every listing of a tetrahedron with the same
/// orientation: a removal tried from either side of a ring sees the same qualities.
fn canonical_order(x: &[P; 4]) -> [P; 4] {
let less = |a: &P, b: &P| a[0] < b[0] || (a[0] == b[0] && (a[1] < b[1] || (a[1] == b[1] && a[2] < b[2])));
let mut y = *x;
let mut odd = false;
// A sorting network on four (pairs 01, 23, 02, 13, 12), counting swaps for the parity.
for (i, j) in [(0, 1), (2, 3), (0, 2), (1, 3), (1, 2)] {
if less(&y[j], &y[i]) {
y.swap(i, j);
odd = !odd;
}
}
if odd {
y.swap(2, 3);
}
y
}
fn quality_in_order(x: &[P; 4]) -> f64 {
let v6 = volume6(x);
if v6 == 0.0 || !v6.is_finite() {
return 0.0;
@@ -95,16 +124,131 @@ pub fn quality(x: &[P; 4]) -> f64 {
pick = if v6 > 0.0 { pick.min(r) } else { pick.max(r) };
}
let q = v6 * pick.sqrt();
let q = if v6 > 0.0 { q.min(JACOBIAN_WEIGHT * scaled_jacobian(x, v6)) } else { q };
if q.is_finite() { q } else { 0.0 }
}
/// The corners of a tetrahedron and the other three corners of each.
const CORNERS: [(usize, [usize; 3]); 4] = [(0, [1, 2, 3]), (1, [0, 2, 3]), (2, [0, 1, 3]), (3, [0, 1, 2])];
/// The squared product of the three edge lengths at each corner.
fn corner_products(x: &[P; 4]) -> [f64; 4] {
CORNERS.map(|(c, o)| {
o.iter().fold(1.0, |m, &j| {
let e = sub(x[j], x[c]);
m * dot(e, e)
})
})
}
/// The scaled Jacobian at the worst corner of a tetrahedron with six times the volume `v6`
/// (as [`crate::quality`] reports it).
fn scaled_jacobian(x: &[P; 4], v6: f64) -> f64 {
let worst = corner_products(x).iter().fold(0.0_f64, |m, &p| m.max(p));
std::f64::consts::SQRT_2 * v6 / worst.sqrt()
}
/// The weight of the scaled Jacobian in [`quality`]: the meshing skill's limit, a scaled
/// Jacobian of 0.3, counts as much as a 30° dihedral angle (sin 30°, the improvement's target,
/// [`super::improve::ImproveOptions::target`]), so the Jacobian decides only for tetrahedra near
/// that limit (needles) and the dihedral sine everywhere else.
pub const JACOBIAN_WEIGHT: f64 = 0.5 / 0.3;
/// [`quality`] and its gradient with respect to corner `i`, for a positive tetrahedron: the
/// gradient of the term [`quality`] picks (the smallest dihedral sine, or the scaled Jacobian at
/// the worst corner, `∇ ln J = ∇V₆ / V₆ − Σ ∇ℓ / ℓ` over that corner's edges), analytic, as Klingner and
/// Shewchuk (§3.1) and Freitag and Ollivier-Gooch use for smoothing. With
/// `s = V₆ ℓ / (N_k N_l)` (`N` twice a face's area),
/// `∇s = s (∇V₆ / V₆ + ∇ℓ / ℓ − ∇N_k / N_k − ∇N_l / N_l)`, where `∇V₆` at a corner is its
/// opposite face's area vector, `∇ℓ` the unit edge vector at an end of the edge and, for a face
/// `(p, q, r)` with unit normal `n̂`, `∇_p N = (q − r) × n̂`. A flat or inverted tetrahedron
/// gets a zero gradient (smoothing only moves within valid positions).
pub fn quality_gradient(x: &[P; 4], i: usize) -> (f64, P) {
let q = quality(x);
let v6 = volume6(x);
if !(v6 > 0.0 && q > 0.0) || i > 3 {
return (q, [0.0; 3]);
}
let crosses = [0, 1, 2, 3].map(|k| face_cross(x, k));
let n2 = crosses.map(|c| dot(c, c));
// The edge whose sine is smallest (the same comparison as `quality`).
let mut pick = (f64::INFINITY, EDGE_FACES[0]);
for e in EDGE_FACES {
let (a, b, k, l) = e;
let d = sub(x[a], x[b]);
let r = dot(d, d) / (n2[k] * n2[l]);
if r < pick.0 {
pick = (r, e);
}
}
// The scaled Jacobian branch, when it is the smaller.
let products = corner_products(x);
let (c, cp) = products.iter().enumerate().fold((0, 0.0_f64), |m, (c, &p)| if p > m.1 { (c, p) } else { m });
let sj = JACOBIAN_WEIGHT * std::f64::consts::SQRT_2 * v6 / cp.sqrt();
let sine = v6 * pick.0.sqrt();
let face = |k: usize| -> [usize; 3] {
match k {
0 => [1, 2, 3],
1 => [0, 2, 3],
2 => [0, 1, 3],
_ => [0, 1, 2],
}
};
// ∇V₆ at corner i: the area vector of the face opposite i, signed so that V₆ grows along it.
let gi = crosses[i];
let gv = if dot(gi, sub(x[i], x[face(i)[0]])) < 0.0 { scale(gi, -1.0) } else { gi };
if sj < sine {
// ∇ ln sj = ∇V₆ / V₆ − Σ ∇ℓ / ℓ over the corner's edges at i.
let (corner, others) = CORNERS[c];
let mut g = scale(gv, 1.0 / v6);
for j in others {
let (p, o) = if i == corner {
(x[i], x[j])
} else if i == j {
(x[i], x[corner])
} else {
continue;
};
let e = sub(p, o);
g = sub(g, scale(e, 1.0 / dot(e, e)));
}
let g = scale(g, q);
return if g.iter().all(|c| c.is_finite()) { (q, g) } else { (q, [0.0; 3]) };
}
let (a, b, k, l) = pick.1;
let len = dist(x[a], x[b]);
let gl = if i == a {
scale(sub(x[a], x[b]), 1.0 / len)
} else if i == b {
scale(sub(x[b], x[a]), 1.0 / len)
} else {
[0.0; 3]
};
// ∇N of the face opposite m at corner i (zero when the face does not hold i).
let gn = |m: usize| -> P {
let f = face(m);
let Some(pos) = f.iter().position(|&c| c == i) else { return [0.0; 3] };
let n = crosses[m];
let nn = norm(n);
if !(nn > 0.0) {
return [0.0; 3];
}
let (qv, rv) = (x[f[(pos + 1) % 3]], x[f[(pos + 2) % 3]]);
scale(cross(sub(qv, rv), n), 1.0 / nn)
};
let (nk, nl) = (n2[k].sqrt(), n2[l].sqrt());
let g = add(add(scale(gv, 1.0 / v6), scale(gl, 1.0 / len)), scale(add(scale(gn(k), 1.0 / nk), scale(gn(l), 1.0 / nl)), -1.0));
let g = scale(g, q);
if g.iter().all(|c| c.is_finite()) { (q, g) } else { (q, [0.0; 3]) }
}
/// The smallest and largest dihedral angle in degrees (reports only).
pub fn dihedral_range(x: &[P; 4]) -> (f64, f64) {
let (s, c) = (dihedral_sines(x), dihedral_cosines(x));
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for (sv, cv) in s.iter().zip(c) {
let a = sv.abs().atan2(cv).to_degrees();
let a = crate::vec3::atan2(sv.abs(), cv).to_degrees();
lo = lo.min(a);
hi = hi.max(a);
}
@@ -221,6 +365,49 @@ mod tests {
assert!(circumcenter(&flat).is_none());
}
/// Every even reordering of the corners gives the same quality, bit for bit.
#[test]
fn quality_does_not_depend_on_the_corner_order() {
let x = positive([[0.1, 0.0, 0.05], [1.0, 0.2, 0.0], [0.3, 0.9, 0.1], [0.4, 0.35, 0.7]]);
let q = quality(&x);
for p in [[1, 2, 0, 3], [2, 0, 1, 3], [0, 2, 3, 1], [3, 1, 0, 2], [1, 0, 3, 2], [2, 3, 0, 1]] {
assert_eq!(quality(&p.map(|i| x[i])).to_bits(), q.to_bits(), "{p:?}");
}
}
/// A needle: every dihedral angle above 20°, yet one corner nearly flat; the objective sees
/// it through the scaled Jacobian.
#[test]
fn a_needle_scores_low() {
let x = positive([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 0.866, 0.0], [0.5, 0.29, 12.0]]);
let (lo, hi) = dihedral_range(&x);
assert!(lo > 20.0 && hi < 120.0, "{lo} {hi}");
assert!(quality(&x) < 0.15 * JACOBIAN_WEIGHT, "{}", quality(&x));
}
/// The analytic gradient matches central differences of the quality away from ties, on
/// either branch.
#[test]
fn quality_gradient_matches_central_differences() {
let sliverish = positive([[0.1, 0.0, 0.05], [1.0, 0.2, 0.0], [0.3, 0.9, 0.1], [0.4, 0.35, 0.7]]);
let needle = positive([[0.0, 0.0, 0.0], [1.0, 0.1, 0.0], [0.45, 0.85, 0.05], [0.55, 0.3, 6.0]]);
for x in [sliverish, needle] {
for i in 0..4 {
let (q, g) = quality_gradient(&x, i);
assert_eq!(q, quality(&x));
let h = 1e-7;
for c in 0..3 {
let (mut a, mut b) = (x, x);
a[i][c] += h;
b[i][c] -= h;
let fd = (quality(&a) - quality(&b)) / (2.0 * h);
assert!((fd - g[c]).abs() < 1e-6, "corner {i} axis {c}: {fd} vs {}", g[c]);
}
}
}
assert_eq!(quality_gradient(&[[0.0; 3]; 4], 0).1, [0.0; 3]);
}
#[test]
fn sliver_scores_low() {
let x = positive([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.01]]);
+39 -4
View File
@@ -33,6 +33,7 @@
// clearer than iterator chains (as in the fem and solver crates).
#![allow(clippy::needless_range_loop)]
pub mod canonical;
pub mod complex;
pub mod geometry;
pub mod improve;
@@ -97,6 +98,9 @@ pub struct QualityOptions {
/// grows from it at the grading rate. A seed finer than the global size refines along its
/// edge; one coarser leaves it to the global size.
pub seeds: Vec<crate::seeds::EdgeSeed>,
/// Local sizes (size controls): the size field is at most each one's size in its region and
/// grows from it at the grading rate.
pub locals: Vec<crate::size::LocalSize>,
/// B-rep face tags whose boundary triangles are kept exactly: their vertices stay where they
/// are and no triangle of theirs is split, flipped or removed. The faces a pyramid
/// transition shares with the tetrahedra ([`crate::transition`]) are frozen, so the two
@@ -122,6 +126,7 @@ impl Default for QualityOptions {
max_tets: 5_000_000,
max_points: 1_000_000,
seeds: Vec::new(),
locals: Vec::new(),
frozen_faces: Vec::new(),
}
}
@@ -237,7 +242,8 @@ fn bounds(points: &[[f64; 3]]) -> ([f64; 3], [f64; 3]) {
(lo, hi)
}
/// The size field of a boundary: `global` limited by curvature, graded.
/// The size field of a boundary: `global` limited by curvature, thin walls, edge seeds and local
/// sizes, graded.
pub fn size_field(surface: &BoundarySurface, geometry: &Tessellation, global: f64, o: &QualityOptions) -> GradedSize {
let (lo, hi) = bounds(surface.points());
let floor = o.min_size * global;
@@ -268,7 +274,7 @@ pub fn size_field(surface: &BoundarySurface, geometry: &Tessellation, global: f6
}
}
}
GradedSize::new(global, &samples, lo, hi, o.grading)
GradedSize::new(global, &samples, &o.locals, lo, hi, o.grading)
}
/// Put the seeds' nodes on their feature curves, pinned (corner vertices, which neither
@@ -553,6 +559,26 @@ pub fn quality_mesh(surface: &BoundarySurface, cdt: &TetMesh, analytic: Vec<Opti
}
}
/// The tetrahedra (1-based) that share none of their faces with another one, in a mesh of more
/// than one: each is a separate piece of boundary, joined to the mesh by vertices or an edge.
fn detached_tets(tets: &[[u32; 4]]) -> Vec<u32> {
if tets.len() < 2 {
return Vec::new();
}
let face = |t: &[u32; 4], k: usize| {
let mut f = [t[(k + 1) % 4], t[(k + 2) % 4], t[(k + 3) % 4]];
f.sort_unstable();
f
};
let mut count: std::collections::BTreeMap<[u32; 3], u32> = std::collections::BTreeMap::new();
for t in tets {
for k in 0..4 {
*count.entry(face(t, k)).or_default() += 1;
}
}
tets.iter().enumerate().filter(|(_, t)| (0..4).all(|k| count.get(&face(t, k)).copied().unwrap_or(0) < 2)).map(|(i, _)| i as u32 + 1).collect()
}
/// The constrained Delaunay tetrahedralization itself as a [`QualityMesh`] (no quality stage),
/// through the same validity gate: every tetrahedron admissible, the volume equal to what its
/// boundary encloses.
@@ -570,9 +596,18 @@ pub fn raw_mesh(surface: &BoundarySurface, cdt: &TetMesh) -> Result<QualityMesh>
Ok(QualityMesh { points: cdt.points.clone(), elements: cdt.tets.iter().map(|t| t.to_vec()).collect(), kind: ElementKind::Tet4, boundary, report })
}
/// The validity gate of a linear mesh: every tetrahedron admissible, and the volume equal to
/// what its boundary encloses within 1e-9 (no overlap, no gap).
/// The validity gate of a linear mesh: every tetrahedron admissible, joined to the others by a
/// face (when there are several), and the volume equal to what its boundary encloses within
/// 1e-9 (no overlap, no gap).
pub fn linear_gate(points: &[[f64; 3]], tets: &[[u32; 4]], boundary_volume: f64) -> Result<GateReport> {
let detached = detached_tets(tets);
if !detached.is_empty() {
let list = detached.iter().take(10).map(u32::to_string).collect::<Vec<_>>().join(", ");
return Err(Error::Failed(format!(
"mesh validity gate: {} tetrahedra share no face with the rest of the mesh ({list}): held by vertices alone, they would make the stiffness singular",
detached.len()
)));
}
let coords = |t: &[u32; 4]| -> Result<[[f64; 3]; 4]> {
let p = |v: u32| points.get(v as usize).copied().ok_or_else(|| Error::Failed("gate: vertex id".into()));
Ok([p(t[0])?, p(t[1])?, p(t[2])?, p(t[3])?])
+93 -46
View File
@@ -31,7 +31,7 @@ pub fn tet_quality(cx: &TetComplex, vs: [u32; 4]) -> Result<f64> {
pub fn worst(cx: &TetComplex, tets: &[u32]) -> Result<f64> {
let mut q = f64::INFINITY;
for &t in tets {
q = q.min(tet_quality(cx, cx.tet(t)?)?);
q = q.min(cx.slot_quality(t)?);
}
Ok(q)
}
@@ -47,13 +47,17 @@ pub const MAX_RING: usize = 10;
pub const BOUNDARY_FLIP_COS: f64 = 0.939_692_620_785_908_4;
/// Klincsek's table: the best worst quality of a triangulation of the chain `v_i … v_j`
/// (with the closing edge `v_i v_j`), and the apex `k` that achieves it.
fn klincsek(m: usize, q: &mut dyn FnMut(usize, usize, usize) -> Result<f64>) -> Result<(Vec<f64>, Vec<usize>)> {
/// (with the closing edge `v_i v_j`), and the apex `k` that achieves it. Only triangulations
/// better than `floor` matter: an entry that cannot beat it holds `floor`, and a triangle is
/// evaluated only when its two sub-chains beat it, which skips most of the table when the
/// removal cannot pay off (the decision and the triangulation chosen are the same as without
/// the floor).
fn klincsek(m: usize, floor: f64, q: &mut dyn FnMut(usize, usize, usize) -> Result<f64>) -> Result<(Vec<f64>, Vec<usize>)> {
let mut best = vec![f64::INFINITY; m * m];
let mut arg = vec![0usize; m * m];
for i in (0..m.saturating_sub(2)).rev() {
for j in i + 2..m {
let mut b = f64::NEG_INFINITY;
let mut b = floor;
let mut a = i + 1;
for k in i + 1..j {
let sub = best.get(i * m + k).copied().unwrap_or(f64::NEG_INFINITY).min(best.get(k * m + j).copied().unwrap_or(f64::NEG_INFINITY));
@@ -115,12 +119,31 @@ pub fn remove_edge_at(cx: &mut TetComplex, t: u32, a: u32, b: u32) -> Result<boo
if cx.curve(a, b).is_some() || !vs.contains(&a) || !vs.contains(&b) {
return Ok(false);
}
// A removal that failed fails again until a tetrahedron around the edge changes.
if cx.edge_known_to_fail(a, b) {
return Ok(false);
}
let removed = remove_edge_ring(cx, t, a, b)?;
if !removed {
cx.mark_edge_failed(a, b);
}
Ok(removed)
}
/// [`remove_edge_at`] past its checks.
fn remove_edge_ring(cx: &mut TetComplex, t: u32, a: u32, b: u32) -> Result<bool> {
let Some(ring) = cx.edge_ring_at(t, a, b)? else { return Ok(false) };
let m = ring.verts.len();
if m < 3 || m > MAX_RING + usize::from(!ring.closed) {
return Ok(false);
}
let v = ring.verts.clone();
let mut v = ring.verts.clone();
if ring.closed {
// Start a closed ring at its smallest vertex, so the triangulation chosen does not
// depend on the tetrahedron the ring was walked from.
let first = v.iter().enumerate().min_by_key(|(_, id)| **id).map_or(0, |(i, _)| i);
v.rotate_left(first);
}
let x = |id: u32| cx.xyz(id);
let (xa, xb) = (x(a)?, x(b)?);
// An open ring runs from boundary face (a, b, v_0) to (a, b, v_{m−1}): both must lie on one
@@ -150,7 +173,7 @@ pub fn remove_edge_at(cx: &mut TetComplex, t: u32, a: u32, b: u32) -> Result<boo
Ok(measure::quality(&[pi, pj, pk, xa]).min(measure::quality(&[pi, pk, pj, xb])))
};
// Triangulate the polygon v_0 … v_{m−1}, closed by the edge v_{m−1} v_0.
let (best, arg) = klincsek(m, &mut q)?;
let (best, arg) = klincsek(m, before, &mut q)?;
let total = best.get(m - 1).copied().unwrap_or(f64::NEG_INFINITY);
if !(total > before) {
return Ok(false);
@@ -199,37 +222,59 @@ pub fn remove_face(cx: &mut TetComplex, t: u32, k: usize) -> Result<bool> {
let us = cx.tet(u)?;
let face = face_toward(vs, k);
let b = us.iter().copied().find(|v| !face.contains(v)).unwrap_or(NONE);
if b == NONE || cx.curve(a, b).is_some() || cx.edge_tet(a, b)?.is_some() {
if b == NONE || cx.curve(a, b).is_some() || cx.face_known_to_fail(a, b, face) || cx.edge_tet(a, b)?.is_some() {
return Ok(false);
}
let removed = remove_faces_between(cx, t, u, a, b, face)?;
if !removed {
cx.mark_face_failed(a, b, face);
}
Ok(removed)
}
/// [`remove_face`] past its checks: tetrahedra `t` (apex `a`) and `u` (apex `b`) share `face`.
fn remove_faces_between(cx: &mut TetComplex, t: u32, u: u32, a: u32, b: u32, face: [u32; 3]) -> Result<bool> {
// Sandwiched faces between a and b: faces f with tetrahedra (a ∪ f) and (b ∪ f). Grow the
// set from the given face across shared edges while every new tetrahedron (a, b, e) on the
// ring stays better than the worst removed one; take the best prefix found (a greedy form of
// Shewchuk's optimal search, which visits the same candidates).
let mut faces: Vec<[u32; 3]> = vec![face];
// Each face with the tetrahedron (a ∪ face).
let mut faces: Vec<([u32; 3], u32)> = vec![(face, t)];
let mut old: Vec<u32> = vec![t, u];
let mut best: Option<(f64, Vec<u32>, Vec<[u32; 4]>)> = None;
let mut removed_q = worst(cx, &old)?;
let mut made: IdMap<(u32, u32), ([u32; 4], f64)> = IdMap::default();
for _round in 0..8 {
let removed_q = worst(cx, &old)?;
// The boundary edges of the face set (edges in exactly one face), oriented as in the face
// seen from a: each gives the new tetrahedron (a, b, e) positive.
let mut count: IdMap<(u32, u32), (u32, u32, usize)> = IdMap::default();
for f in &faces {
// seen from a, with the face's tetrahedron on a's side: each gives the new tetrahedron
// (a, b, e) positive.
let mut count: IdMap<(u32, u32), (u32, u32, u32, usize)> = IdMap::default();
for &(f, ta) in &faces {
for i in 0..3 {
let (p, q) = (f[i], f[(i + 1) % 3]);
let e = count.entry(sorted2(p, q)).or_insert((p, q, 0));
e.2 += 1;
let e = count.entry(sorted2(p, q)).or_insert((p, q, ta, 0));
e.3 += 1;
}
}
let mut ring: Vec<(u32, u32)> = count.values().filter(|e| e.2 == 1).map(|e| (e.0, e.1)).collect();
let mut ring: Vec<(u32, u32, u32)> = count.values().filter(|e| e.3 == 1).map(|e| (e.0, e.1, e.2)).collect();
ring.sort_unstable();
let mut new = Vec::with_capacity(ring.len());
let mut q = f64::INFINITY;
for &(p, r) in &ring {
// face (p, r, x) has a on its positive side, so (p, r, a, b)… orient explicitly.
let cand = [p, r, b, a];
let cand = if cx.orient(cand)? == Sign::Positive { cand } else { [r, p, b, a] };
q = q.min(tet_quality(cx, cand)?);
for &(p, r, _) in &ring {
// A ring edge stays on the ring until a face grows across it: its tetrahedron is
// worked out once.
let (cand, cq) = match made.get(&(p, r)) {
Some(&c) => c,
None => {
// face (p, r, x) has a on its positive side, so (p, r, a, b)… orient explicitly.
let cand = [p, r, b, a];
let cand = if cx.orient(cand)? == Sign::Positive { cand } else { [r, p, b, a] };
let c = (cand, tet_quality(cx, cand)?);
made.insert((p, r), c);
c
}
};
q = q.min(cq);
new.push(cand);
}
if q > removed_q && best.as_ref().is_none_or(|(bq, _, _)| q > *bq) {
@@ -237,11 +282,12 @@ pub fn remove_face(cx: &mut TetComplex, t: u32, k: usize) -> Result<bool> {
}
// Grow: a sandwiched neighbour face across a ring edge.
let mut grown = false;
for &(p, r) in &ring {
if let Some((f, ta, tb)) = sandwiched_across(cx, a, b, p, r, &faces)? {
faces.push(f);
old.push(ta);
old.push(tb);
for &(p, r, ta) in &ring {
if let Some((f, na, nb)) = sandwiched_across(cx, a, b, p, r, ta, &faces)? {
faces.push((f, na));
old.push(na);
old.push(nb);
removed_q = removed_q.min(worst(cx, &[na, nb])?);
grown = true;
break;
}
@@ -257,27 +303,28 @@ pub fn remove_face(cx: &mut TetComplex, t: u32, k: usize) -> Result<bool> {
/// A face `(p, r, x)` sandwiched between `a` and `b` (tetrahedra `a ∪ f` and `b ∪ f` both exist)
/// next to edge `pr`, not already in `faces`.
fn sandwiched_across(cx: &mut TetComplex, a: u32, b: u32, p: u32, r: u32, faces: &[[u32; 3]]) -> Result<Option<([u32; 3], u32, u32)>> {
let Some(ring) = cx.edge_ring(p, r)? else { return Ok(None) };
// Tetrahedra around pr containing a, and containing b.
for &ta in &ring.tets {
let va = cx.tet(ta)?;
if !va.contains(&a) {
continue;
}
let x = va.iter().copied().find(|&v| v != a && v != p && v != r).unwrap_or(NONE);
if x == NONE || x == b {
continue;
}
let f = [p, r, x];
if faces.iter().any(|g| sorted3(*g) == sorted3(f)) {
continue;
}
let k = va.iter().position(|&v| v == a).unwrap_or(0);
let tb = cx.adj(ta)?.get(k).copied().unwrap_or(NONE);
if tb != NONE && cx.tet(tb)?.contains(&b) {
return Ok(Some((f, ta, tb)));
}
fn sandwiched_across(cx: &TetComplex, a: u32, b: u32, p: u32, r: u32, ta: u32, faces: &[([u32; 3], u32)]) -> Result<Option<([u32; 3], u32, u32)>> {
// ta = (a, p, r, y) holds the face set's face on edge pr; the other tetrahedron of a around
// pr in a face set's neighbourhood lies across its face (a, p, r).
let va = cx.tet(ta)?;
let Some(ky) = va.iter().position(|&v| v != a && v != p && v != r) else { return Ok(None) };
let n = cx.adj(ta)?.get(ky).copied().unwrap_or(NONE);
if n == NONE {
return Ok(None);
}
let vn = cx.tet(n)?;
let x = vn.iter().copied().find(|&v| v != a && v != p && v != r).unwrap_or(NONE);
if x == NONE || x == b || !vn.contains(&a) {
return Ok(None);
}
let f = [p, r, x];
if faces.iter().any(|(g, _)| sorted3(*g) == sorted3(f)) {
return Ok(None);
}
let Some(k) = vn.iter().position(|&v| v == a) else { return Ok(None) };
let tb = cx.adj(n)?.get(k).copied().unwrap_or(NONE);
if tb != NONE && cx.tet(tb)?.contains(&b) {
return Ok(Some((f, n, tb)));
}
Ok(None)
}
+60 -14
View File
@@ -207,6 +207,42 @@ impl LocalStar {
measure::quality(&y)
}
/// The quality of tetrahedron `k` with the vertex at `p`, and its gradient there: analytic
/// for a valid tetrahedron, central differences with step `delta` for a flat or inverted one
/// (whose signed measure has no single smooth branch).
fn quality_gradient_at(&self, k: usize, p: P, delta: f64) -> (f64, P) {
let Some((x, i)) = self.tets.get(k) else { return (f64::NEG_INFINITY, [0.0; 3]) };
let mut y = *x;
if let Some(s) = y.get_mut(*i) {
*s = p;
}
let (q, g) = measure::quality_gradient(&y, *i);
if q > 0.0 {
return (q, g);
}
let mut g = [0.0; 3];
for (c, gc) in g.iter_mut().enumerate() {
let (mut a, mut b) = (p, p);
a[c] += delta;
b[c] -= delta;
*gc = (self.quality_at(k, a) - self.quality_at(k, b)) / (2.0 * delta);
}
(q, g)
}
/// The worst quality at `p` over the tetrahedra in `order`, or the first one found that is
/// not above `floor` (then the move is refused whatever the others are).
fn worst_above(&self, order: &[usize], p: P, floor: f64) -> f64 {
let mut w = f64::INFINITY;
for &k in order {
w = w.min(self.quality_at(k, p));
if !(w > floor) {
return w;
}
}
w
}
fn worst_at(&self, p: P) -> f64 {
(0..self.tets.len()).map(|k| self.quality_at(k, p)).fold(f64::INFINITY, f64::min)
}
@@ -215,7 +251,15 @@ impl LocalStar {
/// Optimization-based smoothing of `v`: raise the worst quality of its star (see the module
/// docs). Returns whether it moved.
pub fn smooth_vertex(cx: &mut TetComplex, ctx: &Context, v: u32) -> Result<bool> {
if !cx.is_used(v) || cx.kind(v) == VertexKind::Corner {
smooth_vertex_below(cx, ctx, v, f64::INFINITY)
}
/// [`smooth_vertex`], only when the worst tetrahedron around `v` is below `good` (Klingner and
/// Shewchuk smooth only around tetrahedra below their threshold).
pub fn smooth_vertex_below(cx: &mut TetComplex, ctx: &Context, v: u32, good: f64) -> Result<bool> {
// The optimization is a function of the star alone: one that found no move finds none
// again until a tetrahedron around `v` changes.
if !cx.is_used(v) || cx.kind(v) == VertexKind::Corner || cx.smoothing_known_to_fail(v) {
return Ok(false);
}
let star = cx.star(v)?;
@@ -237,25 +281,19 @@ pub fn smooth_vertex(cx: &mut TetComplex, ctx: &Context, v: u32) -> Result<bool>
if !(len > 0.0 && len.is_finite()) {
return Ok(false);
}
let delta = 1e-6 * len;
let mut x = x0;
let mut worst = local.worst_at(x);
if !(worst < good) {
return Ok(false);
}
let mut moved = false;
for _ in 0..8 {
// Qualities and gradients (central differences) of the tetrahedra near the worst.
// Qualities and analytic gradients of the tetrahedra near the worst.
let mut qs = Vec::with_capacity(local.tets.len());
for k in 0..local.tets.len() {
let q = local.quality_at(k, x);
let mut g = [0.0; 3];
if q <= worst + 0.1 {
for (c, gc) in g.iter_mut().enumerate() {
let (mut a, mut b) = (x, x);
a[c] += delta;
b[c] -= delta;
*gc = (local.quality_at(k, a) - local.quality_at(k, b)) / (2.0 * delta);
}
}
qs.push((q, restrict(g, f)));
let g = if q <= worst + 0.1 { restrict(local.quality_gradient_at(k, x, 1e-6 * len).1, f) } else { [0.0; 3] };
qs.push((q, g));
}
let active: Vec<P> = qs.iter().filter(|s| s.0 <= worst + 1e-4).map(|s| s.1).collect();
let d = min_norm(&active);
@@ -272,10 +310,15 @@ pub fn smooth_vertex(cx: &mut TetComplex, ctx: &Context, v: u32) -> Result<bool>
alpha = alpha.min((q - worst) / (dd - gd));
}
}
// Trial positions check the worst tetrahedra first and stop at the first that does not
// beat the current worst.
let mut order: Vec<usize> = (0..qs.len()).collect();
let q_of = |i: usize| qs.get(i).map_or(f64::INFINITY, |s| s.0);
order.sort_by(|&i, &j| q_of(i).total_cmp(&q_of(j)).then(i.cmp(&j)));
let mut accepted = false;
for _ in 0..6 {
let Some(p) = ctx.place(cx.kind(v), add(x, scale(d, alpha))) else { break };
let q = local.worst_at(p.coords());
let q = local.worst_above(&order, p.coords(), worst);
if p.coords() != x && q > worst && cx.move_is_valid(v, &p, &star)? {
cx.move_point(v, p)?;
x = p.coords();
@@ -290,5 +333,8 @@ pub fn smooth_vertex(cx: &mut TetComplex, ctx: &Context, v: u32) -> Result<bool>
break;
}
}
if !moved {
cx.mark_smoothing_failed(v);
}
Ok(moved)
}
+217
View File
@@ -349,3 +349,220 @@ mod pipeline {
}
}
}
/// Determinism (#115): the quality mesh is a function of the geometry, the same bit for bit on
/// every platform, run and listing order of the input.
mod determinism {
use crate::cdt::{CdtOptions, tetrahedralize};
use crate::surface::{BoundarySurface, SurfaceOptions};
use crate::tet::canonical::CanonicalSoup;
use crate::tet::{Order, QualityMesh, QualityOptions, quality_mesh};
/// cos and sin of k · 15° from square roots only (correctly rounded everywhere), so the
/// test input itself is the same on every platform.
fn unit_24(k: usize) -> [f64; 2] {
let (s2, s3, s6) = (2f64.sqrt(), 3f64.sqrt(), 6f64.sqrt());
let c = [1.0, (s6 + s2) / 4.0, s3 / 2.0, s2 / 2.0, 0.5, (s6 - s2) / 4.0, 0.0];
// Quadrant q, step j: cos(90° q + 15° j).
let (q, j) = (k / 6 % 4, k % 6);
let (x, y) = (c[j], c[6 - j]);
match q {
0 => [x, y],
1 => [-y, x],
2 => [-x, -y],
_ => [y, -x],
}
}
/// A washer (inner radius 6, outer 15, height 4) as a 24-sided prism ring: faces bottom 0,
/// top 1, inside 2, outside 3; triangles oriented outward.
pub(crate) fn washer() -> (Vec<[f64; 3]>, Vec<[u32; 3]>, Vec<u32>) {
const N: usize = 24;
let mut p = Vec::new();
for (r, z) in [(6.0, 0.0), (15.0, 0.0), (6.0, 4.0), (15.0, 4.0)] {
for k in 0..N {
let [c, s] = unit_24(k);
p.push([r * c, r * s, z]);
}
}
let id = |ring: usize, k: usize| (ring * N + k % N) as u32;
let (mut t, mut f) = (Vec::new(), Vec::new());
for k in 0..N {
// (ring a, ring b): quad a_k b_k b_k+1 a_k+1, outward when listed this way.
for (a, b, tag) in [(1, 0, 0), (2, 3, 1), (0, 2, 2), (3, 1, 3)] {
let q = [id(a, k), id(b, k), id(b, k + 1), id(a, k + 1)];
t.push([q[0], q[1], q[2]]);
t.push([q[0], q[2], q[3]]);
f.extend([tag, tag]);
}
}
(p, t, f)
}
fn mesh(p: &[[f64; 3]], t: &[[u32; 3]], f: &[u32]) -> QualityMesh {
let soup = CanonicalSoup::new(p, t, f).unwrap();
let s = BoundarySurface::new(&soup.positions, &soup.triangles, &soup.tri_face, &SurfaceOptions::default()).unwrap();
assert!(s.volume() > 0.0);
let cdt = tetrahedralize(&s, &CdtOptions::default()).unwrap();
let mut m =
quality_mesh(&s, &cdt, Vec::new(), &QualityOptions { size: Some(3.0), order: Order::Quadratic, ..QualityOptions::default() }).unwrap();
soup.restore_tags(&mut m);
m
}
/// FNV-1a over every coordinate's bits, the elements and the tagged boundary faces.
pub(crate) fn fingerprint(m: &QualityMesh) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64;
let mut eat = |x: u64| {
for b in x.to_le_bytes() {
h = (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3);
}
};
m.points.iter().flatten().for_each(|c| eat(c.to_bits()));
m.elements.iter().flatten().for_each(|&v| eat(u64::from(v)));
m.boundary.iter().for_each(|b| eat(u64::from(b.tet) << 40 | u64::from(b.opposite) << 32 | u64::from(b.face)));
h
}
/// The washer's tet10 mesh has one fingerprint on every platform (CI checks it on Linux
/// x86-64, macOS arm64 and Windows): only correctly rounded arithmetic and the crate's own
/// `atan2` and `cbrt` make its decisions, and no hash map is iterated into it. A change that
/// moves the tet mesh on purpose updates the constant.
#[test]
fn the_quality_mesh_is_the_same_on_every_platform() {
let (p, t, f) = washer();
let m = mesh(&p, &t, &f);
assert!(m.elements.len() > 100, "{:?}", m.report);
// No needles either: the scaled Jacobian analysts check is above the skill's limit.
let sj = m
.elements
.iter()
.map(|e| {
let xyz: Vec<[f64; 3]> = e.iter().map(|&v| m.points[v as usize]).collect();
crate::quality::element_quality(m.kind, &xyz).unwrap().get(crate::quality::Metric::ScaledJacobian).unwrap()
})
.fold(f64::INFINITY, f64::min);
assert!(sj >= 0.3, "min scaled Jacobian {sj}");
assert_eq!(fingerprint(&m), 13_918_536_535_603_991_107, "{} tets, {} nodes", m.elements.len(), m.points.len());
}
/// Listing the triangles, their corners and the vertices in another order, and numbering
/// the faces differently, gives the same mesh with the faces' own tags.
#[test]
fn the_quality_mesh_does_not_depend_on_the_input_order() {
let (p, t, f) = washer();
let a = mesh(&p, &t, &f);
let n = p.len() as u32;
let q: Vec<[f64; 3]> = p.iter().rev().copied().collect();
let u: Vec<[u32; 3]> = t.iter().rev().map(|x| [n - 1 - x[1], n - 1 - x[2], n - 1 - x[0]]).collect();
let g: Vec<u32> = f.iter().rev().map(|&x| 10 + 3 - x).collect();
let b = mesh(&q, &u, &g);
assert_eq!((&a.points, &a.elements), (&b.points, &b.elements));
assert_eq!(a.boundary.len(), b.boundary.len());
for (x, y) in a.boundary.iter().zip(&b.boundary) {
assert_eq!((x.tet, x.opposite, 10 + 3 - x.face), (y.tet, y.opposite, y.face));
}
}
}
/// A cap tetrahedron (two boundary faces of one B-rep face) whose two other neighbours each have
/// three boundary faces: deleting it would leave them joined to nothing by a face, floating free
/// (a singular stiffness: the dome-on-a-plate mesh on Linux, #438), so it stays.
#[test]
fn deleting_a_cap_never_leaves_a_neighbour_floating() {
let p: Vec<[f64; 3]> = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.3, 0.3, -1.0], [1.0, 1.0, 1.0]];
let pts: Vec<Point3> = p.iter().map(|c| Point3::try_from(*c).unwrap()).collect();
let positive = |t: [u32; 4]| if measure::volume6(&t.map(|v| pts[v as usize].coords())) > 0.0 { t } else { [t[1], t[0], t[2], t[3]] };
// The cap, and a tetrahedron on each of its faces 012 and 123.
let tets = [positive([0, 1, 2, 3]), positive([0, 1, 2, 4]), positive([1, 2, 3, 5])];
let key = |f: [u32; 3]| {
let mut f = f;
f.sort_unstable();
f
};
let mut boundary = Vec::new();
for (t, vs) in tets.iter().enumerate() {
for k in 0..4 {
let f = key(face_toward(*vs, k));
let shared = tets.iter().enumerate().any(|(u, ws)| u != t && (0..4).any(|j| key(face_toward(*ws, j)) == f));
if !shared {
boundary.push((t as u32, k, 0));
}
}
}
let mut cx = TetComplex::new(&p, vec![VertexKind::Corner; 6], &tets, &boundary, &[], 100, 100).unwrap();
assert!(!cx.delete_boundary_tet(0).unwrap(), "the cap was deleted, leaving its neighbours floating");
assert_eq!(cx.live_tets().count(), 3);
cx.validate().unwrap();
}
/// The validity gate refuses a tetrahedron that shares no face with the rest of the mesh, even
/// when the volume adds up: it says why instead of leaving the solver to find it singular.
#[test]
fn the_gate_refuses_a_detached_tetrahedron() {
let p = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 1.0, 1.0], [2.0, 1.0, 1.0], [1.0, 2.0, 1.0], [1.0, 1.0, 2.0]];
let tets = [[0, 1, 2, 3], [4, 5, 6, 7]];
let v = 2.0 / 6.0;
let e = super::linear_gate(&p, &tets, v).unwrap_err().to_string();
assert!(e.contains("share no face") && e.contains("(1, 2)"), "{e}");
assert!(super::linear_gate(&p, &tets[..1], v / 2.0).is_ok());
}
/// Undoing an edge removal or a face removal puts every slot back as it was.
#[test]
fn rollback_restores_every_slot() {
use crate::cdt::{CdtOptions, tetrahedralize};
use crate::surface::{BoundarySurface, SurfaceOptions};
let (p, t, f) = determinism::washer();
let s = BoundarySurface::new(&p, &t, &f, &SurfaceOptions::default()).unwrap();
let cdt = tetrahedralize(&s, &CdtOptions::default()).unwrap();
let (mut cx, _) = crate::tet::complex_from_cdt(&s, &cdt, &crate::tet::QualityOptions::default()).unwrap();
let state = |cx: &TetComplex| -> Vec<([u32; 4], [u32; 4], [u32; 4])> {
(0..cx.slots() as u32).map(|t| (cx.tet(t).unwrap(), cx.adj(t).unwrap(), cx.tags(t).unwrap())).collect()
};
let mut changed = 0;
for (t, vs) in cx.live_tets().collect::<Vec<_>>() {
for &(i, j) in &EDGES {
for op in 0..2 {
let before = state(&cx);
cx.journal.recording = true;
let len = cx.journal.steps.len();
let did = if op == 0 { remove_edge_at(&mut cx, t, vs[i], vs[j]).unwrap() } else { remove_face(&mut cx, t, i).unwrap() };
changed += usize::from(did);
cx.rollback(len).unwrap();
cx.journal.steps.truncate(len);
cx.journal.recording = false;
assert_eq!(state(&cx), before, "tet {t} edge {i}{j} op {op}");
}
}
}
assert!(changed > 0);
}
/// The cached qualities match a fresh computation after improvement (smoothing, flips,
/// insertions and the rollbacks of rejected insertions).
#[test]
fn cached_qualities_match_fresh_ones() {
use crate::cdt::{CdtOptions, tetrahedralize};
use crate::size::UniformSize;
use crate::surface::{BoundarySurface, SurfaceOptions};
use crate::tet::geometry::Tessellation;
use crate::tet::refine::Context;
let (p, t, f) = determinism::washer();
let s = BoundarySurface::new(&p, &t, &f, &SurfaceOptions::default()).unwrap();
let cdt = tetrahedralize(&s, &CdtOptions::default()).unwrap();
let (mut cx, curve_faces) = crate::tet::complex_from_cdt(&s, &cdt, &crate::tet::QualityOptions::default()).unwrap();
let (geometry, size) = (Tessellation::new(&s, Vec::new()), UniformSize(3.0));
let ctx = Context { size: &size, geometry: &geometry, curve_faces: &curve_faces };
// Fill the cache, then change the mesh.
for (t, _) in cx.live_tets().collect::<Vec<_>>() {
cx.slot_quality(t).unwrap();
}
let o = crate::tet::improve::ImproveOptions { max_passes: 3, ..Default::default() };
let stats = crate::tet::improve::improve(&mut cx, &ctx, &o).unwrap();
assert!(stats.attempted > 0 && stats.smoothed > 0, "{stats:?}");
for (t, vs) in cx.live_tets().collect::<Vec<_>>() {
assert_eq!(cx.slot_quality(t).unwrap().to_bits(), measure::quality(&cx.coords(vs).unwrap()).to_bits());
}
cx.validate().unwrap();
}
+80 -1
View File
@@ -21,7 +21,59 @@ pub(crate) fn det(a: P, b: P, c: P) -> f64 {
/// The angle between two vectors in degrees (0 when either is zero).
pub(crate) fn angle_deg(a: P, b: P) -> f64 {
norm(cross(a, b)).atan2(dot(a, b)).to_degrees()
atan2(norm(cross(a, b)), dot(a, b)).to_degrees()
}
/// `atan(t)` for `0 ≤ t ≤ 1` from `+ − × ÷ √` only: two half-angle reductions
/// (`atan t = 2 atan(t / (1 + √(1 + t²)))`) bring `t` below tan(π/16) ≈ 0.199, where the Taylor
/// series converges to double precision in 20 terms.
fn atan_unit(t: f64) -> f64 {
let mut t = t;
for _ in 0..2 {
t /= 1.0 + (1.0 + t * t).sqrt();
}
let (t2, mut term, mut sum) = (t * t, t, 0.0);
for k in 0..20 {
let s = term / (2 * k + 1) as f64;
sum += if k % 2 == 0 { s } else { -s };
term *= t2;
}
4.0 * sum
}
/// The angle of `(x, y)` in radians, in `(−π, π]`, like [`f64::atan2`] but the same bit for bit
/// on every platform: the platform maths libraries' `atan2` differ in the last bits (macOS and
/// glibc), and a mesh or report must not (AGENTS.md, Determinism). 0 for `(0, 0)` and for
/// non-finite input.
pub(crate) fn atan2(y: f64, x: f64) -> f64 {
use std::f64::consts::{FRAC_PI_2, PI};
if !(x.is_finite() && y.is_finite()) || (x == 0.0 && y == 0.0) {
return 0.0;
}
let (ax, ay) = (x.abs(), y.abs());
let a = if ay <= ax { atan_unit(ay / ax) } else { FRAC_PI_2 - atan_unit(ax / ay) };
let a = if x < 0.0 { PI - a } else { a };
if y < 0.0 { -a } else { a }
}
/// The cube root, from `+ − × ÷` only (Newton's iteration from an exact power-of-two estimate),
/// the same on every platform (see [`atan2`]).
pub(crate) fn cbrt(x: f64) -> f64 {
if !x.is_finite() || x == 0.0 {
return x;
}
let a = x.abs();
// 2^(e/3) for a's binary exponent e: within a factor 2 of the root.
let e = ((a.to_bits() >> 52) & 0x7ff) as i64 - 1023;
let mut r = f64::from_bits((((e / 3) + 1023).clamp(1, 2046) as u64) << 52);
for _ in 0..60 {
let next = r - (r * r * r - a) / (3.0 * r * r);
if next == r || !next.is_finite() {
break;
}
r = next;
}
if x < 0.0 { -r } else { r }
}
pub(crate) fn add(a: P, b: P) -> P {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
@@ -38,3 +90,30 @@ pub(crate) fn lerp(a: P, b: P, t: f64) -> P {
pub(crate) fn mid(a: P, b: P) -> P {
[0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]), 0.5 * (a[2] + b[2])]
}
#[cfg(test)]
mod tests {
use super::*;
/// The portable atan2 and cbrt agree with the platform's to a few ulps.
#[test]
fn portable_functions_match_the_platform() {
let mut worst: f64 = 0.0;
for i in 0..2000 {
let a = -std::f64::consts::PI + i as f64 * (2.0 * std::f64::consts::PI / 1999.0);
for r in [1e-300, 1e-3, 1.0, 7.5, 1e200] {
let (y, x) = (r * a.sin(), r * a.cos());
worst = worst.max((atan2(y, x) - y.atan2(x)).abs());
}
}
assert!(worst < 4e-15, "{worst}");
assert_eq!(atan2(0.0, 0.0), 0.0);
assert_eq!(atan2(1.0, 0.0), std::f64::consts::FRAC_PI_2);
assert_eq!(atan2(0.0, -1.0), std::f64::consts::PI);
for x in [1e-300, 2.5e-7, 0.3, 1.0, 8.0, 27.0, 1234.5, 1e300, -64.0] {
let rel: f64 = (cbrt(x) - x.cbrt()).abs() / x.cbrt().abs();
assert!(rel < 4e-16, "{x}: {} vs {}", cbrt(x), x.cbrt());
}
assert_eq!(cbrt(0.0), 0.0);
}
}
+2 -1
View File
@@ -174,7 +174,8 @@ impl Model {
/// The definitions that refer to a datum (the partitions that cut by it), as `kind \`name\``
/// strings.
pub fn datum_users(&self, name: &str) -> Vec<String> {
self.partitions.iter().filter(|p| p.datum == name).map(|p| format!("partition `{}`", p.name)).collect()
let partitions = self.partitions.iter().filter(|p| p.datum == name).map(|p| format!("partition `{}`", p.name));
partitions.chain(self.face_splits().iter().filter(|s| s.datum == name).map(|s| format!("face split `{}`", s.name))).collect()
}
/// The partitions, in the order they cut.
+41 -2
View File
@@ -50,6 +50,7 @@ pub mod rename;
pub mod sections;
pub mod seeds;
pub mod sets;
pub mod sizes;
pub mod steps;
pub mod tie;
pub mod units;
@@ -78,6 +79,7 @@ pub use relaxations::{Relaxation, RelaxationCell};
pub use sections::{BeamProfile, Extent, Formulation, Integration, Section, SectionKind};
pub use seeds::{Bias, BiasToward, Divisions, Seed, SeedConstraint};
pub use sets::Set;
pub use sizes::{SizeControl, SizeRegion};
pub use steps::{
ArcConstraint, ArcLength, BulkViscosity, DisplacementLimit, ExplicitDynamic, Hourglass, HourglassKind, Imperfection, InitialVelocity,
MAX_LOAD_PATH_POINTS, MAX_NONLINEAR_INCREMENTS, MAX_NONLINEAR_ITERATIONS, MassScaling, NonlinearStatic, OutputVariable, Procedure, RigidWall,
@@ -85,7 +87,7 @@ pub use steps::{
};
pub use tie::Tie;
pub use units::UnitSystem;
pub use virtual_faces::{FaceMerge, Stitch};
pub use virtual_faces::{Defeature, FaceMerge, FaceSplit, Stitch};
/// The largest node or element label. Nastran's GRID and element ids stop at 99 999 999 (eight
/// digits in a small field), the tightest of the three formats.
@@ -168,6 +170,12 @@ pub struct Model {
/// Edge seeds, sorted by edge.
#[serde(skip_serializing_if = "Vec::is_empty")]
seeds: Vec<Seed>,
/// Size controls (`mesh.size.set`), by name.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
size_controls: BTreeMap<String, SizeControl>,
/// The size field's growth rate (`mesh.size.growth`); `None`: the default.
#[serde(skip_serializing_if = "Option::is_none")]
size_growth: Option<f64>,
/// Relaxation fields (`mesh.node.relax`), by name.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
relaxations: BTreeMap<String, Relaxation>,
@@ -180,6 +188,12 @@ pub struct Model {
/// Stitches of imported surfaces, in the order they apply.
#[serde(skip_serializing_if = "Vec::is_empty")]
stitches: Vec<Stitch>,
/// Face splits along datum planes, in the order they apply (after the merges).
#[serde(skip_serializing_if = "Vec::is_empty")]
face_splits: Vec<FaceSplit>,
/// Features of the design taken out for meshing, in the order they were made.
#[serde(skip_serializing_if = "Vec::is_empty")]
defeatures: Vec<Defeature>,
}
/// A [`Model`] as stored, before it is checked.
@@ -229,6 +243,10 @@ struct ModelData {
#[serde(default)]
seeds: Vec<Seed>,
#[serde(default)]
size_controls: BTreeMap<String, SizeControl>,
#[serde(default)]
size_growth: Option<f64>,
#[serde(default)]
relaxations: BTreeMap<String, Relaxation>,
#[serde(default)]
mesh_plans: BTreeMap<String, MeshPlan>,
@@ -236,6 +254,10 @@ struct ModelData {
face_merges: Vec<FaceMerge>,
#[serde(default)]
stitches: Vec<Stitch>,
#[serde(default)]
face_splits: Vec<FaceSplit>,
#[serde(default)]
defeatures: Vec<Defeature>,
}
impl TryFrom<ModelData> for Model {
@@ -319,6 +341,10 @@ impl TryFrom<ModelData> for Model {
for s in d.seeds {
m.set_seed(s)?;
}
for (n, v) in d.size_controls {
m.set_size_control(&n, v)?;
}
m.set_size_growth(d.size_growth)?;
for (n, v) in d.relaxations {
m.set_relaxation(&n, v)?;
}
@@ -331,6 +357,12 @@ impl TryFrom<ModelData> for Model {
for v in d.stitches {
m.add_stitch(v)?;
}
for v in d.face_splits {
m.add_face_split(v)?;
}
for v in d.defeatures {
m.set_defeature(v)?;
}
Ok(m)
}
}
@@ -461,10 +493,14 @@ impl Model {
partitions: self.partitions.clone(),
suppressed_datums: self.suppressed_datums.clone(),
seeds: self.seeds.clone(),
size_controls: self.size_controls.clone(),
size_growth: self.size_growth,
relaxations: self.relaxations.clone(),
mesh_plans: self.mesh_plans.clone(),
face_merges: self.face_merges.clone(),
stitches: self.stitches.clone(),
face_splits: self.face_splits.clone(),
defeatures: self.defeatures.clone(),
}
}
@@ -515,7 +551,7 @@ impl Model {
}
/// Counts of each kind of definition, for summaries.
pub fn definition_counts(&self) -> [(&'static str, usize); 18] {
pub fn definition_counts(&self) -> [(&'static str, usize); 21] {
[
("sets", self.sets.len()),
("materials", self.materials.len()),
@@ -533,8 +569,11 @@ impl Model {
("datums", self.datums.len()),
("partitions", self.partitions.len()),
("seeds", self.seeds.len()),
("size_controls", self.size_controls.len()),
("face_merges", self.face_merges.len()),
("stitches", self.stitches.len()),
("face_splits", self.face_splits.len()),
("defeatures", self.defeatures.len()),
]
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Size controls: local element sizes that every mesher honours.
//!
//! A control asks for elements of about `size` in a region: on B-rep faces or whole bodies
//! (`geometry`, by persistent name), inside an axis-aligned box or inside a sphere. Together with
//! the mesher's global size they make one size field: the size at a point is the smallest of the
//! global size and, over the controls, the control's size plus the growth rate times the
//! distance to its region, so the elements grow away from a refined region at most by the
//! growth rate per unit length. A control coarser than the global size leaves it alone, except a
//! control on a whole body, which sets that body's global size. Controls are definitions of the
//! model: journalled with the command that made them and saved in the `.fieldcraft` file.
use serde::{Deserialize, Serialize};
use crate::geometry::{Entity, GeometryRef};
use crate::{Error, Model, Result, check_coordinates, context, insert_new, positive, remove_existing, replace_existing};
/// Most faces or bodies one control names.
pub const MAX_SIZE_TARGETS: usize = 10_000;
/// The default growth rate: the largest change of element size per unit length.
pub const DEFAULT_GROWTH: f64 = 0.3;
/// The growth rates a model accepts.
pub const GROWTH_RANGE: std::ops::RangeInclusive<f64> = 0.05..=2.0;
/// Where a size control applies.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum SizeRegion {
/// B-rep faces, or whole bodies (`{"body": "Body1"}`), by persistent name.
Geometry(Vec<GeometryRef>),
/// An axis-aligned box.
Box { min: [f64; 3], max: [f64; 3] },
/// A ball.
Sphere { center: [f64; 3], radius: f64 },
}
/// A size control.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SizeControl {
pub region: SizeRegion,
/// Target element edge length.
pub size: f64,
}
impl SizeControl {
pub fn check(&self) -> Result<()> {
positive("size", self.size)?;
check_coordinates(&[self.size])?;
match &self.region {
SizeRegion::Geometry(refs) => {
if refs.is_empty() || refs.len() > MAX_SIZE_TARGETS {
return Err(Error::Invalid(format!("a size control names 1 to {MAX_SIZE_TARGETS} faces or bodies")));
}
if let Some(r) = refs.iter().find(|r| !matches!(r.entity, Entity::Face(_) | Entity::Body)) {
return Err(Error::Invalid(format!("a size control is on faces or bodies, not the {r}")));
}
}
SizeRegion::Box { min, max } => {
check_coordinates(min)?;
check_coordinates(max)?;
if (0..3).any(|k| min[k] >= max[k]) {
return Err(Error::Invalid("a size box's `min` must be below its `max` on every axis".into()));
}
}
SizeRegion::Sphere { center, radius } => {
check_coordinates(center)?;
positive("radius", *radius)?;
check_coordinates(&[*radius])?;
}
}
Ok(())
}
/// The bodies a control on whole bodies sizes.
pub fn bodies(&self) -> impl Iterator<Item = &str> {
let refs = match &self.region {
SizeRegion::Geometry(refs) => refs.as_slice(),
_ => &[],
};
refs.iter().filter(|r| r.entity == Entity::Body).map(|r| r.body.as_str())
}
}
impl Model {
/// The size controls, by name.
pub fn size_controls(&self) -> &std::collections::BTreeMap<String, SizeControl> {
&self.size_controls
}
/// Add a size control, or replace the one of that name.
pub fn set_size_control(&mut self, name: &str, control: SizeControl) -> Result<()> {
control.check().map_err(|e| context("size control", name, e))?;
if self.size_controls.contains_key(name) {
replace_existing(&mut self.size_controls, "size control", name, control)
} else {
insert_new(&mut self.size_controls, "size control", name, control)
}
}
pub fn remove_size_control(&mut self, name: &str) -> Result<SizeControl> {
remove_existing(&mut self.size_controls, "size control", name)
}
/// The growth rate of the size field: set by [`Model::set_size_growth`], else
/// [`DEFAULT_GROWTH`].
pub fn size_growth(&self) -> f64 {
self.size_growth.unwrap_or(DEFAULT_GROWTH)
}
/// Whether the growth rate was set.
pub fn size_growth_set(&self) -> bool {
self.size_growth.is_some()
}
/// Set the growth rate (in [`GROWTH_RANGE`]), or go back to the default with `None`.
pub fn set_size_growth(&mut self, rate: Option<f64>) -> Result<()> {
if let Some(g) = rate
&& !GROWTH_RANGE.contains(&g)
{
return Err(Error::Invalid(format!("the growth rate must be from {} to {}, got {g}", GROWTH_RANGE.start(), GROWTH_RANGE.end())));
}
self.size_growth = rate;
Ok(())
}
/// The size a control on the whole body `body` sets (the smallest, when several do).
pub fn body_size(&self, body: &str) -> Option<f64> {
self.size_controls.values().filter(|c| c.bodies().any(|b| b == body)).map(|c| c.size).reduce(f64::min)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn face(n: &str) -> GeometryRef {
GeometryRef::new("Body1", Entity::Face(n.into())).unwrap()
}
#[test]
fn controls_are_checked_replaced_and_round_trip() {
let mut m = Model::default();
m.set_size_control("Hole", SizeControl { region: SizeRegion::Geometry(vec![face("F3")]), size: 0.5 }).unwrap();
m.set_size_control("Zone", SizeControl { region: SizeRegion::Box { min: [0.0; 3], max: [1.0, 2.0, 3.0] }, size: 1.0 }).unwrap();
m.set_size_control("Zone", SizeControl { region: SizeRegion::Sphere { center: [0.0; 3], radius: 2.0 }, size: 0.8 }).unwrap();
m.set_size_control("Whole", SizeControl { region: SizeRegion::Geometry(vec![GeometryRef::new("Body1", Entity::Body).unwrap()]), size: 3.0 })
.unwrap();
m.set_size_growth(Some(0.5)).unwrap();
assert_eq!(m.size_controls().len(), 3);
assert_eq!(m.body_size("Body1"), Some(3.0));
assert_eq!(m.body_size("Body2"), None);
let back: Model = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
assert_eq!(back.size_controls(), m.size_controls());
assert_eq!(back.size_growth(), 0.5);
assert_eq!(Model::default().size_growth(), DEFAULT_GROWTH);
m.remove_size_control("Hole").unwrap();
assert!(m.remove_size_control("Hole").is_err());
}
#[test]
fn hostile_controls_are_refused() {
let mut m = Model::default();
let ball = |radius: f64, size: f64| SizeControl { region: SizeRegion::Sphere { center: [0.0; 3], radius }, size };
for c in [
ball(1.0, 0.0),
ball(1.0, -1.0),
ball(1.0, f64::NAN),
ball(1.0, 1e300),
ball(0.0, 1.0),
ball(f64::INFINITY, 1.0),
SizeControl { region: SizeRegion::Box { min: [1.0, 0.0, 0.0], max: [0.0, 1.0, 1.0] }, size: 1.0 },
SizeControl { region: SizeRegion::Box { min: [f64::NAN, 0.0, 0.0], max: [1.0; 3] }, size: 1.0 },
SizeControl { region: SizeRegion::Geometry(vec![]), size: 1.0 },
SizeControl { region: SizeRegion::Geometry(vec![GeometryRef::new("Body1", Entity::Edge("E".into())).unwrap()]), size: 1.0 },
] {
assert!(m.set_size_control("Bad", c.clone()).is_err(), "{c:?}");
}
assert!(m.set_size_control("1bad", ball(1.0, 1.0)).is_err());
for g in [0.0, 0.01, 3.0, f64::NAN] {
assert!(m.set_size_growth(Some(g)).is_err(), "{g}");
}
assert!(m.size_controls().is_empty());
}
}
+123
View File
@@ -33,6 +33,59 @@ pub struct FaceMerge {
pub into: Option<String>,
}
/// Most face splits a model holds.
pub const MAX_FACE_SPLITS: usize = 1024;
/// A face of a body split along a datum plane for meshing (`split:<name>:a|b`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FaceSplit {
pub name: String,
pub body: String,
pub face: String,
/// The datum plane it splits along.
pub datum: String,
}
impl Model {
/// The face splits, in the order they apply (after the merges).
pub fn face_splits(&self) -> &[FaceSplit] {
&self.face_splits
}
/// Add a face split after the others.
pub fn add_face_split(&mut self, split: FaceSplit) -> Result<()> {
let name = split.name.clone();
check_name("face split", &name)?;
if self.face_splits.iter().any(|s| s.name == name) {
return Err(Error::Invalid(format!("face split `{name}` already exists")));
}
if self.face_splits.len() >= MAX_FACE_SPLITS {
return Err(Error::Limit(format!("a model holds at most {MAX_FACE_SPLITS} face splits")));
}
let invalid = |m: String| context("face split", &name, Error::Invalid(m));
for n in [&split.body, &split.face] {
if n.trim().is_empty() || n.len() > MAX_GEOMETRY_NAME_LEN {
return Err(invalid(format!("body and face names are 1 to {MAX_GEOMETRY_NAME_LEN} bytes")));
}
}
match self.datum(&split.datum) {
None => return Err(context("face split", &name, Error::NotFound(format!("datum `{}`", split.datum)))),
Some(d) if d.plane().is_none() => {
return Err(invalid(format!("datum `{}` is a datum {}; a split is along a plane", split.datum, d.kind())));
}
Some(_) => {}
}
self.face_splits.push(split);
Ok(())
}
pub fn remove_face_split(&mut self, name: &str) -> Result<FaceSplit> {
let i = self.face_splits.iter().position(|s| s.name == name).ok_or_else(|| Error::NotFound(format!("face split `{name}`")))?;
Ok(self.face_splits.remove(i))
}
}
/// Most stitches a model holds.
pub const MAX_STITCHES: usize = 1024;
@@ -132,6 +185,23 @@ mod tests {
FaceMerge { name: name.into(), body: "Base".into(), faces: faces.iter().map(|f| f.to_string()).collect(), into: None }
}
#[test]
fn splits_need_a_datum_plane_and_hold_it() {
let mut m = Model::default();
m.add_datum("Mid", crate::Datum::Plane { point: [0.0; 3], normal: [1.0, 0.0, 0.0] }).unwrap();
m.add_datum("P", crate::Datum::Point { point: [0.0; 3] }).unwrap();
let sp = |n: &str, d: &str| FaceSplit { name: n.into(), body: "Base".into(), face: "F1:+z".into(), datum: d.into() };
m.add_face_split(sp("S", "Mid")).unwrap();
for bad in [sp("S", "Mid"), sp("T", "P"), sp("U", "nope"), FaceSplit { face: String::new(), ..sp("V", "Mid") }] {
assert!(m.add_face_split(bad).is_err());
}
assert!(m.remove_datum("Mid").is_err(), "in use by the split");
let back: Model = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
assert_eq!(back.face_splits(), m.face_splits());
m.remove_face_split("S").unwrap();
m.remove_datum("Mid").unwrap();
}
#[test]
fn stitches_are_kept_in_order_and_checked() {
let mut m = Model::default();
@@ -167,3 +237,56 @@ mod tests {
assert_eq!(m.face_merges().len(), 2);
}
}
/// Most defeatures a model holds.
pub const MAX_DEFEATURES: usize = 1024;
/// Most features one defeature names.
pub const MAX_DEFEATURE_FEATURES: usize = 1024;
/// Features of the design's timeline taken out for meshing (`geometry.defeature`): holes filled,
/// fillets and chamfers back to their sharp edges, bosses removed, by feature id (`F<id>` in
/// persistent names).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Defeature {
pub name: String,
pub features: Vec<u64>,
}
impl Model {
/// The defeatures, in the order they were made.
pub fn defeatures(&self) -> &[Defeature] {
&self.defeatures
}
/// Add a defeature, or replace the features of the one of that name (in its place).
pub fn set_defeature(&mut self, defeature: Defeature) -> Result<()> {
let name = defeature.name.clone();
check_name("defeature", &name)?;
let invalid = |m: String| context("defeature", &name, Error::Invalid(m));
if defeature.features.is_empty() || defeature.features.len() > MAX_DEFEATURE_FEATURES {
return Err(invalid(format!("1 to {MAX_DEFEATURE_FEATURES} features")));
}
let mut seen = std::collections::BTreeSet::new();
if let Some(id) = defeature.features.iter().find(|id| !seen.insert(**id)) {
return Err(invalid(format!("feature F{id} is listed twice")));
}
match self.defeatures.iter().position(|d| d.name == name) {
Some(i) => {
if let Some(d) = self.defeatures.get_mut(i) {
*d = defeature;
}
}
None if self.defeatures.len() >= MAX_DEFEATURES => {
return Err(Error::Limit(format!("a model holds at most {MAX_DEFEATURES} defeatures")));
}
None => self.defeatures.push(defeature),
}
Ok(())
}
pub fn remove_defeature(&mut self, name: &str) -> Result<Defeature> {
let i = self.defeatures.iter().position(|d| d.name == name).ok_or_else(|| Error::NotFound(format!("defeature `{name}`")))?;
Ok(self.defeatures.remove(i))
}
}
+70 -5
View File
@@ -534,11 +534,34 @@ impl Reduction {
});
// A tie whose secondary node is held already: what is left of the row must be a
// constraint of its own, not the difference of two near copies of one.
if row.source == Source::Tie
&& intended.is_none()
&& let Some(&(_, c)) = largest
&& c.abs() < NEARLY_REPEATED * gross
{
let nearly_repeated = row.source == Source::Tie && intended.is_none() && largest.is_some_and(|t| t.1.abs() < NEARLY_REPEATED * gross);
// Held by a prescribed value (the secondary surface meeting a boundary condition or an
// axis-aligned symmetry plane: a crosspoint), the row can only constrain main degrees
// of freedom, and nearly repeated, what it adds is the mortar condition of a node
// whose motion is already given: its weights on main nodes off the boundary do not
// vanish (the dual basis spreads over the node's faces), so after the substitutions
// a small but real constraint is left, not round-off (1.3e-6 of the coefficients that
// cancelled to give it where a tet face meets a symmetry plane, #540; round-off would
// be ~1e-14 there). Eliminating it divides by that smallness; dropping it leaves the
// tie off by that fraction at the crosspoint, below the discretization error. So it
// is dropped, with the same threshold that calls it a near repeat. A secondary degree
// of freedom another constraint already eliminated (a node secondary in two ties, a
// tie and a coupling) is a real conflict and still refused (#389).
if nearly_repeated && state.get(row.pivot) == Some(&State::Fixed) {
// Still consistent with the prescribed values, or a contradiction.
let value = |t: &(usize, f64)| t.1 * values.get(t.0).copied().unwrap_or(0.0);
let residual: f64 = terms.iter().filter(|t| state.get(t.0) == Some(&State::Fixed)).map(value).sum();
let size: f64 = terms.iter().map(|t| value(t).abs()).sum();
if residual.abs() > 1e-9 * size.max(f64::MIN_POSITIVE) && residual.abs() > 0.0 {
return Err(Error::Invalid(format!(
"{} `{}` contradicts the prescribed values (boundary conditions or couplings hold its nodes in a way it cannot follow)",
row.source.what(),
row.coupling
)));
}
continue;
}
if nearly_repeated {
let mut others: Vec<String> = row
.terms
.iter()
@@ -858,6 +881,48 @@ impl Reduction {
mod tests {
use super::*;
fn tie(terms: Vec<(usize, f64)>, pivot: usize) -> Row<'static> {
Row { terms, pivot, coupling: "glue", source: Source::Tie }
}
/// Dofs `held` prescribed, dof 1 at `one` and the others at 0.
fn reduce(rows: &[Row], held: &[usize], one: f64) -> Result<Reduction> {
let layout = DofLayout::uniform(0..2, DofSet::TRANSLATIONS);
let budget = Budget { max_bytes: usize::MAX, pattern_entries: 0, matrices: 1 };
Reduction::new(
&layout,
std::iter::empty(),
&[0, 1],
rows,
|active| Ok((0..active.len()).map(|d| held.contains(&d).then_some(if d == 1 { one } else { 0.0 })).collect()),
budget,
)
}
/// A tie row of a secondary degree of freedom held by a boundary condition (dof 1), which
/// after substituting an earlier tie row (dof 0 following dofs 2 and 3) nearly repeats it, is
/// dropped (a crosspoint); dof 3 stays free.
#[test]
fn a_tie_row_on_a_held_degree_of_freedom_is_dropped() {
let rows = [tie(vec![(0, 1.0), (2, -0.5), (3, -0.5)], 0), tie(vec![(1, 1.0), (0, -1.0), (2, 0.5), (3, 0.5 - 1e-7)], 1)];
let r = reduce(&rows, &[1, 2], 0.0).unwrap();
assert_eq!(r.neq(), 3, "dofs 3, 4, 5 free");
}
/// Nearly repeated but inconsistent: on a secondary degree of freedom another tie already
/// eliminated (a node secondary in two ties) it is refused (#389), and on a held one whose
/// prescribed values it contradicts too.
#[test]
fn a_near_repeat_on_an_eliminated_degree_of_freedom_is_refused() {
let rows = [tie(vec![(0, 1.0), (2, -0.5), (3, -0.5)], 0), tie(vec![(0, 1.0), (2, -0.5), (3, -0.5 - 1e-7)], 0)];
let e = reduce(&rows, &[2], 0.0).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("almost alike"), "{e}");
// Dof 1 held at 1 and dof 2 at 0 while the row says dof 1 follows them.
let rows = [tie(vec![(0, 1.0), (2, -0.5), (3, -0.5)], 0), tie(vec![(1, 1.0), (0, -1.0), (2, 0.5), (3, 0.5 - 1e-7)], 1)];
let e = reduce(&rows, &[1, 2], 1.0).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("contradicts"), "{e}");
}
#[test]
fn reduced_matrix_budget_refuses_byte_counts_that_overflow() {
for matrices in [1, 2] {
+3
View File
@@ -15,3 +15,6 @@ fieldcraft-mesh = { workspace = true }
fieldcraft-engine = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
fieldcraft-parallel = { workspace = true }
+64 -5
View File
@@ -19,6 +19,7 @@ use fieldcraft_mesh::hex::{Cell, HexOptions, Surface, hex_mesh, hex_mesh_cells};
use fieldcraft_mesh::octree::{OctreeOptions, octree_hex_mesh};
use fieldcraft_mesh::size::UniformSize;
use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
use fieldcraft_mesh::tet::canonical::CanonicalSoup;
use fieldcraft_mesh::tet::{QualityOptions, quality_mesh};
pub const FILE: &str = "docs/meshing/corpus.md";
@@ -107,8 +108,7 @@ impl Stage for TetCdt {
let start = Instant::now();
let (mut tets, mut steiner, mut cavities, mut inadmissible) = (0, 0, 0, 0);
for (body, mesh) in l.design.bodies.iter().zip(&l.surfaces) {
let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() };
let s = BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options).map_err(|e| format!("{}: {e}", body.name))?;
let (s, _) = tet_surface(mesh).map_err(|e| format!("{}: {e}", body.name))?;
let m = tetrahedralize(&s, &CdtOptions::default()).map_err(|e| format!("{}: {e}", body.name))?;
let report = m.gate(s.volume()).map_err(|e| format!("{}: rounded mesh: {e}", body.name))?;
if !report.volume_matches() {
@@ -125,6 +125,13 @@ impl Stage for TetCdt {
}
}
/// A body's boundary as the tet mesher takes it ([`CanonicalSoup`], as `mesh.tet.generate` does).
pub fn tet_surface(mesh: &SurfaceMesh) -> fieldcraft_mesh::Result<(BoundarySurface, CanonicalSoup)> {
let soup = CanonicalSoup::new(&mesh.positions, &mesh.triangles, &mesh.tri_face)?;
let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() };
Ok((BoundarySurface::new(&soup.positions, &soup.triangles, &soup.tri_face, &options)?, soup))
}
/// The quality stage (`fieldcraft_mesh::tet`) on each body's CDT, with the default options: the
/// mesh's tetrahedra, its extreme dihedral angles, the slivers (a dihedral angle below 10° or
/// above 170°) of the CDT and of the result, and the validity gate.
@@ -139,10 +146,9 @@ impl Stage for TetQualityStage {
let start = Instant::now();
let (mut tets, mut lo, mut hi, mut rr, mut before, mut after) = (0, 180.0_f64, 0.0_f64, 1.0_f64, 0, 0);
for (body, mesh) in l.design.bodies.iter().zip(&l.surfaces) {
let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() };
let s = BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options).map_err(|e| format!("{}: {e}", body.name))?;
let (s, soup) = tet_surface(mesh).map_err(|e| format!("{}: {e}", body.name))?;
let m = tetrahedralize(&s, &CdtOptions::default()).map_err(|e| format!("{}: {e}", body.name))?;
let q = quality_mesh(&s, &m, fieldcraft_engine::analytic_surfaces(body), &QualityOptions::default())
let q = quality_mesh(&s, &m, soup.analytic(&fieldcraft_engine::analytic_surfaces(body)), &QualityOptions::default())
.map_err(|e| format!("{}: {e}", body.name))?;
let r = &q.report;
tets += r.output.tets;
@@ -480,4 +486,57 @@ mod tests {
assert!(cells[0].parse::<usize>().unwrap() > 0);
}
}
/// The tet mesh of a SolidCraft part, as `mesh.tet.generate` makes it (tet10, default
/// options): every coordinate's bits, the elements and the boundary faces with their faces' names.
fn tet_fingerprint(part: &Part) -> Vec<u64> {
let (l, _) = load(part).unwrap();
let mut out = Vec::new();
for (body, mesh) in l.design.bodies.iter().zip(&l.surfaces) {
let (s, soup) = tet_surface(mesh).unwrap();
let cdt = tetrahedralize(&s, &CdtOptions::default()).unwrap();
let o = QualityOptions { order: fieldcraft_mesh::tet::Order::Quadratic, ..QualityOptions::default() };
let mut m = quality_mesh(&s, &cdt, soup.analytic(&fieldcraft_engine::analytic_surfaces(body)), &o).unwrap();
soup.restore_tags(&mut m);
out.extend(m.points.iter().flatten().map(|c| c.to_bits()));
out.extend(m.elements.iter().flatten().map(|&v| u64::from(v)));
// A face by its persistent name: SolidCraft may number the faces differently each run.
let faces = body.faces();
let name = |f: u32| {
faces
.get(f as usize)
.map_or(0, |x| x.name.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)))
};
out.extend(m.boundary.iter().flat_map(|b| [u64::from(b.tet) << 8 | u64::from(b.opposite), name(b.face)]));
}
out
}
/// The same part gives the same tet mesh bit for bit on repeated runs and with 1 and 8
/// threads (#115). SolidCraft lists the rounded box's faces in a different order from run to
/// run; the mesher's canonical input order ([`CanonicalSoup`]) keeps the mesh the same.
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn tet_meshes_do_not_depend_on_the_run_or_the_thread_count() {
let mut parts = corpus::parts().unwrap();
parts.retain(|p| ["rounded-box", "filleted-bracket"].contains(&p.name.as_str()));
assert_eq!(parts.len(), 2);
for part in &parts {
let one = fieldcraft_parallel::with_threads(1, || tet_fingerprint(part));
let many = fieldcraft_parallel::with_threads(8, || tet_fingerprint(part));
let again = fieldcraft_parallel::with_threads(1, || tet_fingerprint(part));
assert!(!one.is_empty());
let first = |a: &[u64], b: &[u64]| a.iter().zip(b).position(|(x, y)| x != y);
assert!(
one == many && one == again,
"{}: the tet mesh changed between runs ({} / {} / {} words, first difference {:?} / {:?})",
part.name,
one.len(),
many.len(),
again.len(),
first(&one, &many),
first(&one, &again)
);
}
}
}
+4 -25
View File
@@ -28,7 +28,7 @@ const MIN_SCALED_JACOBIAN: f64 = 0.3;
const MAX_EDGE_RATIO: f64 = 10.0;
/// The mesh's volume against the geometry's (§6.1): a larger difference is a missing cell or a
/// mesh too coarse for the curvature.
const MAX_VOLUME_ERROR: f64 = 0.02;
const MAX_VOLUME_ERROR: f64 = 0.01;
/// The most marked elements relaxed one by one; more than this is a clustered failure (the
/// blocks are wrong, §6.3), which relaxing does not fix.
const MAX_RELAXED: usize = 20;
@@ -52,38 +52,17 @@ const PLAIN_CUP: &str = r#"[
/// Why a part fails the skill's limits today, read from its numbers and its section picture.
/// Shown only while the part fails.
const CAUSES: &[(&str, &str)] = &[
(
"bent-tube",
"the plan's default size (1/25 of the diagonal) puts six elements round the Ø10 tube: the swept section is a hexagon and loses 17 % of the volume. Size from the tube's radius (§5), not the part's diagonal.",
),
(
"filleted-bracket",
"no structured method takes the whole body (the plan has no cuts for the fillet beside the holes), and tet10 at the plan's size leaves slivers (min SJ 0.04, 1709 marked). The same part partitioned into mapped cells (`filleted-bracket-blocks`) passes: partition (§3.4).",
),
(
"loft-transition",
"the lofted sides are swept one to one, and the elements where the section changes shape are skewed (min SJ 0.19). Relaxing the four marked elements did not raise it: the blocks are wrong, not the nodes (§6.3). A loft has no template yet (`unmapped`).",
),
(
"pinhole-block",
"the plan's cuts run beside the small holes' frames and leave strips of thin elements along the cut planes (red in the picture, edge ratio 18): 1680 marked, a clustered failure. §4: offset a cut one element out of the feature.",
),
(
"sharp-wedge",
"the wedge's sharp edge is swept as hex, so every element tapers to it (min SJ 0.01, edge ratio 150). §4: a column of wedge6 at the edge; the plan has no wedge template.",
"the wedge's sharp edge is swept as hex, so every element tapers to it (min SJ 0.01, edge ratio 150). A column of wedge6 at the edge (§4) raises the SJ but not the ratio: any element with a corner on a 2.9° edge has sides at least 1/tan 2.9° ≈ 20 times its width there, twice the limit. The edge has to be blunted (defeatured) or the limit waived along it.",
),
("sphere-on-plate", "no structured method takes the plate with the sunk sphere, and tet10 leaves slivers (min SJ 0.12, 662 marked)."),
(
"analytic-sphere",
"the sphere template meshes it cleanly, but hex8 faces chord the sphere at the plan's size and the mesh is 3 % short of its volume: refine, or hex20 (`order: 2`).",
),
(
"analytic-torus",
"the revolve is clean, but hex8 faces chord the torus at the plan's size and the mesh is 4.7 % short of its volume: refine, or hex20.",
),
(
"crank-arm",
"the edge ratio is 10.5 in the nine elements at the square bore's R1.5 corners, just over the limit. Relaxing each raised its region's edge ratio, so each was undone (§6.3). `mesh.plan.apply` did not find the y = 0 symmetry plane; the sample meshes the partitioned half and mirrors it.",
"no structured method takes a sphere (it has no face to sweep from or axis to revolve about), so the plan does not apply and the fallback's sphere template meshes it at the plan's size (2.45 mm, which keeps a sphere's chords to 0.75 % on paper), 1.1 % short of the volume: the template's elements at the cube's eight corners span more of the sphere than the size. A sphere template in the plan (§3) would size its caps; hex20 (`order: 2`) closes it too.",
),
(
"cross-cylinders",
@@ -518,7 +497,7 @@ with (a set on them finds nothing); `marked` the elements failing the limits bef
relaxing, and what relaxing changed; ¹ a tet mesh has no edge ratio: its largest aspect ratio\n\
stands in; `duplicates` coincident nodes and cracks; `geometry.check` its findings at the size;\n\
`verdict` pass when the minimum scaled Jacobian and the largest edge ratio meet the limits and\n\
the volume is within 2 % (`pass (fallback)` when a fallback meshed it).\n\
the volume is within 1 % (`pass (fallback)` when a fallback meshed it).\n\
The section pictures cut each part through the middle of its longest side, marked elements red.\n\
Times are wall clock on the machine that ran it.\n\n## Results\n\n",
);
+1 -1
View File
@@ -165,7 +165,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
selection::Target::DatumPoint => "Puts the point on what is selected: a vertex, an edge's midpoint, a face's centre, or the centre of a hole or of several items",
selection::Target::Bodies => "The selected bodies (Select: Bodies), or the bodies of the selected faces, become the cells",
selection::Target::ExtrudeFaces => "Pick the source face and use it, then pick the target surface and use it",
selection::Target::Faces => "The selected design faces (Shift-click to pick, Select: Faces) are relaxed instead of a sphere",
selection::Target::Faces => "The selected design faces (Shift-click to pick, Select: Faces) become the form's faces, in place of a sphere",
};
let button = match target {
selection::Target::ExtrudeFaces if selection::extrude_key(&params) == "source" => "Use as source",
+2 -1
View File
@@ -1153,6 +1153,7 @@ impl ModelApp {
datums.union(scene::datum_preview(&mut s, d, &self.display.1.bounds, &t));
}
scene::seeds(&mut s, &self.session.model, &self.pick.names, &self.display.1, &t);
scene::sizes(&mut s, &self.session.model, &self.pick.names, &self.display.1, &t);
}
self.scene = (Some(key), s, datums);
}
@@ -1822,7 +1823,7 @@ impl ModelApp {
selection::Target::ExtrudeFaces => selection::with_extrude_faces(&params, &self.pick.selection)
.ok_or("no face is selected: pick the source face, then the target surface (Select: Faces)")?,
selection::Target::Faces => {
selection::with_faces(&params, &self.pick.selection).ok_or("no face is selected: pick the faces to relax (Select: Faces)")?
selection::with_faces(&params, &self.pick.selection).ok_or("no face is selected: pick the faces (Select: Faces)")?
}
selection::Target::Bodies => {
let bodies = selection::bodies(&self.pick.selection).ok_or("no geometry is selected: pick cells with Select: Bodies")?;
+121 -1
View File
@@ -28,7 +28,7 @@ pub struct Reply {
const SOLVES: [&str; 6] = ["solve.static", "solve.nonlinear", "solve.heat", "solve.frequency", "solve.buckle", "solve.explicit"];
/// Replies that only read the model, refreshed when it changes; any other reply closes then.
pub const LIVE: [&str; 2] = ["model.check", "model.activity"];
pub const LIVE: [&str; 3] = ["model.check", "model.activity", "geometry.defeature.propose"];
/// The status line's text (after the command's label) is cut to this many characters.
const MAX_LINE: usize = 120;
@@ -221,6 +221,17 @@ enum Act {
Elements(Vec<u32>),
/// Open Relax Nodes round an element (Mesh Quality's worst).
Relax(u32),
/// Switch a feature on (kept in the mesh) or off (defeatured by the named defeature).
Defeature {
name: String,
feature: u64,
on: bool,
},
/// Defeature the proposed features with the named defeature.
Propose {
name: String,
features: Vec<u64>,
},
}
/// The reply's section of the context panel.
@@ -248,6 +259,7 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
"model.activity" => activity(ui, t, &reply.value),
"geometry.bodies" => bodies(ui, t, &reply.value, &mut acts),
"mesh.quality" => quality(ui, t, &reply.value, &mut acts),
"geometry.defeature.propose" => defeature_list(ui, t, &reply.value, &mut acts),
_ => value(ui, t, egui::Id::new("fc_reply"), &reply.value, 0),
}
for a in acts {
@@ -267,10 +279,94 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
app.show_elements(&[element]);
app.open_form("mesh.node.relax", serde_json::json!({"around_element": element}));
}
Act::Defeature { name, feature, on } => {
let mut features = defeatured(app, &name);
features.retain(|f| *f != feature);
if !on {
features.push(feature);
}
let _ = set_defeature(app, &name, features);
}
Act::Propose { name, features } => {
let mut all = defeatured(app, &name);
for f in features {
if !all.contains(&f) {
all.push(f);
}
}
let _ = set_defeature(app, &name, all);
}
}
}
}
/// The features the defeature `name` takes out (none when the model has no such defeature).
fn defeatured(app: &ModelApp, name: &str) -> Vec<u64> {
app.session.model.defeatures().iter().find(|d| d.name == name).map(|d| d.features.clone()).unwrap_or_default()
}
/// Give the defeature `name` these features (none: delete it).
fn set_defeature(app: &mut ModelApp, name: &str, features: Vec<u64>) -> Result<Value, String> {
match features.is_empty() {
true if defeatured(app, name).is_empty() => Ok(Value::Null),
true => app.run("geometry.defeature.delete", serde_json::json!({"name": name})),
false => app.run("geometry.defeature", serde_json::json!({"name": name, "features": features})),
}
}
/// Propose Defeaturing: each candidate with a switch (on: in the mesh; off: defeatured), its
/// kind, size and the defeature that takes it out, and a button that defeatures the proposal.
fn defeature_list(ui: &mut egui::Ui, t: &Tokens, v: &Value, acts: &mut Vec<Act>) {
let name = v["name"].as_str().unwrap_or("Defeature").to_string();
let body = v["body"].as_str().unwrap_or_default();
let candidates = v["candidates"].as_array().map(Vec::as_slice).unwrap_or_default();
let proposal: Vec<u64> = v["proposal"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect();
let pending: Vec<u64> = candidates.iter().filter(|c| c["proposed"] == true && c["suppressed"] != true).filter_map(|c| c["id"].as_u64()).collect();
if !proposal.is_empty() {
let button = ui.add_enabled(!pending.is_empty(), egui::Button::new(format!("Defeature the {} proposed", pending.len())));
if button.on_hover_text(format!("Take them out with defeature {name}")).clicked() {
acts.push(Act::Propose { name: name.clone(), features: pending });
}
ui.add_space(4.0);
}
egui::Grid::new("fc_defeature").num_columns(4).spacing(vec2(10.0, 2.0)).striped(true).show(ui, |ui| {
for h in ["In mesh", "Feature", "Kind", "Size"] {
ui.label(RichText::new(h).color(t.text_dim));
}
ui.end_row();
for c in candidates.iter().take(MAX_ENTITIES) {
let Some(id) = c["id"].as_u64() else { continue };
let mut on = c["suppressed"] != true;
let by = c["by"].as_str().unwrap_or(&name).to_string();
let hover = if on { format!("Switch off: defeature {name} takes it out") } else { format!("Switch on: defeature {by} keeps it") };
if ui.checkbox(&mut on, "").on_hover_text(hover).changed() {
acts.push(Act::Defeature { name: if on { by } else { name.clone() }, feature: id, on });
}
let label = format!("F{id} {}", c["name"].as_str().unwrap_or_default());
let text = if c["proposed"] == true { RichText::new(label).color(t.warm) } else { RichText::new(label) };
match c["faces"].as_array().and_then(|f| f.first()).and_then(Value::as_str).filter(|_| on) {
Some(face) => {
if ui.link(text).on_hover_text("Pick its face in the viewport").clicked() {
let entity = fieldcraft_model::Entity::Face(face.to_string());
acts.push(Act::Pick(fieldcraft_model::GeometryRef { body: body.to_string(), entity }));
}
}
None => {
ui.label(text);
}
}
let method = match c["method"].as_str() {
Some("virtual") => " (virtual)",
_ => "",
};
ui.label(format!("{}{method}", c["category"].as_str().unwrap_or_default()));
ui.label(format!("{} mm", crate::describe::quantity(sig(c["size"].as_f64().unwrap_or(0.0)))));
ui.end_row();
}
});
more_than(ui, t, candidates.len(), MAX_ENTITIES);
}
/// The definition a diagnostic's subject names (``section `web` ``), when the model has it.
fn subject_definition(app: &ModelApp, subject: &str) -> Option<(Def, String)> {
let (kind, rest) = subject.split_once(" `")?;
@@ -817,6 +913,30 @@ mod tests {
use super::*;
/// Propose Defeaturing stays in the panel and follows the model: switching the plate's hole
/// off fills it in the viewport's bodies and the list shows it off; on again brings it back.
#[test]
fn the_defeature_list_switches_a_hole_off_and_on() {
let dir = std::env::temp_dir().join(format!("fieldcraft-ui-defeature-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let design = dir.join("plate.solidcraft");
std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap();
let mut app = ModelApp::new(fieldcraft_engine::Session::default());
app.run("geometry.open", json!({"path": design.to_string_lossy()})).unwrap();
let body = app.session.design.as_ref().unwrap().bodies[0].name.clone();
app.run("geometry.defeature.propose", json!({"body": body, "below": {"hole_diameter": 12}})).unwrap();
let hole = app.reply.as_ref().unwrap().value["proposal"][0].as_u64().unwrap();
let faces = |app: &ModelApp| app.session.design.as_ref().unwrap().bodies[0].faces().len();
set_defeature(&mut app, "Defeature", vec![hole]).unwrap();
let reply = app.reply.as_ref().expect("the list stays open");
assert_eq!((faces(&app), reply.value["candidates"][0]["suppressed"].as_bool()), (6, Some(true)));
assert_eq!(defeatured(&app, "Defeature"), [hole]);
set_defeature(&mut app, "Defeature", Vec::new()).unwrap();
assert_eq!((faces(&app), app.reply.as_ref().unwrap().value["candidates"][0]["suppressed"].as_bool()), (8, Some(false)));
assert!(app.session.model.defeatures().is_empty());
std::fs::remove_dir_all(&dir).ok();
}
/// Mesh Quality's reply draws as counts, histograms and worst elements, and a worst element
/// is selected and framed (#113).
#[test]
+68
View File
@@ -137,6 +137,57 @@ pub fn seeds(s: &mut Scene, model: &fieldcraft_model::Model, names: &[crate::sel
}
}
/// Draw the model's size controls (mesh.size.set) where they apply: the faces and bodies they
/// refine tinted on the drawn geometry, a box as its twelve edges, a sphere as
/// [`sphere`] draws one, each labelled with its name and size.
pub fn sizes(s: &mut Scene, model: &fieldcraft_model::Model, names: &[crate::selection::BodyNames], display: &DisplayMesh, t: &Tokens) {
use fieldcraft_model::{Entity, GeometryRef, SizeRegion};
if model.size_controls().is_empty() {
return;
}
let color = rgb(t.warm);
for (name, c) in model.size_controls() {
let text = format!("{name} {}", c.size);
let v = |p: [f64; 3]| Vec3 { x: p[0], y: p[1], z: p[2] };
match &c.region {
SizeRegion::Box { min, max } => {
let corner = |k: usize| v([0, 1, 2].map(|a| if (k >> a) & 1 == 1 { max[a] } else { min[a] }));
for k in 0..8usize {
for a in 0..3 {
if (k >> a) & 1 == 0 {
s.lines.push(SceneLine { a: corner(k), b: corner(k | (1 << a)), color, width: 1.5 });
}
}
}
s.labels.push(SceneLabel { p: (corner(0) + corner(7)) * 0.5, text, color });
}
SizeRegion::Sphere { center, radius } => {
sphere(s, v(*center), *radius, color);
s.labels.push(SceneLabel { p: v(*center), text, color });
}
SizeRegion::Geometry(refs) => {
let (mut sum, mut n) = (Vec3 { x: 0.0, y: 0.0, z: 0.0 }, 0usize);
for tri in &display.surface_tris {
let Some(body) = names.get(tri.body as usize) else { continue };
let face = body.faces.get(tri.face as usize).and_then(|f| GeometryRef::face(body.name.clone(), f.clone()).ok());
let hit = refs.iter().any(|r| match &r.entity {
Entity::Body => r.body == body.name,
_ => face.as_ref().is_some_and(|f| r.selects(f)),
});
if hit {
s.fills.push(SceneFill { tri: SceneTri { p: tri.p, normal: tri.normal, color }, alpha: 0.3 });
sum = sum + (tri.p[0] + tri.p[1] + tri.p[2]) * (1.0 / 3.0);
n += 1;
}
}
if n > 0 {
s.labels.push(SceneLabel { p: sum * (1.0 / n as f64), text, color });
}
}
}
}
}
/// How many element edges of the drawn mesh lie along the geometry edge `run` (its polyline,
/// `length` long): those whose ends and middle are all on it, counted once per element edge
/// (a quadratic edge is drawn in pieces). 0 when the mesh has none there (not meshed).
@@ -265,4 +316,21 @@ mod tests {
assert_eq!(s.tris.first().map(|t| t.color), None);
assert!(radius(&d.bounds, &Camera::default()) > 0.5);
}
#[test]
fn size_controls_draw_where_they_apply() {
use fieldcraft_model::{SizeControl, SizeRegion};
let mut m = Model::default();
m.set_size_control("Zone", SizeControl { region: SizeRegion::Box { min: [0.0; 3], max: [1.0; 3] }, size: 0.5 }).unwrap();
m.set_size_control("Ball", SizeControl { region: SizeRegion::Sphere { center: [0.0; 3], radius: 2.0 }, size: 0.5 }).unwrap();
let mut s = Scene::default();
sizes(&mut s, &m, &[], &DisplayMesh::default(), &Tokens::dark());
assert!(s.lines.len() >= 12 && !s.fills.is_empty(), "the box's edges and the sphere");
let mut labels: Vec<&str> = s.labels.iter().map(|l| l.text.as_str()).collect();
labels.sort();
assert_eq!(labels, ["Ball 0.5", "Zone 0.5"]);
let mut empty = Scene::default();
sizes(&mut empty, &Model::default(), &[], &DisplayMesh::default(), &Tokens::dark());
assert!(empty.lines.is_empty() && empty.labels.is_empty());
}
}
+2 -2
View File
@@ -856,7 +856,7 @@ pub enum Target {
/// `to_surface`: pick the source face, then the target surface.
ExtrudeFaces,
/// The selected geometry faces as `faces: [{body, face}]`, in place of a sphere or labels
/// (the faces Relax Nodes relaxes).
/// (the faces Relax Nodes relaxes, the faces a local size refines).
Faces,
}
@@ -870,7 +870,7 @@ pub fn target(id: &str, params: &Value) -> Option<Target> {
"geometry.datum.point" | "geometry.datum.point.edit" => Some(Target::DatumPoint),
"mesh.cells" => Some(Target::Bodies),
"mesh.hex.extrude" => Some(Target::ExtrudeFaces),
"mesh.node.relax" => Some(Target::Faces),
"mesh.node.relax" | "mesh.size.set" => Some(Target::Faces),
"model.boundary_condition.create" | "model.boundary_condition.edit" => Some(Target::TargetSet),
"model.section.create" | "model.section.edit" | "model.coupling.create" | "model.coupling.edit" | "interaction.rigid_wall" => {
Some(Target::Set)
+4 -1
View File
@@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
81 ready, 37 coming soon.
84 ready, 37 coming soon.
## Geometry
@@ -24,6 +24,8 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Prepare | Partition — Cut bodies into cells by a datum plane; the two faces of each cut are paired by name | ready | `geometry.partition`, (`geometry.partition.delete`), (`geometry.partition.suppress`) |
| Prepare | Merge Faces — Mesh adjacent faces as one (virtual topology): a fillet in pieces, a face split by an imprint; the B-rep is unchanged | ready | `geometry.faces.merge`, (`geometry.faces.merge.delete`) |
| Prepare | Absorb Faces — Absorb tiny and sliver faces into a neighbour for meshing, one picked or every one Check Geometry found | ready | `geometry.face.absorb` |
| Prepare | Split Face — Split a face along a datum plane for meshing only: two faces on its surface, the cut an edge between them | ready | `geometry.face.split`, (`geometry.face.split.delete`) |
| Prepare | Defeature — Take small holes, fillets, chamfers and bosses out for meshing, from a proposal list with each one on or off | ready | `geometry.defeature`, (`geometry.defeature.propose`), (`geometry.defeature.delete`) |
| Prepare | Stitch — Close an imported surface's gaps under a tolerance: open points welded, T-junctions split | ready | `geometry.stitch`, (`geometry.stitch.delete`) |
| Prepare | Midsurface — Extract midsurfaces of thin parts for shell meshes | coming soon (M8) | |
@@ -33,6 +35,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
|---|---|---|---|
| Size | Global Size — One element size for the whole part | coming soon (M2) | |
| Size | Seed Edges — Element counts or sizes per design edge, with bias and an exact, minimum or free constraint; the meshers honour them | ready | `mesh.seed.edge`, `mesh.seed.clear`, `mesh.seed.list` |
| Size | Local Size — Element size on faces or bodies, in a box or in a sphere, growing away at a set rate; every mesher honours it | ready | `mesh.size.set`, `mesh.size.growth`, `mesh.size.clear`, `mesh.size.list` |
| Size | Curvature — Refine by curvature and proximity | coming soon (M2) | |
| Tet | Tet Mesh — Quality tet4 or tet10 mesh of a body to a size: refined, smoothed and improved, boundary on the B-rep faces, faces tagged | ready | `mesh.tet.generate` |
| Tet | Quadratic — Second-order elements with mid-side nodes on the geometry | coming soon (M2) | |
+21
View File
@@ -185,6 +185,27 @@ edges of partition cells matching counts.
4. `mesh.seed.list` lists the seeds and the edges they reach (`stale` when the design has no such
edge); `mesh.seed.clear {edges?}` removes them. Seeds are saved with the model.
## Size controls
Ask for a local element size where the answer is decided (a fillet, a hole's wall, a contact
patch) instead of making the whole part fine.
1. `execute` `mesh.size.set {name, size, faces: [{body, face}] | bodies: [...] | box: {min, max} |
sphere: {center, radius}}` adds the control (or replaces the one of that name). Faces are
named as `inspect_model` lists them.
2. `mesh.size.growth {rate}` sets how fast the size grows away from a control (the largest
change of element size per unit length; 0.3 by default, 0.05 to 2).
3. Mesh: every mesher honours the controls. The size at a point is the smallest of the mesh
command's `size` and, over the controls, a control's size plus the growth rate times the
distance to it. The tet mesher refines to that field; the structured hex meshers, the block
projection and mesh plans divide their edges by it (a structured mesh carries a count across
its block, so the refinement spans the row or layer); the octree refines its cells to it (a
size that needs more levels than `max_depth` is refused before meshing). A control on whole
bodies is their size in place of the command's.
4. `mesh.size.list` lists the controls, what each covers in the open design (`stale` when the
design no longer has a face it names) and the growth rate; `mesh.size.clear {names?}` removes
them. Controls are saved with the model.
## Meshing from the feature graph
A design opened from `.solidcraft` is rebuilt from its timeline, so how each body was made is
+25 -25
View File
@@ -8,31 +8,31 @@ clock on the machine that ran it; every other column is deterministic.
## Results
| part | categories | bodies | faces | triangles | tol. mm | build ms | enclosed mm³ | SolidCraft mm³ | rel. diff | exact mm³ | boundary | genus | feature edges | check ms | CDT tets | Steiner | cavities | tet gate | CDT ms | hex | hex elements | hex8 min SJ | hex20 min SJ | hex ms | octree hexes | min SJ | mean SJ | hex vol. diff | octree ms |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| bent-tube | sweepable, thin-wall, genus | 1 | 18 | 4296 | 0.121 | 433 | 4213.1 | 4159.4 | 1.3e-2 | 4159.8 | ok, 2148 vertices | 1 | 268 | 11.4 | 15550 | 2338 | 0 | pass | 609 | sweep | 192 | 0.859 | 0.997 | 63 | no: Tube: meshing failed: the core's boundary has components of genus [0, 0, 0, 0], the part [1]: the part has walls 0.984 thick, thinner than 3 cells of the deepest level (0.536); use a smaller size, more levels, or a structured (swept) hex mesh | | | | 572 |
| bolted-assembly | assembly, blocky | 3 | 22 | 284 | 0.117 | 54 | 33004.7 | 32999.0 | 1.7e-4 | 32998.9 | ok, 144 vertices | 0, 1, 1 | 104 | 0.7 | 690 | 181 | 0 | pass | 35 | extrude | 357 | 0.398 | 0.222 | 42 | 41344 | 0.323 | 0.961 | 2.0e-4 | 11261 |
| cantilever-i-beam | sweepable, thin-wall | 1 | 14 | 44 | 0.208 | 3 | 61600.0 | 61600.0 | 0.0e0 | 61600.0 | ok, 24 vertices | 0 | 36 | 0.2 | 90 | 24 | 0 | pass | 3 | extrude | 336 | 0.707 | 0.707 | 34 | no: Beam: meshing failed: the core's boundary has components of genus [0, 0], the part [0]: the part has walls 2.000 thick, thinner than 3 cells of the deepest level (0.924); use a smaller size, more levels, or a structured (swept) hex mesh | | | | 908 |
| cross-cylinders | boolean, sharp | 1 | 10 | 1395 | 0.087 | 7820 | 32197.4 | 32357.8 | -5.0e-3 | 32365.8 | error: Cross: invalid boundary surface: 28 defects; triangles 142 and 184 are oriented inconsistently across edge 0–396 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 184 and 239 are oriented inconsistently across edge 0–554 at (-18.75000000000002, 5.634596901641276, 7.632255089815496); edge 103–108 at (-0.16007784063084018, 0.03984236936862052, 9.998639270596865) is shared by 3 triangles; … | | | | error: Cross: invalid boundary surface: 28 defects; triangles 142 and 184 are oriented inconsistently across edge 0–396 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 184 and 239 are oriented inconsistently across edge 0–554 at (-18.75000000000002, 5.634596901641276, 7.632255089815496); edge 103–108 at (-0.16007784063084018, 0.03984236936862052, 9.998639270596865) is shared by 3 triangles; … | | | | | error: Cross: invalid boundary surface: 28 defects; triangles 142 and 184 are oriented inconsistently across edge 0–396 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 184 and 239 are oriented inconsistently across edge 0–554 at (-18.75000000000002, 5.634596901641276, 7.632255089815496); edge 103–108 at (-0.16007784063084018, 0.03984236936862052, 9.998639270596865) is shared by 3 triangles; … | | | | | error: Cross: invalid boundary surface: 28 defects; triangles 142 and 184 are oriented inconsistently across edge 0–396 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 184 and 239 are oriented inconsistently across edge 0–554 at (-18.75000000000002, 5.634596901641276, 7.632255089815496); edge 103–108 at (-0.16007784063084018, 0.03984236936862052, 9.998639270596865) is shared by 3 triangles; … | | | | |
| filleted-bracket | fillet, genus, sweepable | 1 | 13 | 540 | 0.088 | 388 | 32162.3 | 32145.0 | 5.4e-4 | 32144.8 | ok, 268 vertices | 2 | 135 | 1.4 | 5582 | 1613 | 0 | pass | 201 | no: Bracket: not supported yet: no structured hex method fits this body (extrude: cylindrical face 8 is not parallel to the direction; sweep: from face 1: linking face 6 has 2 boundary loops (periodic or holed linking faces are not swept); from face 6: | | | | 5 | 30676 | 0.305 | 0.963 | 8.3e-4 | 10213 |
| filleted-bracket-blocks | fillet, genus, partitioned | 30 | 190 | 712 | 0.088 | 1006 | 32169.8 | 32145.1 | 7.7e-4 | 32144.8 | ok, 416 vertices | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 | 474 | 1.6 | 1423 | 349 | 0 | pass | 56 | blocks (30 cells, 45 shared faces) | 4986 | 0.705 | 0.705 | 542 | 149898 | 0.156 | 0.935 | 9.7e-4 | 36760 |
| i-beam | sweepable, thin-wall | 1 | 14 | 44 | 0.208 | 3 | 61600.0 | 61600.0 | 0.0e0 | 61600.0 | ok, 24 vertices | 0 | 36 | 0.1 | 94 | 24 | 0 | pass | 4 | extrude | 336 | 0.707 | 0.707 | 33 | no: Beam: meshing failed: the core's boundary has components of genus [0, 0], the part [0]: the part has walls 2.000 thick, thinner than 3 cells of the deepest level (0.924); use a smaller size, more levels, or a structured (swept) hex mesh | | | | 874 |
| loft-transition | organic | 1 | 6 | 292 | 0.075 | 56 | 56390.2 | 56450.6 | -1.1e-3 | – | ok, 148 vertices | 0 | 40 | 0.7 | 1329 | 256 | 1 | pass | 36 | sweep | 2873 | 0.189 | 0.189 | 329 | 67188 | 0.210 | 0.959 | -1.2e-3 | 16559 |
| perforated-plate | genus, thin-wall, sweepable | 1 | 54 | 1644 | 0.144 | 252 | 26822.2 | 26764.6 | 2.2e-3 | 26764.2 | ok, 776 vertices | 24 | 828 | 4.7 | 33611 | 9798 | 0 | pass | 1276 | extrude | 467 | 0.304 | 0.033 | 98 | 185128 | 0.300 | 0.958 | 2.5e-3 | 70289 |
| pinhole-block | small-feature, genus | 1 | 38 | 4044 | 0.073 | 195 | 49919.4 | 49909.6 | 2.0e-4 | 49909.5 | ok, 1992 vertices | 16 | 1548 | 12.7 | 96411 | 30221 | 0 | pass | 4479 | extrude | 5236 | 0.408 | 0.392 | 587 | no: Block: meshing failed: octree hex meshing failed after 2 builds: the feature corner at [9.76, 10.180000000000001, 10.526315789473685] has no vertex; refining for another build needs more than 250000 grid cells (the grid needs more than 250000 cell | | | | 2009 |
| rounded-box | fillet | 1 | 26 | 2540 | 0.054 | 147 | 23325.7 | 23340.5 | -6.3e-4 | 23340.8 | ok, 1272 vertices | 0 | 216 | 6.3 | 11701 | 1928 | 0 | pass | 387 | no: Box: not supported yet: no structured hex method fits this body (extrude: cylindrical face 6 is not parallel to the direction; sweep: from face 0: 12 faces are not on the sweep path; from face 5: 12 faces are not on the sweep path; revolve: plan | | | | 14 | 76430 | 0.561 | 0.985 | -1.3e-3 | 26673 |
| shallow-groove | near-tangent, boolean | 1 | 8 | 62 | 0.075 | 11 | 47795.2 | 47782.3 | 2.7e-4 | 47781.5 | ok, 33 vertices | 0 | 24 | 0.2 | 267 | 87 | 0 | pass | 12 | extrude | 1960 | 0.991 | 0.986 | 196 | 2926 | 0.301 | 0.920 | 3.4e-4 | 982 |
| sharp-wedge | sharp, thin-wall, sweepable | 1 | 5 | 8 | 0.083 | 2 | 3200.0 | 3200.0 | 0.0e0 | 3200.0 | ok, 6 vertices | 0 | 9 | 0.0 | 11 | 3 | 0 | pass | 0 | extrude | 294 | 0.050 | 0.050 | 30 | 84952 | 0.003 | 0.980 | -3.5e-2 | 23994 |
| sphere-on-plate | near-tangent, boolean | 1 | 12 | 2826 | 0.098 | 686 | 69353.3 | 69509.0 | -2.2e-3 | 69509.7 | ok, 1415 vertices | 0 | 124 | 5.9 | 4898 | 309 | 9 | pass | 227 | no: Plate: not supported yet: no structured hex method fits this body (extrude: face 6 is curved across the direction; sweep: from face 0: 6 faces are not on the sweep path; from face 5: linking face 11 has 2 boundary loops (periodic or holed linking | | | | 10 | 25194 | 0.065 | 0.945 | 4.4e-2 | 20477 |
| spline-vase | organic | 1 | 11 | 3890 | 0.106 | 217 | 94891.2 | 94948.5 | -6.0e-4 | – | ok, 1947 vertices | 0 | 243 | 8.6 | 12461 | 1512 | 0 | pass | 439 | sweep | 4554 | 0.649 | 0.649 | 522 | no: Vase: limit reached: the grid needs more than 500000 cells | | | | 2308 |
| stepped-block | blocky, sharp | 1 | 14 | 96 | 0.078 | 29 | 47000.0 | 47000.0 | 0.0e0 | 47000.0 | ok, 48 vertices | 1 | 60 | 0.4 | 223 | 41 | 0 | pass | 7 | extrude | 1416 | 0.999 | 0.999 | 137 | 52274 | 0.076 | 0.975 | -2.7e-6 | 14558 |
| thin-ribs | thin-wall, blocky | 1 | 26 | 92 | 0.119 | 62 | 32000.0 | 32000.0 | -4.5e-16 | 32000.0 | ok, 48 vertices | 0 | 72 | 0.5 | 171 | 33 | 0 | pass | 7 | extrude | 777 | 0.844 | 0.844 | 76 | no: Plate: limit reached: the grid needs more than 500000 cells | | | | 1111 |
| thin-wall-enclosure | thin-wall, blocky | 1 | 11 | 36 | 0.108 | 8 | 15564.0 | 15564.0 | 0.0e0 | 15564.0 | ok, 20 vertices | 0 | 28 | 0.1 | 191 | 47 | 2 | pass | 5 | extrude | 894 | 0.297 | 0.297 | 89 | no: Enclosure: meshing failed: the core's boundary has components of genus [0, 0], the part [0]: the part has walls 1.000 thick, thinner than 3 cells of the deepest level (0.479); use a smaller size, more levels, or a structured (swept) hex mesh | | | | 1623 |
| analytic-sphere | analytic | 1 | 6 | 2784 | 0.069 | 247 | 33353.4 | 33507.9 | -4.6e-3 | 33510.3 | ok, 1394 vertices | 0 | 96 | 5.9 | 4211 | 58 | 0 | pass | 266 | no: Sphere: not supported yet: no structured hex method fits this body (extrude: no direction to try; sweep: no planar face to sweep from; revolve: no axis to try; mapped: meshing failed: meshing failed: mesh validity gate: 8 inverted or degenerate ele | | | | 23 | 2400 | 0.538 | 0.866 | -1.3e-2 | 966 |
| analytic-torus | analytic, genus | 1 | 6 | 7230 | 0.109 | 337 | 37644.1 | 37893.7 | -6.6e-3 | 37899.3 | ok, 3615 vertices | 1 | 180 | 15.8 | 19790 | 466 | 0 | pass | 761 | revolve | 1134 | 0.706 | 0.706 | 168 | 18814 | 0.460 | 0.881 | -1.2e-2 | 5646 |
| analytic-cylinder | analytic, sweepable | 1 | 4 | 1004 | 0.049 | 26 | 12533.1 | 12564.7 | -2.5e-3 | 12566.4 | ok, 504 vertices | 0 | 106 | 2.7 | 7437 | 1568 | 0 | pass | 267 | extrude | 3200 | 0.707 | 0.707 | 331 | 2424 | 0.532 | 0.919 | -9.1e-3 | 699 |
| analytic-pipe | analytic, genus, sweepable | 1 | 6 | 1448 | 0.104 | 44 | 94150.9 | 94238.0 | -9.3e-4 | 94247.8 | ok, 724 vertices | 1 | 220 | 3.8 | 6238 | 1340 | 0 | pass | 272 | extrude | 1288 | 0.998 | 0.998 | 145 | 53680 | 0.520 | 0.944 | -7.8e-4 | 14728 |
| analytic-plate-hole | analytic, genus, sweepable | 1 | 8 | 112 | 0.224 | 11 | 98449.1 | 98429.5 | 2.0e-4 | 98429.2 | ok, 56 vertices | 1 | 62 | 0.4 | 1868 | 543 | 0 | pass | 53 | extrude | 321 | 0.320 | 0.142 | 38 | 163664 | 0.309 | 0.989 | 2.2e-4 | 51714 |
| part | categories | bodies | faces | triangles | tol. mm | build ms | enclosed mm³ | SolidCraft mm³ | rel. diff | exact mm³ | boundary | genus | feature edges | check ms | CDT tets | Steiner | cavities | tet gate | CDT ms | quality tets | min dihedral ° | max dihedral ° | min radius ratio | slivers CDT → mesh | quality gate | quality ms | hex | hex elements | hex8 min SJ | hex20 min SJ | hex ms | octree hexes | min SJ | mean SJ | hex vol. diff | octree ms |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| bent-tube | sweepable, thin-wall, genus | 1 | 18 | 4296 | 0.121 | 458 | 4213.1 | 4159.4 | 1.3e-2 | 4159.8 | ok, 2148 vertices | 1 | 268 | 14.5 | 15300 | 2338 | 0 | 20 flat | 600 | 25100 | 27.4 | 149.0 | 0.091 | 3208 → 0 | pass | 3687 | sweep | 192 | 0.859 | 0.997 | 65 | no: Tube: meshing failed: octree hex meshing failed after 2 builds: the core's boundary has components of genus [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, | | | | 2565 |
| bolted-assembly | assembly, blocky | 3 | 22 | 284 | 0.117 | 57 | 33004.7 | 32999.0 | 1.7e-4 | 32998.9 | ok, 144 vertices | 0, 1, 1 | 104 | 0.7 | 687 | 181 | 0 | pass | 34 | 14750 | 33.0 | 143.2 | 0.236 | 189 → 0 | pass | 1270 | extrude | 377 | 0.446 | 0.446 | 2462 | 41344 | 0.323 | 0.961 | 2.0e-4 | 11596 |
| cantilever-i-beam | sweepable, thin-wall | 1 | 14 | 44 | 0.208 | 4 | 61600.0 | 61600.0 | 0.0e0 | 61600.0 | ok, 24 vertices | 0 | 36 | 0.2 | 90 | 24 | 0 | pass | 3 | 6357 | 29.9 | 149.4 | 0.070 | 70 → 0 | pass | 1373 | extrude | 480 | 0.308 | 0.308 | 55 | 351220 | 0.308 | 0.977 | -9.1e-14 | 208396 |
| cross-cylinders | boolean, sharp | 1 | 10 | 1539 | 0.087 | 6789 | 32196.4 | 32357.4 | -5.0e-3 | 32365.8 | error: Cross: invalid boundary surface: 28 defects; triangles 142 and 254 are oriented inconsistently across edge 0–396 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 251 and 254 are oriented inconsistently across edge 0–553 at (-18.75000000000002, 5.634596901641276, 7.632255089815496); edge 103–108 at (-0.16007784063084018, 0.03984236936862052, 9.998639270596865) is shared by 3 triangles; … | | | | error: Cross: invalid boundary surface: 28 defects; triangles 142 and 254 are oriented inconsistently across edge 0–396 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 251 and 254 are oriented inconsistently across edge 0–553 at (-18.75000000000002, 5.634596901641276, 7.632255089815496); edge 103–108 at (-0.16007784063084018, 0.03984236936862052, 9.998639270596865) is shared by 3 triangles; … | | | | | error: Cross: invalid boundary surface: 28 defects; triangles 142 and 254 are oriented inconsistently across edge 0–396 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 251 and 254 are oriented inconsistently across edge 0–553 at (-18.75000000000002, 5.634596901641276, 7.632255089815496); edge 103–108 at (-0.16007784063084018, 0.03984236936862052, 9.998639270596865) is shared by 3 triangles; … | | | | | | | error: Cross: invalid boundary surface: 28 defects; triangles 142 and 254 are oriented inconsistently across edge 0–396 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 251 and 254 are oriented inconsistently across edge 0–553 at (-18.75000000000002, 5.634596901641276, 7.632255089815496); edge 103–108 at (-0.16007784063084018, 0.03984236936862052, 9.998639270596865) is shared by 3 triangles; … | | | | | error: Cross: invalid boundary surface: 28 defects; triangles 142 and 254 are oriented inconsistently across edge 0–396 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 251 and 254 are oriented inconsistently across edge 0–553 at (-18.75000000000002, 5.634596901641276, 7.632255089815496); edge 103–108 at (-0.16007784063084018, 0.03984236936862052, 9.998639270596865) is shared by 3 triangles; … | | | | |
| filleted-bracket | fillet, genus, sweepable | 1 | 13 | 544 | 0.088 | 453 | 32162.3 | 32145.0 | 5.4e-4 | 32144.8 | ok, 270 vertices | 2 | 135 | 1.9 | 5576 | 1613 | 0 | pass | 206 | 15665 | 28.4 | 149.2 | 0.097 | 2158 → 0 | pass | 2757 | no: Bracket: not supported yet: no structured hex method fits this body (extrude: cylindrical face 8 is not parallel to the direction; sweep: from face 1: linking face 6 has 2 boundary loops (periodic or holed linking faces are not swept); from face 6: | | | | 5 | 30676 | 0.305 | 0.963 | 8.3e-4 | 16975 |
| filleted-bracket-blocks | fillet, genus, partitioned | 30 | 190 | 712 | 0.088 | 2081 | 32169.8 | 32145.1 | 7.7e-4 | 32144.8 | ok, 416 vertices | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 | 474 | 3.9 | 1414 | 349 | 0 | 2 flat | 93 | 176280 | 33.6 | 142.7 | 0.190 | 282 → 0 | pass | 15596 | blocks (30 cells, 45 shared faces) | 4986 | 0.705 | 0.705 | 836 | 149898 | 0.157 | 0.935 | 9.7e-4 | 44280 |
| i-beam | sweepable, thin-wall | 1 | 14 | 44 | 0.208 | 3 | 61600.0 | 61600.0 | 0.0e0 | 61600.0 | ok, 24 vertices | 0 | 36 | 0.1 | 91 | 24 | 0 | 1 flat | 4 | 6154 | 26.4 | 153.6 | 0.053 | 71 → 0 | pass | 1514 | extrude | 480 | 0.308 | 0.308 | 59 | 351220 | 0.308 | 0.977 | 2.3e-13 | 116641 |
| loft-transition | organic | 1 | 6 | 292 | 0.075 | 60 | 56390.2 | 56450.6 | -1.1e-3 | – | ok, 148 vertices | 0 | 40 | 0.8 | 1328 | 256 | 1 | pass | 37 | 9691 | 31.2 | 140.6 | 0.133 | 591 → 0 | pass | 1116 | sweep | 2873 | 0.189 | 0.189 | 339 | 67188 | 0.210 | 0.959 | -1.2e-3 | 16530 |
| perforated-plate | genus, thin-wall, sweepable | 1 | 54 | 1644 | 0.144 | 236 | 26822.2 | 26764.6 | 2.2e-3 | 26764.2 | ok, 776 vertices | 24 | 828 | 5.2 | 33560 | 9798 | 0 | 1 flat | 1293 | 44028 | 27.8 | 148.7 | 0.126 | 11707 → 0 | pass | 8434 | extrude | 768 | 0.496 | 0.496 | 224 | 185128 | 0.300 | 0.958 | 2.5e-3 | 72087 |
| pinhole-block | small-feature, genus | 1 | 38 | 4044 | 0.073 | 903 | 49919.4 | 49909.6 | 2.0e-4 | 49909.5 | ok, 1992 vertices | 16 | 1548 | 14.4 | 96203 | 30221 | 0 | 17 flat | 6474 | 843460 | 14.0 | 166.0 | 0.000 | 65489 → 0 | pass | 124949 | extrude | 5236 | 0.408 | 0.392 | 1368 | no: Block: limit reached: the grid has 675798 elements; the limit is 500000 | | | | 2246 |
| rounded-box | fillet | 1 | 26 | 2540 | 0.054 | 157 | 23325.7 | 23340.5 | -6.3e-4 | 23340.8 | ok, 1272 vertices | 0 | 216 | 7.2 | 11348 | 1906 | 0 | 18 flat | 385 | 62949 | 24.8 | 160.2 | 0.001 | 8780 → 0 | pass | 24516 | no: Box: not supported yet: no structured hex method fits this body (extrude: cylindrical face 7 is not parallel to the direction; sweep: from face 0: 12 faces are not on the sweep path; from face 5: 12 faces are not on the sweep path; revolve: plan | | | | 21 | 76430 | 0.561 | 0.985 | -1.3e-3 | 39852 |
| shallow-groove | near-tangent, boolean | 1 | 8 | 62 | 0.075 | 41 | 47795.2 | 47782.3 | 2.7e-4 | 47781.5 | ok, 33 vertices | 0 | 24 | 0.2 | 266 | 87 | 0 | pass | 14 | 6318 | 37.8 | 136.6 | 0.297 | 85 → 0 | pass | 811 | extrude | 1960 | 0.991 | 0.986 | 295 | 2926 | 0.301 | 0.920 | 3.4e-4 | 1525 |
| sharp-wedge | sharp, thin-wall, sweepable | 1 | 5 | 8 | 0.083 | 10 | 3200.0 | 3200.0 | 0.0e0 | 3200.0 | ok, 6 vertices | 0 | 9 | 0.1 | 11 | 3 | 0 | pass | 1 | 1273 | 2.9 | 176.6 | 0.003 | 3 → 24 | pass | 544 | extrude | 1674 | 0.014 | 0.014 | 415 | 84952 | 0.003 | 0.980 | -3.5e-2 | 35349 |
| sphere-on-plate | near-tangent, boolean | 1 | 12 | 2826 | 0.098 | 1306 | 69353.3 | 69509.0 | -2.2e-3 | 69509.7 | ok, 1415 vertices | 0 | 124 | 7.9 | 4370 | 309 | 9 | pass | 298 | 11792 | 32.7 | 141.3 | 0.235 | 3001 → 0 | pass | 2893 | no: Plate: not supported yet: no structured hex method fits this body (extrude: face 6 is curved across the direction; sweep: from face 0: 6 faces are not on the sweep path; from face 5: linking face 11 has 2 boundary loops (periodic or holed linking | | | | 17 | 25194 | 0.065 | 0.945 | 4.3e-2 | 31594 |
| spline-vase | organic | 1 | 11 | 3890 | 0.106 | 214 | 94891.2 | 94948.5 | -6.0e-4 | – | ok, 1947 vertices | 0 | 243 | 10.3 | 11859 | 1512 | 0 | 72 flat | 468 | 38225 | 29.1 | 150.8 | 0.033 | 7206 → 0 | pass | 15955 | sweep | 4536 | 0.665 | 0.665 | 555 | 43579 | 0.387 | 0.927 | -1.5e-3 | 18037 |
| stepped-block | blocky, sharp | 1 | 14 | 52 | 0.078 | 556 | 47000.0 | 47000.0 | 0.0e0 | 47000.0 | ok, 26 vertices | 1 | 36 | 0.3 | 143 | 32 | 0 | pass | 7 | 5063 | 38.3 | 139.2 | 0.225 | 15 → 0 | pass | 677 | extrude | 1416 | 1.000 | 1.000 | 342 | no: Steps: meshing failed: octree hex meshing failed after 2 builds: 4 elements fail the certified validity check; refining for another build needs more than 250000 grid cells (the grid needs more than 250000 cells) | | | | 15666 |
| thin-ribs | thin-wall, blocky | 1 | 26 | 92 | 0.119 | 619 | 32000.0 | 32000.0 | -4.5e-16 | 32000.0 | ok, 48 vertices | 0 | 72 | 0.7 | 171 | 33 | 0 | pass | 13 | 23922 | 24.2 | 155.7 | 0.058 | 141 → 0 | pass | 9970 | extrude | 1659 | 0.124 | 0.124 | 351 | no: Plate: limit reached: the part has walls 0.8000 thick, which 3 cells across need cells of 0.2667: 3 levels below a size of 4.7625, more than 2588948 grid cells by level 3; or 2 levels below a size of 2.4000, more than 1284890 grid cells by level 2 | | | | 234 |
| thin-wall-enclosure | thin-wall, blocky | 1 | 11 | 28 | 0.108 | 54 | 15564.0 | 15564.0 | 0.0e0 | 15564.0 | ok, 16 vertices | 0 | 24 | 0.1 | 80 | 18 | 4 | pass | 3 | 19237 | 23.8 | 153.6 | 0.037 | 78 → 0 | pass | 6188 | extrude | 926 | 0.707 | 0.707 | 118 | no: Enclosure: limit reached: the part has walls 1.0000 thick, which 3 cells across need cells of 0.3333: 3 levels below a size of 4.3081, more than 3945836 grid cells by level 3; or 2 levels below a size of 3.0000, more than 1023496 grid cells by level 2 | | | | 183 |
| analytic-sphere | analytic | 1 | 6 | 2784 | 0.069 | 309 | 33353.4 | 33507.9 | -4.6e-3 | 33510.3 | ok, 1394 vertices | 0 | 96 | 7.7 | 3671 | 58 | 0 | pass | 334 | 10074 | 37.0 | 140.1 | 0.230 | 2448 → 0 | pass | 1958 | no: Sphere: not supported yet: no structured hex method fits this body (extrude: no direction to try; sweep: no planar face to sweep from; revolve: no axis to try; mapped: meshing failed: meshing failed: mesh validity gate: 8 inverted or degenerate ele | | | | 28 | 2400 | 0.538 | 0.866 | -1.3e-2 | 1033 |
| analytic-torus | analytic, genus | 1 | 6 | 7230 | 0.109 | 317 | 37644.1 | 37893.7 | -6.6e-3 | 37899.3 | ok, 3615 vertices | 1 | 180 | 18.5 | 18528 | 466 | 0 | 9 flat | 812 | 22988 | 33.4 | 145.0 | 0.154 | 12988 → 0 | pass | 4503 | revolve | 1080 | 0.834 | 0.834 | 165 | 18814 | 0.460 | 0.881 | -1.2e-2 | 5674 |
| analytic-cylinder | analytic, sweepable | 1 | 4 | 1004 | 0.049 | 26 | 12533.1 | 12564.7 | -2.5e-3 | 12566.4 | ok, 504 vertices | 0 | 106 | 3.0 | 7382 | 1568 | 0 | pass | 269 | 20056 | 26.2 | 152.2 | 0.089 | 4373 → 0 | pass | 6907 | extrude | 3200 | 0.850 | 0.850 | 346 | 2424 | 0.532 | 0.919 | -9.1e-3 | 701 |
| analytic-pipe | analytic, genus, sweepable | 1 | 6 | 1448 | 0.104 | 44 | 94150.9 | 94238.0 | -9.3e-4 | 94247.8 | ok, 724 vertices | 1 | 220 | 4.3 | 6193 | 1340 | 0 | pass | 280 | 16627 | 31.4 | 145.5 | 0.150 | 1445 → 0 | pass | 2490 | extrude | 1288 | 0.998 | 0.998 | 149 | 53680 | 0.520 | 0.944 | -7.8e-4 | 21679 |
| analytic-plate-hole | analytic, genus, sweepable | 1 | 8 | 112 | 0.224 | 65 | 98449.1 | 98429.5 | 2.0e-4 | 98429.2 | ok, 56 vertices | 1 | 62 | 0.9 | 1848 | 543 | 0 | pass | 87 | 2394 | 31.4 | 145.3 | 0.230 | 342 → 0 | pass | 904 | extrude | 488 | 0.642 | 0.655 | 403 | 163664 | 0.309 | 0.989 | 2.2e-4 | 112185 |
## Categories
+24 -4
View File
@@ -398,13 +398,21 @@ Delaunay methods with Voronoi/ODT improvement, then (2) all-hex and hex-dominant
- **Implemented** in `fieldcraft-mesh::tet::{ops, improve}` from the two papers: Klincsek's dynamic programming for edge
removal (interior edges; boundary edges of one flat B-rep face end in a 2-2 flip), multi-face removal grown greedily
over the sandwiched faces, nonsmooth optimization smoothing (active set, smallest vector of the convex hull of the
active gradients, line search; gradients by central differences), and the composite vertex insertion with rollback
active gradients, line search; analytic gradients of the active measure, as §3.1 and Freitag–Ollivier-Gooch 1997
use), and the composite vertex insertion with rollback
(barycentres of boundary faces, of the tetrahedron, midpoints of boundary edges, and a point inside off a flat
boundary edge). Deviations: the inserted vertex takes the Delaunay cavity rather than the optimal cut of §3.4; each pass
smooths and flips the tetrahedra below sin 40° and inserts only when the worst did not improve; passes and the
insertion list are capped. Objective: the minimum dihedral sine. Every operation is a checked replacement of a region
insertion list are capped. Objective: the smaller of the minimum dihedral sine and the scaled Jacobian at the worst
corner weighted so that a Jacobian of 0.3 counts as a 30° dihedral angle: the sine misses needles (every dihedral
angle fine, one corner nearly flat; Shewchuk 2002); the scaled Jacobian analysts check (Knupp 2001, B6.6) catches
them, and with the weight it decides only near that limit. Every operation is a checked replacement of a region
(positive by exact `orient3d`, faces matched with opposite orientation, removed vertices fully inside), so the mesh is
valid after each step.
valid after each step. Speed (#84): an operation (topological, per tetrahedron, edge and face; or a smoothing) that
failed is not tried again until a tetrahedron it reads changes (a change clock per vertex; exact, because the
operations are functions of that region and the quality is computed on the corners in a canonical order), live
tetrahedra's qualities are cached, Klincsek's table starts at the quality to beat, and the composite insertion runs
its flips and neighbour smoothing only where a tetrahedron is below the threshold.
**A3.7 Cheng, Dey, Edelsbrunner, Facello, Teng 2000, Sliver exudation.** **Verdict: reference.**
- **Citation.** J. ACM 47(5):883–904, 2000. DOI [10.1145/355483.355487](https://doi.org/10.1145/355483.355487).
@@ -574,6 +582,15 @@ Delaunay methods with Voronoi/ODT improvement, then (2) all-hex and hex-dominant
hexahedral mesh of it exists (Mitchell's parity condition). Concave refined regions therefore
need their concave cells refined. The fix is a modified edge rule (an edge with a marked end
splits in three) with buffer layers in the face templates.
- **Finding (#85, 2026-10).** Read in full (§5.3, Fig. 57–61). Under the modified rule the 256 mark
patterns fall into 14 classes of split edges; the faces are plain, one-corner (5 quads),
three-edge (template 2a, 10 quads) or full 3 × 3, so parity never fails and seven marks are the
27-split. The six-mark cell (a concave edge) is given only as a sketch ("the two trapezoidal
faces become a dangling face"). Working it through: its two 2a faces both fold their three
columns into the one unsplit edge, the cell corner there has five surface quads, and in the
sheet structure the chord through the two trapezoids must cross a transverse sheet twice, so
every hexahedral filling folds a sheet back on itself near that edge. No construction with
usable quality was found, so the rule was not adopted.
**B1.8 Ito, Shih, Soni 2009, Octree-based reasonable-quality hexahedral mesh generation using a
new set of refinement templates.** **Verdict: reference.**
@@ -606,7 +623,10 @@ new set of refinement templates.** **Verdict: reference.**
feature map of B1.4 (patches first); padding plus a pillow per patch (B1.4 §3.4); the B6.4 energy
with a fitting term (B1.4 §3.5); the certified check of B6.6 with local repair. The dual schemes
of B1.5 were not adopted (B1.6 finding); concave refined regions are closed by refinement (B1.7
limit), so curvature refines one level only.
limit), so curvature refines one level only. Since #85 the levels and root size are chosen before
the grid is built, from the thinnest wall (measured to a facing wall only: a ray that leaves
through the face beyond a convex edge measures how near the edge it started, not a wall) and the
narrowest patch; a part beyond the levels or cells allowed fails at once with the numbers.
---
+34 -44
View File
@@ -19,7 +19,7 @@ with (a set on them finds nothing); `marked` the elements failing the limits bef
relaxing, and what relaxing changed; ¹ a tet mesh has no edge ratio: its largest aspect ratio
stands in; `duplicates` coincident nodes and cracks; `geometry.check` its findings at the size;
`verdict` pass when the minimum scaled Jacobian and the largest edge ratio meet the limits and
the volume is within 2 % (`pass (fallback)` when a fallback meshed it).
the volume is within 1 % (`pass (fallback)` when a fallback meshed it).
The section pictures cut each part through the middle of its longest side, marked elements red.
Times are wall clock on the machine that ran it.
@@ -27,43 +27,41 @@ Times are wall clock on the machine that ran it.
| part | method | symmetry | elements | min SJ | mean SJ | max edge ratio¹ | volume error | untagged faces | marked → after relax | duplicates | geometry.check | time s | verdict | section |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| bent-tube | plan (sweep) | – | 192 (192 hex8) | 0.861 | 0.865 | 5.0 | -1.7e-1 | 0 | 0 (not relaxed) | none | 8 near_faces, 2 sliver_face | 1.9 | fail (volume) | [y=37.500](skill/bent-tube.png) |
| bolted-assembly | plan (sweep), mesh.hex.generate (auto) | – | 6272 (6272 hex8) | 0.583 | 0.939 | 3.6 | 2.0e-4 | 0 | 0 (not relaxed) | 8 coincident, 0 cracks | – | 3.9 | pass | [x=50.000](skill/bolted-assembly.png) |
| cantilever-i-beam | plan (sweep) | – | 61600 (61600 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 5.7 | pass | [x=100.000](skill/cantilever-i-beam.png) |
| cross-cylinders | none | – | – | – | – | – | – | – | – | – | – | 18.3 | no mesh | – |
| filleted-bracket | tet10 | – | 19557 (19557 tet10) | 0.042 | 0.519 | 9.1 | -6.7e-6 | 0 | 1709 (not relaxed) | none | – | 35.5 | fail (SJ) | [x=30.000](skill/filleted-bracket.png) |
| filleted-bracket-blocks | plan (sweep) | – | 75410 (75410 hex8) | 0.704 | 0.954 | 3.5 | 3.7e-5 | 0 | 0 (not relaxed) | none (after merging 2348 nodes) | – | 16.7 | pass | [x=30.000](skill/filleted-bracket-blocks.png) |
| i-beam | plan (sweep) | – | 61600 (61600 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 9.3 | pass | [z=100.000](skill/i-beam.png) |
| loft-transition | plan (sweep) | – | 2873 (2873 hex8) | 0.189 | 0.960 | 2.2 | -2.1e-3 | 0 | 4 → 4 (4 relaxed, 0 undone; min SJ 0.189 → 0.189, max ratio 1.7 → 2.2) | none | – | 2.3 | fail (SJ) | [z=25.000](skill/loft-transition.png) |
| perforated-plate | plan (project) | 2 plane(s) | 8576 (8576 hex8) | 0.707 | 0.888 | 3.0 | 7.7e-4 | 0 | 0 (not relaxed) | none | – | 33.5 | pass | [x=60.000](skill/perforated-plate.png) |
| pinhole-block | plan (project) | 2 plane(s) | 5068 (5068 hex8) | 0.707 | 0.942 | 18.4 | 8.1e-5 | 0 | 1680 (not relaxed) | none | 32 sliver_face | 24.0 | fail (ratio) | [y=25.000](skill/pinhole-block.png) |
| rounded-box | plan (project) | 2 plane(s) | 9260 (9260 hex8) | 0.734 | 0.985 | 2.2 | -4.1e-3 | 0 | 0 (not relaxed) | none | – | 10.3 | pass | [x=20.000](skill/rounded-box.png) |
| bent-tube | plan (sweep) | – | 22272 (22272 hex8) | 0.998 | 0.998 | 1.4 | -2.8e-3 | 0 | 0 (not relaxed) | none | – | 3.7 | pass | [y=37.500](skill/bent-tube.png) |
| bolted-assembly | plan (sweep), mesh.hex.generate (auto) | – | 6272 (6272 hex8) | 0.583 | 0.939 | 3.6 | 2.0e-4 | 0 | 0 (not relaxed) | 8 coincident, 0 cracks | – | 3.7 | pass | [x=50.000](skill/bolted-assembly.png) |
| cantilever-i-beam | plan (sweep) | – | 61600 (61600 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 8.5 | pass | [x=100.000](skill/cantilever-i-beam.png) |
| cross-cylinders | none | – | – | – | – | – | – | – | – | – | – | 27.2 | no mesh | – |
| filleted-bracket | tet10 | – | 20020 (20020 tet10) | 0.320 | 0.562 | 4.1 | -6.8e-6 | 0 | 0 (not relaxed) | none | – | 12.7 | pass (fallback) | [x=30.000](skill/filleted-bracket.png) |
| filleted-bracket-blocks | plan (sweep) | – | 75410 (75410 hex8) | 0.704 | 0.954 | 3.5 | 3.7e-5 | 0 | 0 (not relaxed) | none (after merging 2348 nodes) | – | 6.9 | pass | [x=30.000](skill/filleted-bracket-blocks.png) |
| i-beam | plan (sweep) | – | 61600 (61600 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 4.5 | pass | [z=100.000](skill/i-beam.png) |
| loft-transition | plan (sweep) | – | 2873 (2873 hex8) | 0.189 | 0.959 | 1.7 | -2.1e-3 | 0 | 4 → 4 (4 relaxed, 0 undone; min SJ 0.189 → 0.189, max ratio 1.7 → 1.7) | none | – | 0.9 | fail (SJ) | [z=25.000](skill/loft-transition.png) |
| perforated-plate | plan (project) | 2 plane(s) | 8576 (8576 hex8) | 0.707 | 0.888 | 3.0 | 7.7e-4 | 0 | 0 (not relaxed) | none | – | 9.5 | pass | [x=60.000](skill/perforated-plate.png) |
| pinhole-block | plan (project) | 2 plane(s) | 40768 (40768 hex8) | 0.707 | 0.984 | 8.0 | 8.1e-5 | 0 | 0 (not relaxed) | none | – | 25.5 | pass | [y=25.000](skill/pinhole-block.png) |
| rounded-box | plan (project) | 2 plane(s) | 11520 (11520 hex8) | 0.717 | 0.973 | 4.0 | -4.1e-3 | 0 | 0 (not relaxed) | none | – | 8.7 | pass | [x=20.000](skill/rounded-box.png) |
| shallow-groove | plan (sweep) | – | 1960 (1960 hex8) | 0.991 | 0.999 | 1.1 | 2.7e-4 | 0 | 0 (not relaxed) | none | – | 0.2 | pass | [x=30.000](skill/shallow-groove.png) |
| sharp-wedge | plan (sweep) | – | 1674 (1674 hex8) | 0.014 | 0.301 | 149.6 | 2.7e-15 | 0 | 1524 (not relaxed) | none | – | 0.2 | fail (SJ, ratio) | [x=40.000](skill/sharp-wedge.png) |
| sphere-on-plate | tet10 | – | 15073 (15073 tet10) | 0.121 | 0.558 | 6.2 | -1.1e-4 | 0 | 662 (not relaxed) | none | – | 5.0 | fail (SJ) | [y=0.000](skill/sphere-on-plate.png) |
| spline-vase | plan (sweep) | – | 4536 (4536 hex8) | 0.665 | 0.945 | 9.9 | -6.6e-3 | 0 | 0 (not relaxed) | none | – | 0.8 | pass | [z=-35.000](skill/spline-vase.png) |
| stepped-block | plan (cells) | – | 1416 (1416 hex8) | 1.000 | 1.000 | 1.3 | -4.8e-15 | 0 | 0 (not relaxed) | none | – | 0.5 | pass | [x=30.000](skill/stepped-block.png) |
| thin-ribs | plan (sweep) | – | 301308 (301308 hex8) | 0.999 | 1.000 | 1.3 | -3.4e-12 | 0 | 0 (not relaxed) | none | – | 34.0 | pass | [x=50.000](skill/thin-ribs.png) |
| thin-wall-enclosure | plan (sweep) | – | 124512 (124512 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 21.8 | pass | [x=40.000](skill/thin-wall-enclosure.png) |
| analytic-sphere | mesh.hex.project (sphere) | – | 864 (864 hex8) | 0.702 | 0.929 | 2.7 | -3.0e-2 | 0 | 0 (not relaxed) | none | – | 2.5 | fail (volume) | [z=0.000](skill/analytic-sphere.png) |
| analytic-torus | plan (sweep) | – | 1080 (1080 hex8) | 0.834 | 0.909 | 2.4 | -4.7e-2 | 0 | 0 (not relaxed) | none | – | 1.0 | fail (volume) | [x=0.000](skill/analytic-torus.png) |
| analytic-cylinder | plan (project) | – | 1700 (1700 hex8) | 0.845 | 0.970 | 1.9 | -1.6e-2 | 0 | 0 (not relaxed) | none | – | 1.4 | pass | [z=20.000](skill/analytic-cylinder.png) |
| analytic-pipe | plan (project) | – | 1008 (1008 hex8) | 0.996 | 0.996 | 1.4 | -5.0e-3 | 0 | 0 (not relaxed) | none | – | 1.0 | pass | [z=30.000](skill/analytic-pipe.png) |
| analytic-plate-hole | plan (project) | – | 5856 (5856 hex8) | 0.937 | 0.996 | 3.0 | 4.3e-5 | 0 | 0 (not relaxed) | none | – | 5.8 | pass | [x=100.000](skill/analytic-plate-hole.png) |
| crank-arm | plan (project) | – | 1372 (1372 hex8) | 0.544 | 0.860 | 10.5 | -3.3e-3 | 0 | 9 (9 relaxations undone: worse) | none | 16 short_edge, 9 sliver_face | 48.2 | fail (ratio) | [x=81.000](skill/crank-arm.png) |
| upright-bracket | plan (sweep) | – | 1364 (1364 hex8) | 0.707 | 0.968 | 3.1 | 2.1e-4 | 0 | 0 (not relaxed) | none | – | 0.2 | pass | [x=30.000](skill/upright-bracket.png) |
| pi5-base | plan (project) | 1 plane(s) | 29852 (29852 hex8) | 0.683 | 0.969 | 4.9 | -3.0e-3 | 0 | 0 (not relaxed) | none | 8 short_edge, 4 sliver_face | 197.5 | pass | [x=56.000](skill/pi5-base.png) |
| pi5-lid | plan (project) | – | 5130 (5130 hex8) | 0.724 | 0.868 | 3.9 | -2.9e-4 | 0 | 0 (not relaxed) | none | – | 10.7 | pass | [x=56.000](skill/pi5-lid.png) |
| cup | plan (project) | – | 1114 (1114 hex8) | 0.701 | 0.921 | 5.0 | -4.5e-3 | 0 | 0 (not relaxed) | none | – | 3.0 | pass | [x=5.000](skill/cup.png) |
| plain-cup | mesh.hex.generate (auto) | – | 2000 (2000 hex8) | 0.820 | 0.995 | 4.7 | -2.3e-3 | 0 | 0 (not relaxed) | none | – | 0.9 | pass (fallback) | [x=0.000](skill/plain-cup.png) |
| sphere-on-plate | tet10 | – | 24175 (24175 tet10) | 0.341 | 0.604 | 4.2 | -9.6e-5 | 0 | 0 (not relaxed) | none | – | 6.5 | pass (fallback) | [y=0.000](skill/sphere-on-plate.png) |
| spline-vase | plan (sweep) | – | 4536 (4536 hex8) | 0.665 | 0.945 | 9.9 | -6.6e-3 | 0 | 0 (not relaxed) | none | – | 2.3 | pass | [z=-35.000](skill/spline-vase.png) |
| stepped-block | plan (cells) | – | 1416 (1416 hex8) | 1.000 | 1.000 | 1.3 | -4.8e-15 | 0 | 0 (not relaxed) | none | – | 1.6 | pass | [x=30.000](skill/stepped-block.png) |
| thin-ribs | plan (sweep) | – | 301308 (301308 hex8) | 0.999 | 1.000 | 1.3 | -3.4e-12 | 0 | 0 (not relaxed) | none | – | 34.7 | pass | [x=50.000](skill/thin-ribs.png) |
| thin-wall-enclosure | plan (sweep) | – | 124512 (124512 hex8) | 1.000 | 1.000 | 1.0 | 0.0e0 | 0 | 0 (not relaxed) | none | – | 13.5 | pass | [x=40.000](skill/thin-wall-enclosure.png) |
| analytic-sphere | mesh.hex.project (sphere) | – | 3400 (3400 hex8) | 0.728 | 0.968 | 3.3 | -1.1e-2 | 0 | 0 (not relaxed) | none | – | 8.8 | fail (volume) | [z=0.000](skill/analytic-sphere.png) |
| analytic-torus | plan (sweep) | – | 48360 (48360 hex8) | 0.837 | 0.986 | 2.9 | -4.2e-3 | 0 | 0 (not relaxed) | none | – | 9.8 | pass | [x=0.000](skill/analytic-torus.png) |
| analytic-cylinder | plan (project) | – | 4347 (4347 hex8) | 0.859 | 0.985 | 2.4 | -4.9e-3 | 0 | 0 (not relaxed) | none | – | 7.3 | pass | [z=20.000](skill/analytic-cylinder.png) |
| analytic-pipe | plan (project) | – | 2244 (2244 hex8) | 0.997 | 0.997 | 1.3 | -3.3e-3 | 0 | 0 (not relaxed) | none | – | 3.6 | pass | [z=30.000](skill/analytic-pipe.png) |
| analytic-plate-hole | plan (project) | – | 5856 (5856 hex8) | 0.937 | 0.996 | 3.0 | 4.3e-5 | 0 | 0 (not relaxed) | none | – | 4.7 | pass | [x=100.000](skill/analytic-plate-hole.png) |
| crank-arm | plan (project) | – | 4014 (4014 hex8) | 0.578 | 0.933 | 5.8 | -8.4e-4 | 0 | 0 (not relaxed) | none | 4 short_edge, 2 sliver_face | 21.7 | pass | [x=81.000](skill/crank-arm.png) |
| upright-bracket | plan (sweep) | – | 1364 (1364 hex8) | 0.707 | 0.968 | 3.1 | 2.1e-4 | 0 | 0 (not relaxed) | none | – | 0.1 | pass | [x=30.000](skill/upright-bracket.png) |
| pi5-base | plan (project) | 1 plane(s) | 29852 (29852 hex8) | 0.683 | 0.969 | 4.9 | -3.0e-3 | 0 | 0 (not relaxed) | none | 8 short_edge, 4 sliver_face | 131.2 | pass | [x=56.000](skill/pi5-base.png) |
| pi5-lid | plan (project) | – | 5130 (5130 hex8) | 0.724 | 0.868 | 3.9 | -2.9e-4 | 0 | 0 (not relaxed) | none | – | 10.5 | pass | [x=56.000](skill/pi5-lid.png) |
| cup | plan (project) | – | 1258 (1258 hex8) | 0.702 | 0.929 | 5.0 | -3.5e-3 | 0 | 0 (not relaxed) | none | – | 3.0 | pass | [x=5.000](skill/cup.png) |
| plain-cup | mesh.hex.generate (auto) | – | 2000 (2000 hex8) | 0.820 | 0.995 | 4.7 | -2.3e-3 | 0 | 0 (not relaxed) | none | – | 1.3 | pass (fallback) | [x=0.000](skill/plain-cup.png) |
## Why a part fails
Each earlier method's error, in the order the skill tries them, and for a part that fails the
limits the cause read from its numbers and section picture.
- `bent-tube` (corpus: A 10 mm tube with a 1 mm wall, bent through R30: a thin-walled sweep along a line-arc-line path.):
- **fail (volume)**: the plan's default size (1/25 of the diagonal) puts six elements round the Ø10 tube: the swept section is a hexagon and loses 17 % of the volume. Size from the tube's radius (§5), not the part's diagonal.
- `bolted-assembly` (corpus: A base plate with a clamp block component placed twice, once turned 90 degrees: bodies placed by component transforms.):
- Clamp body: mesh.plan.apply: mesh plan `Plan-Clamp_body`: the projected layout does not mesh: invalid input: node 3076 at [11.000000000000004, 10.0, 5.0] lies on the edge between nodes 2640 and 2715: new elements must meet the mesh face to face, with the same divisions…
- Clamp body (Clamp:2): mesh.plan.apply: mesh plan `Plan-Clamp_body__Clamp_2_`: the projected layout does not mesh: invalid input: node 5884 at [66.42857142857143, 5.000000000000001, 5.0] lies on the edge between nodes 984 and 1059: new elements must meet the mesh face to face, wi…
@@ -71,34 +69,26 @@ limits the cause read from its numbers and section picture.
- Cross: mesh.plan.apply: mesh plan `Plan-Cross`: the cells ["Cross"] (cuts []) do not mesh all-hex: body `Cross`: invalid boundary surface: 28 defects; triangles 142 and 254 are oriented inconsistently across edge 0–396 at (-15.488656021315903, 4.577998041166734, 7…
- Cross: mesh.hex.generate: body `Cross`: invalid boundary surface: 28 defects; triangles 146 and 258 are oriented inconsistently across edge 0–402 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 255 and 258 are oriented inconsistently across…
- Cross: mesh.hex.project (sphere): body `Cross`: invalid boundary surface: 28 defects; triangles 146 and 258 are oriented inconsistently across edge 0–402 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 255 and 258 are oriented inconsistently across…
- Cross: mesh.tet.generate: invalid boundary surface: 28 defects; triangles 146 and 258 are oriented inconsistently across edge 0–402 at (-15.488656021315903, 4.577998041166734, 7.853092194999726); triangles 255 and 258 are oriented inconsistently across edge 0–565 at…
- Cross: mesh.tet.generate: invalid boundary surface: 28 defects; triangles 76 and 78 are oriented inconsistently across edge 9–230 at (-18.75000000000002, 5.634596901641276, 7.632255089815496); triangles 77 and 78 are oriented inconsistently across edge 9–232 at (-15…
- **no mesh**: the boundary tessellation of the joined cylinders is invalid (inconsistent orientation at the two singular points), so every mesher refuses the body.
- `filleted-bracket` (corpus: An 8 mm L-bracket with an R6 inside fillet and a hole through each leg: a blend beside holes.):
- Bracket: mesh.plan.apply: mesh plan `Plan-Bracket`: the cells ["Bracket"] (cuts []) do not mesh all-hex: body `Bracket`: not supported yet: no structured hex method fits this body (extrude: cylindrical face 8 is not parallel to the direction; sweep: from face 1: lin…
- Bracket: mesh.hex.generate: body `Bracket`: not supported yet: no structured hex method fits this body (extrude: cylindrical face 8 is not parallel to the direction; sweep: from face 1: linking face 6 has 2 boundary loops (periodic or holed linking faces are not swept…
- Bracket: mesh.hex.project (sphere): body `Bracket`: meshing failed: 120 of 5104 elements are inverted, twisted or degenerate after projection (certified Jacobian; smallest corner scaled Jacobian -0.760): block 1 cell [4, 0, 0] near (20.373, 3.413, 3.606); block 1 cell [5, 0, …
- **fail (SJ)**: no structured method takes the whole body (the plan has no cuts for the fillet beside the holes), and tet10 at the plan's size leaves slivers (min SJ 0.04, 1709 marked). The same part partitioned into mapped cells (`filleted-bracket-blocks`) passes: partition (§3.4).
- Bracket: mesh.hex.project (sphere): body `Bracket`: meshing failed: 91 of 5104 elements are inverted, twisted or degenerate after projection (certified Jacobian; smallest corner scaled Jacobian -0.760): block 1 cell [3, 3, 0] near (12.519, 11.405, 1.604); block 1 cell [3, 4, …
- `loft-transition` (corpus: A 40 mm square lofted to an R15 circle 50 mm above: a ruled free-form surface between unlike sections.):
- **fail (SJ)**: the lofted sides are swept one to one, and the elements where the section changes shape are skewed (min SJ 0.19). Relaxing the four marked elements did not raise it: the blocks are wrong, not the nodes (§6.3). A loft has no template yet (`unmapped`).
- `pinhole-block` (corpus: A 50 mm block drilled through by sixteen 0.6 mm pinholes: features 80 times smaller than the part, genus 16.):
- **fail (ratio)**: the plan's cuts run beside the small holes' frames and leave strips of thin elements along the cut planes (red in the picture, edge ratio 18): 1680 marked, a clustered failure. §4: offset a cut one element out of the feature.
- `sharp-wedge` (corpus: A wedge whose faces meet at under 3 degrees: a sharp edge with a needle-thin cross-section.):
- **fail (SJ, ratio)**: the wedge's sharp edge is swept as hex, so every element tapers to it (min SJ 0.01, edge ratio 150). §4: a column of wedge6 at the edge; the plan has no wedge template.
- **fail (SJ, ratio)**: the wedge's sharp edge is swept as hex, so every element tapers to it (min SJ 0.01, edge ratio 150). A column of wedge6 at the edge (§4) raises the SJ but not the ratio: any element with a corner on a 2.9° edge has sides at least 1/tan 2.9° ≈ 20 times its width there, twice the limit. The edge has to be blunted (defeatured) or the limit waived along it.
- `sphere-on-plate` (corpus: A sphere sunk 0.1 mm into a plate: the faces meet at under 6 degrees around a 2 mm circle.):
- Plate: mesh.plan.apply: mesh plan `Plan-Plate`: the cells ["Plate"] (cuts []) do not mesh all-hex: body `Plate`: not supported yet: no structured hex method fits this body (extrude: face 6 is curved across the direction; sweep: from face 0: 6 faces are not on the …
- Plate: mesh.hex.generate: body `Plate`: not supported yet: no structured hex method fits this body (extrude: face 6 is curved across the direction; sweep: from face 0: 6 faces are not on the sweep path; from face 5: linking face 11 has 2 boundary loops (periodic or …
- Plate: mesh.hex.project (sphere): body `Plate`: meshing failed: the mesh volume 32014.728344815157 is far from the body's 69353.30815403847: the blocks do not cover the part (every outer block face must stand for part of its surface)
- **fail (SJ)**: no structured method takes the plate with the sunk sphere, and tet10 leaves slivers (min SJ 0.12, 662 marked).
- Plate: mesh.hex.project (sphere): body `Plate`: meshing failed: the mesh volume 32917.42909007507 is far from the body's 69353.30815403847: the blocks do not cover the part (every outer block face must stand for part of its surface)
- `analytic-sphere` (corpus: Sphere r 20: curvature everywhere, no edges.):
- Sphere: mesh.plan.apply: mesh plan `Plan-Sphere`: the cells ["Sphere"] (cuts []) do not mesh all-hex: body `Sphere`: not supported yet: no structured hex method fits this body (extrude: no direction to try; sweep: no planar face to sweep from; revolve: no axis to t…
- Sphere: mesh.hex.generate: body `Sphere`: not supported yet: no structured hex method fits this body (extrude: no direction to try; sweep: no planar face to sweep from; revolve: no axis to try; mapped: meshing failed: meshing failed: mesh validity gate: 8 inverted or…
- **fail (volume)**: the sphere template meshes it cleanly, but hex8 faces chord the sphere at the plan's size and the mesh is 3 % short of its volume: refine, or hex20 (`order: 2`).
- `analytic-torus` (corpus: Torus 30/8: genus 1 with doubly curved faces and no edges.):
- **fail (volume)**: the revolve is clean, but hex8 faces chord the torus at the plan's size and the mesh is 4.7 % short of its volume: refine, or hex20.
- `crank-arm` (sample `crank-arm`: the 170 mm bicycle crank, mirror symmetric across y = 0):
- **fail (ratio)**: the edge ratio is 10.5 in the nine elements at the square bore's R1.5 corners, just over the limit. Relaxing each raised its region's edge ratio, so each was undone (§6.3). `mesh.plan.apply` did not find the y = 0 symmetry plane; the sample meshes the partitioned half and mirrors it.
- **fail (volume)**: no structured method takes a sphere (it has no face to sweep from or axis to revolve about), so the plan does not apply and the fallback's sphere template meshes it at the plan's size (2.45 mm, which keeps a sphere's chords to 0.75 % on paper), 1.1 % short of the volume: the template's elements at the cube's eight corners span more of the sphere than the size. A sphere template in the plan (§3) would size its caps; hex20 (`order: 2`) closes it too.
- `plain-cup` (the cup without its handle (#418)):
- Cup: mesh.plan.apply: mesh plan `Plan-Cup`: the projected layout does not mesh: body `Cup`: meshing failed: the mesh volume 4994.312243612882 is far from the body's 16760.91944296662: the blocks do not cover the part (every outer block face must stand for part o…
- Cup: mesh.plan.apply: mesh plan `Plan-Cup`: the projected layout does not mesh: body `Cup`: meshing failed: the mesh volume 5012.205781249979 is far from the body's 16760.91944296662: the blocks do not cover the part (every outer block face must stand for part o…
## Parts
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 105 KiB

+2 -2
View File
@@ -55,7 +55,7 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
| [explicit-sliding-block-wall](../tests/oracle/explicit-sliding-block-wall/) | friction dissipation over ½mv₀² = 2.25 | 1 | 1.0012 | 0.121% | 1% | pass | matches |
| [filleted-bracket-fillet-stress](../tests/oracle/filleted-bracket-fillet-stress/) | largest nodal von Mises stress on the fillet face (MPa) | 213 | mesh-dependent | within 3% | 3% | pass | — |
| [filleted-bracket-fillet-stress](../tests/oracle/filleted-bracket-fillet-stress/) | total reaction x (N) | -2000 | -2000 | < 1e-7% | 1e-9 rel. | pass | matches |
| [filleted-bracket-fillet-stress-coarse](../tests/oracle/filleted-bracket-fillet-stress-coarse/) | largest nodal von Mises stress on the fillet face (MPa) | 213 | mesh-dependent | within 3% | 3% | pass | — |
| [filleted-bracket-fillet-stress-coarse](../tests/oracle/filleted-bracket-fillet-stress-coarse/) | largest nodal von Mises stress on the fillet face (MPa) | 213 | mesh-dependent | within 5% | 5% | pass | — |
| [filleted-bracket-fillet-stress-coarse](../tests/oracle/filleted-bracket-fillet-stress-coarse/) | total reaction x (N) | -2000 | -2000 | < 1e-7% | 1e-9 rel. | pass | matches |
| [free-beam-frequencies](../tests/oracle/free-beam-frequencies/) | sixth frequency (the last rigid-body mode) | 0 | ≈ 0 | ≈ 0 | ± 8.1264e-5 | pass | matches |
| [free-beam-frequencies](../tests/oracle/free-beam-frequencies/) | first free–free bending frequency (mode 7) | 0.081264 | 0.080618 | -0.795% | 1.5% | pass | matches |
@@ -315,7 +315,7 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
- **[explicit-sliding-block-contact](../tests/oracle/explicit-sliding-block-contact/)** (theory): Block sliding to a stop on a deformable plate, friction decaying with the slip rate (explicit contact). Source: A rigid block launched at v₀ with friction μ(v) = μk + (μs − μk) e^(−d v) decelerates at g μ(v); it travels ∫₀^v₀ v dv / (g μ(v)) (Simpson's rule, 200 000 intervals) and stops after [v₀ + ln((μk + (μs − μk) e^(−d v₀))/μs)/d]/(g μk) = 1.2001; Meriam & Kraige, Engineering Mechanics: Dynamics, §3/5; the exponential decay law as in Oden & Martins, CMAME 52:527–634, 1985. Tolerances — distance: the block bounces on the penalty springs and the plate under it shears elastically, so the normal force and the slip rate it feels vary by a little around their rigid-body values; velocity: the block sticks; what is left is its elastic vibration, under 1% of v₀
- **[explicit-sliding-block-wall](../tests/oracle/explicit-sliding-block-wall/)** (theory): Block sliding to a stop on a rigid wall with Coulomb friction (explicit). Source: A rigid block launched at v₀ on a horizontal plane with Coulomb friction μ decelerates at μg and stops after v₀/(μg) at v₀²/(2μg), its kinetic energy all dissipated by friction; Meriam & Kraige, Engineering Mechanics: Dynamics, §3/5 (work and energy). Tolerances — distance: the wall's friction impulses are exact Coulomb impulses; the block's elastic vibration from the sudden gravity and the friction moment move the node by a fraction of a percent; velocity: the block sticks; what is left is its elastic vibration, under 1% of v₀; dissipated: the kinetic energy goes into friction; the vibration and the work of gravity as the block settles add or take a fraction of a percent
- **[filleted-bracket-fillet-stress](../tests/oracle/filleted-bracket-fillet-stress/)** (theory): Filleted L-bracket under a side load: the peak von Mises stress on the R6 fillet, fine tet10 mesh. Source: mesh convergence of the finite element solution (P. J. Roache, Verification and Validation in Computational Science and Engineering, Hermosa, 1998, ch. 5): the bracket has no closed-form stress; the reference is the mesh-independent peak of a four-level study of quadratic tetrahedral meshes refined by √2 in size and in curvature resolution (README.md); equilibrium. Tolerances — fillet_peak: the four-level study gives 213.3, 210.6, 214.3, 213.3 MPa (2k to 13.5k elements): the bending stress is converged across the fillet, and the peak nodal value moves only with which of the fillet's tessellation lines (fixed angles, through every level) carries the largest node, by under 2%; reaction: equilibrium holds to rounding
- **[filleted-bracket-fillet-stress-coarse](../tests/oracle/filleted-bracket-fillet-stress-coarse/)** (theory): Filleted L-bracket under a side load: the peak von Mises stress on the R6 fillet, coarse tet10 mesh. Source: mesh convergence of the finite element solution (P. J. Roache, Verification and Validation in Computational Science and Engineering, Hermosa, 1998, ch. 5): the bracket has no closed-form stress; the reference is the mesh-independent peak of a four-level study of quadratic tetrahedral meshes refined by √2 in size and in curvature resolution (README.md); equilibrium. Tolerances — fillet_peak: the four-level study gives 213.3, 210.6, 214.3, 213.3 MPa (2k to 13.5k elements): the bending stress is converged across the fillet, and the peak nodal value moves only with which of the fillet's tessellation lines (fixed angles, through every level) carries the largest node, by under 2%; reaction: equilibrium holds to rounding
- **[filleted-bracket-fillet-stress-coarse](../tests/oracle/filleted-bracket-fillet-stress-coarse/)** (theory): Filleted L-bracket under a side load: the peak von Mises stress on the R6 fillet, coarse tet10 mesh. Source: mesh convergence of the finite element solution (P. J. Roache, Verification and Validation in Computational Science and Engineering, Hermosa, 1998, ch. 5): the bracket has no closed-form stress; the reference is the mesh-independent peak of a four-level study of quadratic tetrahedral meshes refined by √2 in size and in curvature resolution (README.md); equilibrium. Tolerances — fillet_peak: at this level (16 mm, one element per 90° of the fillet) the peak moves with the mesh by more than at the finer ones: 213.3 MPa (macOS) and 210.2 MPa (Linux) for the kernel's face order before the mesher took its input in a canonical order, 203.6 MPa after (#115), so the band is 5%; the four-level study gives 213.3, 210.6, 214.3, 213.3 MPa (2k to 13.5k elements): the bending stress is converged across the fillet, and the peak nodal value moves only with which of the fillet's tessellation lines (fixed angles, through every level) carries the largest node, by under 2%; reaction: equilibrium holds to rounding
- **[free-beam-frequencies](../tests/oracle/free-beam-frequencies/)** (theory): Natural frequencies of a free beam: rigid-body modes and the first bending mode. Source: Euler–Bernoulli free–free beam, β₁L = 4.7300; Timoshenko, Young and Weaver, Vibration Problems in Engineering, 4th ed., Wiley 1974, §5.3. Tolerances — rigid_6: a free body has six zero frequencies; numerical rigid-body frequencies below 10⁻³ of the first elastic one (eigenvalues below 10⁻⁶ of it) are rounding of the shifted solve; f7: Euler–Bernoulli neglects shear and rotary inertia, about 0.8% at L/h = 20 for the free–free mode; mass: the mass matrix integrates the density exactly
- **[j2-bauschinger](../tests/oracle/j2-bauschinger/)** (theory): Bauschinger effect of linear kinematic hardening: tension, then back to zero strain. Source: J2 plasticity with Prager's linear kinematic hardening in uniaxial stress (W. Prager, Ing.-Arch. 1955; Simo and Hughes, Computational Inelasticity, Springer 1998, §2.3): from σ_a = σ_y + E_t (ε_a − σ_y/E) the material unloads elastically by 2σ_y and then hardens with E_t, so at zero strain σ = σ_a − 2σ_y − E_t (ε_a − 2σ_y/E). Tolerances — residual_stress: a homogeneous small-strain state the element represents exactly, with increments that end at the load path's turn; isotropic hardening would give −281.835 (it yields again only at −σ_a); plastic_strain: (σ_a − σ_y)/H forward plus E/(E + H) (ε_a − 2σ_y/E) in reverse
- **[j2-heated-bar](../tests/oracle/j2-heated-bar/)** (theory): A steel bar held at its ends and heated past yield (small strain). Source: Uniaxial J2 plasticity with linear hardening (Simo and Hughes, Computational Inelasticity, Springer 1998, §2.3) with the additive thermal strain: the bar's total axial strain is held at zero, so its mechanical strain is −α ΔT and σ = −(σ_y + E_t (α ΔT − σ_y/E)) once α ΔT exceeds σ_y/E. Tolerances — sigma: a homogeneous uniaxial state the elements represent exactly; plastic_strain: (\|σ\| − σ_y)/H, as for the stress
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more