Compare commits

...
16 Commits
Author SHA1 Message Date
jyvikoandClaude Opus 5.5 71cc39213c testkit: octree hex column in the meshing corpus
The corpus runs mesh.hex.octree's mesher on every part at 1/25 of its diagonal (the size of
the structured hex column) and reports hexahedra, the smallest and mean scaled Jacobian, the
mesh volume against SolidCraft's and the time; a part it cannot mesh gets the reason. The
column caps the elements at 500 000 so a part whose grid explodes is refused in seconds.
Loaded parts carry their bounding-box diagonal for the stages. docs/meshing/corpus.md
regenerated: 15 of 21 parts mesh, including filleted-bracket, rounded-box, sphere-on-plate
and the analytic sphere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:38:11 -04:00
jyvikoandClaude Opus 5.5 a719ae7e5b oracle: filleted L-bracket on the octree all-hex mesh
A slow theory case: the corpus bracket meshed by mesh.hex.octree at 7, 5 and 4 mm, clamped
at the base and loaded by 2 kN. Checks equilibrium, that every element is certified valid,
the mesh volume against the CAD volume, the beam bending stress of the upright leg
(sigma = M c / I = 145.3 MPa, within 5%) and the convergence of the fillet von Mises stress
against the 4 mm mesh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:38:11 -04:00
jyvikoandClaude Opus 5.5 7d2696057f engine: mesh.hex.octree command
Replaces the mesh with the octree all-hex mesh of a body, an imported surface or every body
of the open design, and tags boundary element faces with their B-rep faces so sets by
geometry resolve. Size, tolerance, refinement depth, element cap, attempts and optimiser
sweeps are validated and capped; the element cap defaults to the session limit
(`Limits::max_hex_elements`), which the hostile-params test keeps small. Body input and face
tagging are shared with mesh.hex.generate. The catalogue's Automatic Hex entry is ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:38:11 -04:00
jyvikoandClaude Opus 5.5 3587b0de74 mesh: unstructured all-hex octree mesher
`fieldcraft_mesh::octree::octree_hex_mesh` meshes any closed part with hex8 only:

- an adaptive 3-refinement grid (27-tree) with strong balance and closure, filled with
  conforming node-marking transition templates (Schneiders; checked over all 48 symmetries);
- a core of grid cells kept clear of the boundary (the HybridOctree buffer rule), with
  non-manifold cleanup and a genus check against the part;
- a feature graph from the B-rep (corners, curves, patches) and a partition-first map of the
  core's boundary onto it, then two boundary layers: a padding layer and a pillow per patch,
  so every face boundary and sharp edge is captured by element edges;
- a Garanzha-type untangling and fitting energy (shape term |J|^3/chi, a volume term and a
  soft fitting term, deterministic across platforms), a compass search on the worst
  elements and a certified repair; failures refine the grid near them and rebuild, within
  bounded attempts and growth.

The output passes the certified Jacobian check of every element and a gate that the
boundary encloses the elements' volume; boundary quads carry their B-rep faces. Quality is
reported with the same scaled Jacobian as mesh.quality. Literature notes for each step are in
docs/meshing/literature.md (B1.6-B1.10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:38:11 -04:00
jyvikoandClaude Opus 5.5 741a803785 oracle: docs/oracle.md reads the same after a blessing run, so main's freshness check passes
A value blessed in a run matches its new fixture by construction; the page said "blessed" and
CI's regenerated page said "matches", which turned main red after #68 and #69.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:36:49 -04:00
jyvikoandClaude Opus 5.5 d586d84859 verification: hyperelastic and thermal oracle cases and documentation
Five cases against closed forms: a Mooney-Rivlin block in Treloar's
uniaxial, equibiaxial and pure-shear modes and a Yeoh block in uniaxial
tension (the compressible energy's stresses, exact); a stretched
Mooney-Rivlin tube inflated by a follower pressure (Rivlin's incompressible
solution, 0.6 % at nu = 0.4995); free and fully held thermal expansion at a
thermal stretch of 1.2 (exact); a bar held at its ends heated past yield
(exact). docs/fem.md, the literature, ROADMAP (M15, M17), parity and the
oracle page follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:23:06 -04:00
jyvikoandClaude Opus 5.5 3f9e9e30d8 fem, solver: thermal strain in nonlinear static steps
Temperatures now strain the material in nonlinear steps instead of being
refused. At finite strain the deformation gradient splits multiplicatively,
F = F_m F_theta with F_theta = diag(1 + alpha_i dT) at each point: the material
sees F_m and the loops run on the thermally expanded volume J_theta dV0 (mixed
elements project det F_m), so the tangent stays consistent. At small strain the
split is additive, and elements on their linear stiffness take the equivalent
nodal forces as a dead load. The step's temperatures are its temperature loads,
or the steady conduction of its temperature conditions and heat loads (one-way
coupling, as the linear coupled step); both at once are refused. They ramp with
the load factor from each material's reference temperature. Johnson-Cook
plasticity softens at the point's temperature. Verified: free expansion and a
fully constrained neo-Hookean block at 20 % thermal stretch, full and hybrid
(exact); a bar held at its ends heated past yield (exact); the linear limit and
the conducted temperatures against the linear solvers; tangents by central
differences with a temperature field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:23:06 -04:00
jyvikoandClaude Opus 5.5 53adab54c2 fem, model: Mooney-Rivlin and Yeoh hyperelasticity in nonlinear steps
Two more large-strain options on the isotropic constants, decoupled like
neo-Hookean with the volume kappa/2 (J - 1)^2: Mooney-Rivlin with C10 = mu/2 - C01
(0 <= C01 <= mu/2) and Yeoh with C10 = mu/2 and the higher terms C20, C30, so the
small-strain material is the elastic one everywhere. MaterialModel::Polynomial
gives the isochoric stress and tangent from Holzapfel's fictitious quantities
(eqs. 6.168, 6.193), consistent by central differences, equal to neo-Hookean
with C10 alone; hybrid elements take it. Abaqus writes *HYPERELASTIC,
MOONEY-RIVLIN and YEOH; LS-DYNA keeps refusing hyperelastic materials in
nonlinear steps by name. Verified in uniaxial and equibiaxial tension and pure
shear against the compressible closed form (exact).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:23:06 -04:00
jyvikoandClaude Opus 5.5 7a69fe5e2f verification: implicit plasticity oracle cases and documentation
Four cases, each against its closed form: uniaxial tension of a J2 cube to a
logarithmic strain of 0.2 (Kirchhoff stress exact; 0.13 % from the explicit
solver's hypoelastic Cauchy stress, the elastic volume change between the two
laws), the Bauschinger effect of linear kinematic hardening on a load path
back to zero strain (exact), Hill's elastic-perfectly plastic thick cylinder
(bore displacement at a plastic radius of 1.5a within 0.009 %, collapse
pressure within 2e-8; the pending case is now active) and a cantilever's
plastic collapse, converged 3.1 % above beam theory's hinge, the bound a
continuum exceeds. docs/fem.md, the literature, ROADMAP (M15), parity and the
oracle page follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 06:03:36 -04:00
jyvikoandClaude Opus 5.5 17b2c837f2 solver: J2 plasticity in nonlinear static steps, finite and small strain
Elements of a plastic material take the J2 material point: total Lagrangian
with nonlinear geometry, the small-strain loops without (elastic elements keep
their linear stiffness, so elastic results do not move). The plastic history is
committed per converged increment. Increments follow the step's load path,
ending at its points, the extrapolated predictor scaled by the change of the
load factor. Steps with path-dependent materials start an increment without a
predictor from the tangent predictor (the prescribed motion spread through the
converged tangent), and a first tangent that is not positive definite is a
cutback when the undeformed model's is. Refused by name: the Johnson-Cook rate
term (a static step has no time) and kinematic hardening at finite strain;
damage, erosion and adiabatic heating are left out with a warning. Results
carry the equivalent plastic strain at the integration points (solve.probe,
the step summary). LS-DYNA writes a load path as the step's curve; Abaqus
refuses it and a plastic deck of several general steps (Abaqus carries the
plastic state between them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 06:03:36 -04:00
jyvikoandClaude Opus 5.5 569b69c061 model: load paths of nonlinear static steps
NonlinearStatic::load_path gives the load factor against the step fraction,
piecewise linear from [0, 0] to fraction 1, so a step can unload or reverse
its loads and prescribed displacements (cyclic loading of path-dependent
materials). Validated (2 to 1000 finite points, increasing fractions), with
load_factor and next_turn for the solver's increments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 06:03:36 -04:00
jyvikoandClaude Opus 5.5 72940fe78f fem: J2 plasticity at the implicit material point, finite and small strain
The radial return of the explicit update is now J2::radial_return, called by
both solvers, with the consistent tangent J2::tangent (Simo & Hughes 1998,
Box 3.2). The new elastoplastic module gives the implicit material point:
Simo's multiplicative plasticity with the exponential return map in the
principal logarithmic strains (CMAME 99, 1992) and its spatial tangent through
the Daleckii-Krein divided differences of the logarithm, and the additive
radial return of small strain with kinematic hardening. MaterialModel::Plastic
carries it, decoupled (deviatoric return, Hencky volume) so hybrid elements
take it. The element loops of finite.rs gain small-strain kinematics for
inelastic materials and pass history through the mixed path. Every tangent is
checked against central differences, element by element, from a plastic
history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 06:03:36 -04:00
jyvikoandClaude Opus 5.5 3ecb7c6883 corpus: the filleted bracket partitioned into 30 blocks, meshed all-hex
filleted-bracket-blocks splits the bracket with SolidCraft's Split Body
into three slabs across its width, the fillet corner halved along its
diagonal and a four-cell O-grid round each hole; the corpus runner meshes
partitioned parts as one conforming block mesh. A heavy test clamps the
upright, loads the foot and checks hex8 and hex20 converge (hex20 tip
deflection within 0.1 % of a fine tet10 solution). Roadmap M5:
conforming interfaces done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 04:56:46 -04:00
jyvikoandClaude Opus 5.5 98d2f55407 render: the bodies of a design with several in their own colours
Geometry triangles remember their surface (body); the viewport and the
headless pictures draw each body of a multi-body design, such as the
cells of a partitioned part, in its own colour from the theme (eight in
turn), and a single body in the geometry colour as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 04:56:46 -04:00
jyvikoandClaude Opus 5.5 7f5399c7e9 mesh.hex.blocks: one conforming hex mesh of a design split into blocks
Meshes the open design's bodies (or the listed ones) as the cells of one
conforming all-hex mesh with one size and tessellation tolerance for all;
elements carry their cell's body, boundary faces its B-rep faces. A cell
that is no block, or cells that touch without sharing whole faces, is an
error naming the bodies. Catalogue entry with its VectorCraft icon, the
MCP mesh tool's hex_blocks method, docs/features.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 04:56:46 -04:00
jyvikoandClaude Opus 5.5 b6ce7aee69 hex: conforming multi-block meshes of partitioned parts
hex_mesh_cells meshes cells that meet face to face (SolidCraft split
bodies) as one conforming all-hex mesh: each cell a mapped block after
smoothly meeting faces are joined (virtual topology: the halves of a hole,
a plane running into a fillet), vertices, edges and faces shared by
identity, interval assignment over all cells, each shared face meshed
once, radial edges graded off curved faces. finish spans several cells:
tags per cell, hex20 mid-edge nodes shared across cells, a closed
2-manifold boundary and no free internal face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 04:56:46 -04:00
109 changed files with 9718 additions and 456 deletions

No files matched your search

+1
View File
@@ -48,6 +48,7 @@ is committed and listed here with its exports.
| assets/icons/mesh.element.add.svg, assets/icons/mesh.element.add.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.element.add.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.hex.decompose.svg, assets/icons/mesh.hex.decompose.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.decompose.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.hex.dominant.svg, assets/icons/mesh.hex.dominant.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.dominant.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.hex.blocks.svg, assets/icons/mesh.hex.blocks.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.blocks.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.hex.mapped.svg, assets/icons/mesh.hex.mapped.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.mapped.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.hex.octree.svg, assets/icons/mesh.hex.octree.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.octree.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.hex.polycube.svg, assets/icons/mesh.hex.polycube.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.polycube.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
+7 -7
View File
@@ -31,8 +31,8 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; 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, few-ulp rounding repair checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); Delaunay refinement, ODT smoothing, sliver removal; tet4 and tet10 with mid-side nodes on the B-rep; 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 and tet–hex transition; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started |
| M6 Automatic hex | Octree/grid-dual all-hex for any part; hex-dominant by tet recombination; polycube for blocky parts; smoothing, untangling, boundary padding, layers through thin walls; mesh editing and orphan-mesh import | planned |
| 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); sweepable cells in a conforming mesh and tet–hex transition; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started |
| M6 Automatic hex | Octree/grid all-hex for any part (first version: done — `fieldcraft-mesh::octree`, `mesh.hex.octree`: 3-refinement grid with conforming templates refined by curvature, thickness and narrow faces, a core cleared to a manifold boundary, feature map onto it, padding and a pillow per patch capturing every sharp curve, foldover-free fitting, certified validity, B-rep face tags; the corpus hex column and the filleted-bracket oracle case; next: transitions for concave refined regions so thin and curved parts stop over-refining); hex-dominant by tet recombination; polycube for blocky parts; layers through thin walls; mesh editing and orphan-mesh import | started |
| M7 Assemblies, contact and friction | Needs M15. Part instances from SolidCraft occurrences; tie between non-matching meshes (mortar); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection with a bounding-volume search shared with explicit contact; friction: Coulomb with stick–slip, static/kinetic, anisotropic, pressure-, rate- and temperature-dependent coefficients; contact output (pressure, slip, status); thermal contact conductance and frictional heating; wear (Archard); bolt pretension, connectors with behaviour (elastic, damped, stops, locks); amplitudes, multiple steps, restart; nonlinear steps in all three decks. Verified by Hertz (sphere and cylinder), the Cattaneo–Mindlin partial-slip problem, the sliding block, NAFEMS contact benchmarks | planned |
| M8 Materials, explicit, shells, composites | Plasticity, hyperelasticity, damage data per solver; explicit dynamics decks (Abaqus/Explicit, LS-DYNA); midsurface shells; composite layups and material orientations, continuum shells on swept hex; hybrid (mixed u–p) hex8/hex20/tet10 for nearly incompressible materials (done); heat transfer (solids done: steady and θ-method transient conduction with flux, convection and sources, thermal strain in static steps (solids and 2D) and explicit dynamics (with plasticity), coupled temperature–displacement, `solve.heat`; deck output, 2D/shell/beam conduction planned) | started |
| M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned |
@@ -41,9 +41,9 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark) | started |
| M13 Fluid–structure interaction | Immersed-boundary coupling of the lattice Boltzmann fluid with the explicit or implicit structure; interface force transfer; sub-cycling; Aitken then interface quasi-Newton coupling; free-surface flow | planned |
| M14 Blast and impact fluids | EOS-driven fluids in the explicit solver: ALE multi-material or SPH, with fluid–structure contact | planned |
| M15 Nonlinear implicit core | Newton–Raphson with line search, automatic increments and cutbacks, convergence on force, displacement and energy; geometric nonlinearity (total and updated Lagrangian, large rotations for shells and beams, follower pressure); every `fem` material behind one interface with its consistent tangent (plasticity, damage and thermal strain from the explicit solver reused implicitly); arc-length (Riks) for snap-through; linear buckling (eigen) with preload stress stiffening, and imperfections from buckling modes; nonlinear stabilisation; restart. Verified by the elastica, Lee's frame, the snap-through arch, the hinged cylindrical shell and plastic collapse loads (solids core: done — `nonlinear_static` steps with automatic increments and cutbacks, Newton–Raphson with the consistent tangent, force, displacement and energy convergence and an optional line search; a material point interface (Kirchhoff stress and spatial tangent from F, with history) with St Venant–Kirchhoff and compressible neo-Hookean; total Lagrangian tet4, tet10, wedge6, hex8, hex20 (full and reduced), hybrid hex8/hex20/tet10 by the three-field mixed form, plane strain and axisymmetric elements; follower pressure with its symmetrised load stiffness; Cauchy stress and logarithmic strain, increment history and frames, `solve.nonlinear`; Abaqus and LS-DYNA decks; verified by the patch and objectivity tests, uniaxial stretch, the elastica and tube inflation by a follower pressure (finite-strain Cook's membrane pending its transcribed reference). Next: J2 plasticity and Mooney–Rivlin on the interface, thermal strain, incompatible modes (EAS), shells and beams, arc-length) | started |
| M15 Nonlinear implicit core | Newton–Raphson with line search, automatic increments and cutbacks, convergence on force, displacement and energy; geometric nonlinearity (total and updated Lagrangian, large rotations for shells and beams, follower pressure); every `fem` material behind one interface with its consistent tangent (plasticity, damage and thermal strain from the explicit solver reused implicitly); arc-length (Riks) for snap-through; linear buckling (eigen) with preload stress stiffening, and imperfections from buckling modes; nonlinear stabilisation; restart. Verified by the elastica, Lee's frame, the snap-through arch, the hinged cylindrical shell and plastic collapse loads (solids core: done — `nonlinear_static` steps with automatic increments and cutbacks, Newton–Raphson with the consistent tangent, force, displacement and energy convergence and an optional line search; a material point interface (Kirchhoff stress and spatial tangent from F, with history) with St Venant–Kirchhoff and compressible neo-Hookean; total Lagrangian tet4, tet10, wedge6, hex8, hex20 (full and reduced), hybrid hex8/hex20/tet10 by the three-field mixed form, plane strain and axisymmetric elements; follower pressure with its symmetrised load stiffness; Cauchy stress and logarithmic strain, increment history and frames, `solve.nonlinear`; Abaqus and LS-DYNA decks; verified by the patch and objectivity tests, uniaxial stretch, the elastica and tube inflation by a follower pressure (finite-strain Cook's membrane pending its transcribed reference; J2 plasticity on the interface: Simo's exponential return map at finite strain and the radial return at small strain, both with consistent tangents and the return shared with explicit dynamics, bilinear isotropic and kinematic (small strain), tabulated and Johnson–Cook (rate-independent) hardening, hybrid elements included, load paths for cyclic loading, a tangent predictor for path-dependent materials, verified by uniaxial tension to large strain, Hill's thick cylinder (plastic zone and collapse pressure), a cantilever's collapse and the Bauschinger effect, with quadratic Newton convergence; Mooney–Rivlin and Yeoh options (decoupled, hybrid elements), verified in Treloar's three modes and by a stretched tube's inflation; thermal strain by the multiplicative split at finite strain and the additive one at small strain, with temperatures from temperature loads or the step's own conduction, verified by free and constrained expansion and a heated bar held past yield. Next: incompatible modes (EAS), kinematic hardening at finite strain, shells and beams, linear buckling, arc-length with an indefinite factorization) | started |
| M16 Structural dynamics | Implicit transient (Newmark, HHT-α, generalised-α); modal superposition transient; steady-state harmonic (direct and modal), with Rayleigh, modal, structural and material damping; random vibration from PSDs and response spectrum (SRSS, CQC); complex eigenvalues with damping; rotating machinery (Campbell diagrams, gyroscopic terms); substructures (Craig–Bampton) | planned |
| M17 Constitutive library and material library | Elastic: transversely isotropic, fully anisotropic (21 constants), temperature-dependent. Elastomers: hyperelastic neo-Hookean, Mooney–Rivlin, Yeoh, Ogden, Arruda–Boyce, Gent, Marlow, with Mullins softening and finite-strain viscoelasticity (Prony series; parallel networks after Bergström–Boyce), foams. Viscoelasticity: small- and finite-strain Prony series, time–temperature superposition (WLF, Arrhenius), storage and loss moduli for harmonic steps. Viscoplasticity and creep: Norton power law, time and strain hardening, Perzyna, Anand, Chaboche combined hardening. Plasticity: Hill anisotropic, Drucker–Prager, Mohr–Coulomb, Cam-clay, crushable foam, concrete damaged plasticity, Gurson. Fluids: Newtonian and non-Newtonian viscosity shared with M12. Material library: curated, cited data sets (steels, aluminium and titanium alloys, polymers, elastomers, composites, tissues) with units and temperature dependence; calibration from test data (uniaxial, biaxial, shear, relaxation, DMA) by least squares with Drucker stability checks; user materials as a safe Rust trait | planned |
| M17 Constitutive library and material library | Elastic: transversely isotropic, fully anisotropic (21 constants), temperature-dependent. Elastomers: hyperelastic neo-Hookean, Mooney–Rivlin, Yeoh (these three done, in nonlinear steps), Ogden, Arruda–Boyce, Gent, Marlow, with Mullins softening and finite-strain viscoelasticity (Prony series; parallel networks after Bergström–Boyce), foams. Viscoelasticity: small- and finite-strain Prony series, time–temperature superposition (WLF, Arrhenius), storage and loss moduli for harmonic steps. Viscoplasticity and creep: Norton power law, time and strain hardening, Perzyna, Anand, Chaboche combined hardening. Plasticity: Hill anisotropic, Drucker–Prager, Mohr–Coulomb, Cam-clay, crushable foam, concrete damaged plasticity, Gurson. Fluids: Newtonian and non-Newtonian viscosity shared with M12. Material library: curated, cited data sets (steels, aluminium and titanium alloys, polymers, elastomers, composites, tissues) with units and temperature dependence; calibration from test data (uniaxial, biaxial, shear, relaxation, DMA) by least squares with Drucker stability checks; user materials as a safe Rust trait | planned |
| M18 Composites and anisotropy | Material orientations (rectangular, cylindrical, spherical, from geometry edges and faces, per-element fields, draping of fibres over curved parts); layups on shells and continuum shells, ply stacks on swept hex; classical lamination theory checks; ply stresses and strains through the thickness; failure indices (Tsai–Wu, Tsai–Hill, Hashin, Puck, LaRC) and progressive damage; homogenisation (rule of mixtures, Mori–Tanaka, RVEs with periodic boundary conditions); delamination through M20's cohesive zones. Verified by laminate closed forms and the NAFEMS composite benchmarks | planned |
| M19 Coupled fields | On the DOF framework, each a degree of freedom beside the displacements: piezoelectricity (electric potential, d/e coupling and permittivity matrices, voltage and charge conditions, resonance and impedance through M16 harmonic steps); poroelasticity (Biot, u–p, Darcy flow, consolidation; Terzaghi and Mandel verification; soils and biphasic tissue); electro-thermal (Joule heating) and thermo-electro-mechanical coupling; acoustics (Helmholtz elements, structure–acoustic coupling, absorbing boundaries); low-frequency electromagnetics later | planned |
| M20 Damage, fracture, fatigue and adhesion | Continuum damage beyond Johnson–Cook (Lemaitre, ductile and shear criteria) for implicit and explicit; fracture mechanics (J-integral and stress intensity factors, crack-front meshes from the hex mesher; XFEM later); fatigue (S–N and ε–N with mean-stress corrections, rainflow counting, Paris crack growth); adhesion and debonding by cohesive zones (bilinear and exponential traction–separation, mixed mode with Benzeggagh–Kenane) as cohesive elements and cohesive contact; adhesive joints. Verified by DCB, ENF and MMB delamination tests and published fatigue data | planned |
@@ -60,7 +60,7 @@ In progress, in this order of landing:
1. Tet quality: sliver repair and Delaunay refinement to a size field, so curved and filleted parts
(the filleted bracket) solve on tets (M2).
2. Octree all-hex for any part (M6), the project's main meshing goal.
2. Octree all-hex (M6): concave transitions (Schneiders' modified templates or Livesu's dual schemes) so curved and thin-walled parts mesh without filling their hull with fine cells.
3. The analysis video regenerated with the setup's symbols (done: support, load, reference-point
and coupling symbols in the viewport and headless renders), captions and an orbit (screenshot
camera and the ribbon following the command: done).
@@ -76,9 +76,9 @@ What exists and what is missing, by area; each gap is in a milestone above.
| Area | Today | Missing |
|---|---|---|
| Solution procedures | Linear static, nonlinear static (solids with geometric nonlinearity), frequency (Lanczos), steady and transient heat, coupled temperature–displacement, explicit dynamics, LBM flow | Nonlinear shells and beams, plasticity in implicit steps, arc-length, buckling (M15); implicit, harmonic, random and spectrum dynamics (M16) |
| Solution procedures | Linear static, nonlinear static (solids with geometric and material nonlinearity: hyperelasticity, J2 plasticity), frequency (Lanczos), steady and transient heat, coupled temperature–displacement, explicit dynamics, LBM flow | Nonlinear shells and beams, arc-length, buckling (M15); implicit, harmonic, random and spectrum dynamics (M16) |
| Contact, friction, adhesion | Explicit planar rigid walls (frictionless); couplings, springs, dashpots | Implicit and explicit deformable contact, tie, friction laws, thermal contact, wear (M7); cohesive adhesion and debonding (M20) |
| Materials | Isotropic and orthotropic elasticity, density, thermal, J2 plasticity (bilinear, table, Johnson–Cook), Johnson–Cook damage and erosion, compressible neo-Hookean hyperelasticity, Newtonian fluids | Elastomers beyond neo-Hookean (Mooney–Rivlin and the rest), viscoelasticity, viscoplasticity and creep, anisotropic and pressure-dependent plasticity, non-Newtonian viscosity, a cited material library and calibration (M17) |
| Materials | Isotropic and orthotropic elasticity, density, thermal, J2 plasticity (bilinear, table, Johnson–Cook), Johnson–Cook damage and erosion, compressible neo-Hookean, Mooney–Rivlin and Yeoh hyperelasticity, Newtonian fluids | Elastomers beyond Yeoh (Ogden, Arruda–Boyce and the rest), viscoelasticity, viscoplasticity and creep, anisotropic and pressure-dependent plasticity, non-Newtonian viscosity, a cited material library and calibration (M17) |
| Composites and anisotropy | Orthotropic constants | Orientations, layups, ply results, failure criteria, homogenisation (M18) |
| Coupled fields | Thermal–structural (one way and coupled) | Piezoelectric, poroelastic, electro-thermal, acoustics (M19) |
| Failure | Johnson–Cook damage and element erosion (explicit) | Fracture mechanics, fatigue, cohesive zones, composite progressive damage (M18, M20) |
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>mesh.hex.blocks</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M12 3 L20 7 L20 16 L12 20 L4 16 L4 7 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 7 L12 11 L20 7 M12 11 L12 20" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 8 L6 17 M10 10 L10 19 M4 9.25 L12 13.25 M4 13.75 L12 17.75 M14 10 L14 19 M18 8 L18 17 M12 13.25 L20 9.25 M12 17.75 L20 13.75 M6 6 L14 10 M10 4 L18 8 M14 4 L6 8 M18 6 L10 10" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 5 L16 9 M16 5 L8 9 M8 9 L8 18 M4 11.5 L12 15.5 M16 9 L16 18 M12 15.5 L20 11.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M12 3 L20 7 L20 16 L12 20 L4 16 L4 7 Z", "fill": "soft", "stroke": "line"}, {"d": "M4 7 L12 11 L20 7 M12 11 L12 20", "stroke": "line"}, {"d": "M6 8 L6 17 M10 10 L10 19 M4 9.25 L12 13.25 M4 13.75 L12 17.75 M14 10 L14 19 M18 8 L18 17 M12 13.25 L20 9.25 M12 17.75 L20 13.75 M6 6 L14 10 M10 4 L18 8 M14 4 L6 8 M18 6 L10 10", "stroke": "accent", "width": 1}, {"d": "M8 5 L16 9 M16 5 L8 9 M8 9 L8 18 M4 11.5 L12 15.5 M16 9 L16 18 M12 15.5 L20 11.5", "stroke": "line", "width": 1.5}]}
+43 -9
View File
@@ -37,7 +37,10 @@
//! and tolerances are left to Abaqus's controls, whose residual norms are defined differently.
//! Abaqus starts each general step from the end of the previous one, while every model step
//! starts from the undeformed state; for elastic materials the end state depends on the final
//! loads alone, so the two agree.
//! loads alone, so the two agree. A plastic material's state depends on the path, so a deck
//! with nonlinear steps, a plastic material and more than one general step is refused, as is
//! a nonlinear step's load path (an amplitude on every load and boundary condition, not
//! written yet).
//!
//! # Loads
//!
@@ -105,7 +108,8 @@
//!
//! Isotropic elasticity is `*ELASTIC, TYPE=ISOTROPIC` (or, for a neo-Hookean material in a deck
//! with nonlinear steps, `*HYPERELASTIC, NEO HOOKE` with C₁₀ = μ/2 and D₁ = 2/κ, the same strain
//! energy; a linear static step is then refused, since Abaqus's general static step would apply
//! energy, and likewise `MOONEY-RIVLIN` (C₁₀, C₀₁, D₁) and `YEOH` (C₁₀, C₂₀, C₃₀, D₁, the higher
//! volumetric terms zero); a linear static step is then refused, since Abaqus's general static step would apply
//! the hyperelastic law), engineering constants
//! `TYPE=ENGINEERING CONSTANTS`, density `*DENSITY`. Von Mises plasticity is `*PLASTIC`: a
//! hardening table as its (yield stress, plastic strain) lines; bilinear isotropic hardening as
@@ -126,8 +130,8 @@ use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use fieldcraft_model::{
BeamProfile, BoundaryCondition, CouplingKind, Elastic, Element, ElementKind, Hardening, Integration, Load, Model, OutputVariable, Plastic,
Procedure, Resolved, Section, SectionKind, Step, Symmetry, Target,
BeamProfile, BoundaryCondition, CouplingKind, Elastic, Element, ElementKind, Hardening, Hyperelastic, Integration, Load, Model, OutputVariable,
Plastic, Procedure, Resolved, Section, SectionKind, Step, Symmetry, Target,
};
use crate::{Error, Result, unsupported};
@@ -313,6 +317,27 @@ fn check_expressible(model: &Model) -> Result<()> {
));
}
}
for step in model.steps() {
if let Procedure::NonlinearStatic(n) = &step.procedure
&& n.load_path.is_some()
{
bad.insert(format!(
"step `{}`: a load path (it would be an amplitude on every load and boundary condition of the step; not written yet)",
step.name
));
}
}
let general = model.steps().iter().filter(|s| matches!(s.procedure, Procedure::Static {} | Procedure::NonlinearStatic(_))).count();
let plastic_in_use = model.sections().values().find(|s| model.material(&s.material).is_some_and(|m| m.plastic.is_some()));
if has_nonlinear_step(model)
&& general > 1
&& let Some(section) = plastic_in_use
{
bad.insert(format!(
"plastic material `{}` in a deck of {general} general steps (Abaqus carries the plastic state from one general step to the next, while every model step starts from the undeformed state; write each step to its own deck)",
section.material
));
}
for (name, m) in model.materials() {
if m.damage.is_some() || m.erosion.is_some() {
bad.insert(format!(
@@ -785,11 +810,20 @@ impl Deck {
for (name, m) in model.materials() {
self.line(&format!("*MATERIAL, NAME={name}"));
match m.elastic {
Some(Elastic::Isotropic { young, poisson }) if m.hyperelastic.is_some() && has_nonlinear_step(model) => {
// W = C10 (Ī1 − 3) + (J − 1)²/D1 with C10 = μ/2 and D1 = 2/κ.
let (shear, bulk) = fieldcraft_model::Hyperelastic::moduli(young, poisson);
self.line("*HYPERELASTIC, NEO HOOKE");
self.numbers_line("", &[shear / 2.0, 2.0 / bulk])?;
Some(Elastic::Isotropic { young, poisson })
if has_nonlinear_step(model)
&& let Some(h) = m.hyperelastic =>
{
// The polynomial constants of the option and U = (J − 1)²/D1 with D1 = 2/κ.
let (shear, bulk) = Hyperelastic::moduli(young, poisson);
let [c10, c01, c20, c30] = h.constants(shear);
let (keyword, values) = match h {
Hyperelastic::NeoHookean {} => ("NEO HOOKE", vec![c10, 2.0 / bulk]),
Hyperelastic::MooneyRivlin { .. } => ("MOONEY-RIVLIN", vec![c10, c01, 2.0 / bulk]),
Hyperelastic::Yeoh { .. } => ("YEOH", vec![c10, c20, c30, 2.0 / bulk]),
};
self.line(&format!("*HYPERELASTIC, {keyword}"));
self.numbers_line("", &values)?;
}
Some(Elastic::Isotropic { young, poisson }) => {
self.line("*ELASTIC, TYPE=ISOTROPIC");
+9 -3
View File
@@ -25,7 +25,8 @@
//! - per step: `*CONTROL_IMPLICIT_GENERAL`, `*CONTROL_IMPLICIT_SOLUTION` (linear) or
//! `*CONTROL_IMPLICIT_EIGENVALUE`, `*CONTROL_TERMINATION` (a static step is one implicit step
//! from time 0 to 1 with every load ramped by one curve, `*DEFINE_CURVE`); a nonlinear static
//! step runs over the same time with the same ramp, its first increment as `dt0`, the nonlinear
//! step runs over the same time with the same ramp (or its load path as the curve), its first
//! increment as `dt0`, the nonlinear
//! solver with a stiffness reformation every iteration (`ilimit` 1, full Newton) and its
//! iteration cap as `maxref`, and `*CONTROL_IMPLICIT_AUTO` with its minimum and maximum
//! increments (its increment cap and tolerances have no LS-DYNA counterpart and are left to
@@ -872,8 +873,13 @@ fn write_step(d: &mut Deck, model: &Model, r: &Resolved, plan: &Plan, step: &Ste
d.header(&["lcid"]);
d.card([Field::from(n)]);
d.header(&["a1", "o1"]);
d.card([Field::from(0.0), Field::from(0.0)]);
d.card([Field::from(1.0), Field::from(1.0)]);
let path = match &step.procedure {
Procedure::NonlinearStatic(s) => s.load_path.clone(),
_ => None,
};
for [t, f] in path.unwrap_or_else(|| vec![[0.0, 0.0], [1.0, 1.0]]) {
d.card([Field::from(t), Field::from(f)]);
}
}
for b in &step.boundary_conditions {
+33
View File
@@ -790,8 +790,41 @@ fn nonlinear_steps_and_hyperelastic_materials_are_written() {
assert!((values[0] - c10).abs() < 1e-9 * c10 && (values[1] - d1).abs() < 1e-9 * d1, "{line}");
assert!(!deck.contains("*ELASTIC"), "{deck}");
// Mooney–Rivlin and Yeoh: C10 = μ/2 − C01, C01, D1 and C10, C20, C30, D1.
m.replace_material("Steel", Material { hyperelastic: Some(Hyperelastic::MooneyRivlin { c01: 10000.0 }), ..steel() }).unwrap();
let deck = abaqus::write(&m).unwrap();
let line = deck.split("*HYPERELASTIC, MOONEY-RIVLIN\n").nth(1).and_then(|s| s.lines().next()).unwrap();
let values: Vec<f64> = line.split(", ").map(|v| v.parse().unwrap()).collect();
assert!((values[0] - (c10 - 10000.0)).abs() < 1e-9 * c10 && values[1] == 10000.0 && (values[2] - d1).abs() < 1e-9 * d1, "{line}");
m.replace_material("Steel", Material { hyperelastic: Some(Hyperelastic::Yeoh { c20: -50.0, c30: 2.0 }), ..steel() }).unwrap();
let deck = abaqus::write(&m).unwrap();
let line = deck.split("*HYPERELASTIC, YEOH\n").nth(1).and_then(|s| s.lines().next()).unwrap();
let values: Vec<f64> = line.split(", ").map(|v| v.parse().unwrap()).collect();
assert!((values[0] - c10).abs() < 1e-9 * c10 && values[1..3] == [-50.0, 2.0] && (values[3] - d1).abs() < 1e-9 * d1, "{line}");
m.replace_material("Steel", Material { hyperelastic: Some(Hyperelastic::NeoHookean {}), ..steel() }).unwrap();
m.add_step(step("Linear", Procedure::Static {}, &["clamp"], &["end"]), None).unwrap();
assert!(unsupported(&m).contains("step `Linear`: a linear static step beside nonlinear steps of hyperelastic material `Steel`"));
m.remove_step("Bend").unwrap();
assert!(abaqus::write(&m).unwrap().contains("*ELASTIC, TYPE=ISOTROPIC\n"), "with linear steps only, the elastic constants are the whole of it");
}
/// A plastic material in nonlinear steps is `*PLASTIC` in a general step; what Abaqus would
/// read differently is refused: a load path, and several general steps (Abaqus carries the
/// plastic state between them).
#[test]
fn nonlinear_plastic_steps_and_what_is_refused() {
use fieldcraft_model::{Hardening, NonlinearStatic, Plastic};
let mut m = cantilever();
let plastic = Plastic::Bilinear { yield_stress: 250.0, hardening_modulus: 2000.0, hardening: Hardening::Isotropic };
m.replace_material("Steel", Material { plastic: Some(plastic), ..steel() }).unwrap();
m.replace_step(step("Bend", Procedure::NonlinearStatic(NonlinearStatic::default()), &["clamp"], &["end"])).unwrap();
let deck = abaqus::write(&m).unwrap();
assert!(deck.contains("*PLASTIC\n250.0, 0.0\n") && deck.contains("NLGEOM=YES"), "{deck}");
let path = NonlinearStatic { load_path: Some(vec![[0.0, 0.0], [0.5, 1.0], [1.0, 0.0]]), ..NonlinearStatic::default() };
m.replace_step(step("Bend", Procedure::NonlinearStatic(path), &["clamp"], &["end"])).unwrap();
assert!(unsupported(&m).contains("step `Bend`: a load path"));
m.replace_step(step("Bend", Procedure::NonlinearStatic(NonlinearStatic::default()), &["clamp"], &["end"])).unwrap();
m.add_step(step("Again", Procedure::NonlinearStatic(NonlinearStatic::default()), &["clamp"], &["end"]), None).unwrap();
assert!(unsupported(&m).contains("plastic material `Steel` in a deck of 2 general steps"));
}
+13
View File
@@ -760,6 +760,19 @@ fn nonlinear_static_steps() {
assert_eq!(auto.iter().map(num).collect::<Vec<_>>(), [1.0, 1e-4, 0.5], "blank fields are skipped: {auto:?}");
assert!(deck.contains("*DEFINE_CURVE_TITLE\nBend ramp\n"), "the loads follow the ramp");
assert!(deck.contains("*LOAD_NODE"), "{deck}");
// A load path is the curve.
let path = NonlinearStatic { load_path: Some(vec![[0.0, 0.0], [0.5, 1.0], [1.0, -1.0]]), ..controls.clone() };
m.replace_step(step("Bend", Procedure::NonlinearStatic(path), &["clamp"], &["tip_load"])).unwrap();
let deck = lsdyna::write(&m).unwrap().text;
let curve: Vec<Vec<f64>> = deck
.lines()
.skip_while(|l| *l != "Bend ramp")
.filter(|l| !l.starts_with('$'))
.skip(2)
.take(3)
.map(|l| l.split_whitespace().map(|v| v.parse().unwrap()).collect())
.collect();
assert_eq!(curve, [vec![0.0, 0.0], vec![0.5, 1.0], vec![1.0, -1.0]], "{deck}");
m.replace_step(step("Bend", Procedure::NonlinearStatic(NonlinearStatic { nonlinear_geometry: false, ..controls }), &["clamp"], &["tip_load"]))
.unwrap();
+2
View File
@@ -27,3 +27,5 @@ log = { workspace = true }
[dev-dependencies]
zip = { workspace = true }
# Multi-body designs from SolidCraft scripts in tests.
fieldcraft-geometry = { workspace = true, features = ["scripts"] }
+14 -1
View File
@@ -272,9 +272,22 @@ pub static CATALOGUE: &[Capability] = &[
"Hex mesh swept along extrusions (several levels), around revolutions, or from a source face to a target",
&["mesh.hex.generate"],
),
ready(
"mesh.hex.blocks",
"Block Hex",
Mesh,
"Structured Hex",
"One conforming hex mesh of a part split into six-sided blocks: shared faces meshed once",
),
soon("mesh.hex.decompose", "Decompose", Mesh, "Structured Hex", "M5", "Find sweepable and mappable regions and propose the splits"),
soon("mesh.transition", "Transition", Mesh, "Structured Hex", "M5", "Conforming tet–hex interfaces with pyramids"),
soon("mesh.hex.octree", "All-Hex", Mesh, "Automatic Hex", "M6", "Octree dual all-hex mesh of any part"),
ready(
"mesh.hex.octree",
"Octree Hex",
Mesh,
"Automatic Hex",
"Unstructured all-hex mesh of any part: adaptive grid, boundary layers on every sharp edge, untangled and proven valid",
),
soon("mesh.hex.dominant", "Hex-Dominant", Mesh, "Automatic Hex", "M6", "Hex-dominant mesh by recombining tets"),
soon("mesh.hex.polycube", "Polycube", Mesh, "Automatic Hex", "M6", "Polycube hex mesh of blocky parts"),
soon("mesh.smooth", "Smooth", Mesh, "Improve", "M6", "Smooth node positions, keeping every element valid"),
+2 -2
View File
@@ -81,7 +81,7 @@ definition_commands!(
Material,
"material",
"Material",
r#"{"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3} | {"type": "engineering_constants", "young": [E1, E2, E3], "poisson": [ν12, ν13, ν23], "shear": [G12, G13, G23]}, "density": 7.85e-9, "fluid": {"type": "newtonian", "viscosity": 1.0e-9} (dynamic viscosity; a fluid needs a density), "plastic": {…} (see model.material.plasticity), "damage": {…}, "erosion": {…} (see model.material.damage), "hyperelastic": {"type": "neo_hookean"} (large strain in nonlinear steps, from the isotropic constants: μ = E/(2(1 + ν)), κ = E/(3(1 − 2ν)); without it an elastic material is St Venant–Kirchhoff there)} — in the model's unit system (model.units), never converted"#,
r#"{"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3} | {"type": "engineering_constants", "young": [E1, E2, E3], "poisson": [ν12, ν13, ν23], "shear": [G12, G13, G23]}, "density": 7.85e-9, "fluid": {"type": "newtonian", "viscosity": 1.0e-9} (dynamic viscosity; a fluid needs a density), "plastic": {…} (see model.material.plasticity), "damage": {…}, "erosion": {…} (see model.material.damage), "hyperelastic": {"type": "neo_hookean"} | {"type": "mooney_rivlin", "c01": 0.1} (C10 = μ/2 − C01) | {"type": "yeoh", "c20": -0.01, "c30": 0.001} (C10 = μ/2) (large strain in nonlinear steps, from the isotropic constants: μ = E/(2(1 + ν)), κ = E/(3(1 − 2ν)); without it an elastic material is St Venant–Kirchhoff there)} — in the model's unit system (model.units), never converted"#,
add_material,
replace_material,
remove_material,
@@ -266,7 +266,7 @@ fn fluid_boundary(s: &mut Session, p: &Value) -> Result<Value> {
pub const STEP_CREATE: CommandSpec = CommandSpec {
id: "model.step.create",
label: "New Step",
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false} (loads and prescribed displacements ramp with the load factor from 0 to 1) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal"}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal"}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
kind: CommandKind::Model,
run: step_create,
};
+238 -30
View File
@@ -3,7 +3,7 @@
use std::borrow::Cow;
use fieldcraft_geometry::{FaceSurface, Shape, SurfaceMesh};
use fieldcraft_mesh::hex::{HexMesh, HexMethod, HexOptions, Surface, hex_mesh};
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};
@@ -28,13 +28,13 @@ const MIN_RELATIVE_TOLERANCE: f64 = 1e-5;
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
const DEFAULT_RELATIVE_TOLERANCE: f64 = 1e-3;
/// One body to mesh.
struct Input<'a> {
name: String,
mesh: Cow<'a, SurfaceMesh>,
/// One body to mesh (also the input of `mesh.hex.octree`).
pub(super) struct Input<'a> {
pub(super) name: String,
pub(super) mesh: Cow<'a, SurfaceMesh>,
surfaces: Vec<Option<Surface>>,
face_names: Vec<String>,
diagonal: f64,
pub(super) diagonal: f64,
}
fn analytic(s: FaceSurface) -> Surface {
@@ -47,7 +47,7 @@ fn analytic(s: FaceSurface) -> Surface {
}
}
fn number(p: &Value, key: &str) -> Result<Option<f64>> {
pub(super) fn number(p: &Value, key: &str) -> Result<Option<f64>> {
match p.get(key) {
None => Ok(None),
Some(v) => v
@@ -73,7 +73,7 @@ fn shape_input(shape: &Shape, tolerance: Option<f64>) -> Result<Input<'static>>
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(shape.tessellate(tol)?), surfaces, face_names, diagonal: diag })
}
fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
let tolerance = number(p, "tolerance")?;
match (p.get("body"), p.get("surface")) {
(Some(b), None) => {
@@ -129,6 +129,134 @@ pub const HEX_MAPPED: CommandSpec = CommandSpec {
run: hex_mapped,
};
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 mesh 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"#,
kind: CommandKind::Model,
run: hex_blocks,
};
fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
let size = number(p, "size")?;
let quadratic = order(p)?;
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
let shapes: Vec<&Shape> = match p.get("bodies") {
None => design.bodies.iter().collect(),
Some(v) => {
let names = v.as_array().ok_or_else(|| EngineError::Params("`bodies` must be a list of body names".into()))?;
// Bounded before the duplicate check, which is quadratic in the list.
if names.len() > MAX_CELLS {
return Err(EngineError::Params(format!("`bodies` lists {} bodies; at most {MAX_CELLS} are meshed together", names.len())));
}
let mut out: Vec<&Shape> = Vec::with_capacity(names.len());
for n in names {
let name = n.as_str().ok_or_else(|| EngineError::Params("`bodies` must be a list of body names".into()))?;
let shape = design.body(name).ok_or_else(|| EngineError::Params(format!("no body `{name}`")))?;
if out.iter().any(|b| b.name == shape.name) {
return Err(EngineError::Params(format!("body `{name}` is listed twice")));
}
out.push(shape);
}
out
}
};
if shapes.is_empty() || shapes.len() > MAX_CELLS {
return Err(EngineError::Params(format!("{} bodies; mesh.hex.blocks meshes 1 to {MAX_CELLS}", shapes.len())));
}
// One size and one tessellation tolerance for all cells, from their joint extent, so shared
// curves are divided alike.
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for b in &shapes {
let m = b.measure()?;
for k in 0..3 {
lo[k] = lo[k].min(m.bbox_min[k]);
hi[k] = hi[k].max(m.bbox_max[k]);
}
}
let diag = diagonal(lo, hi);
if !(diag > 0.0 && diag.is_finite()) {
return Err(EngineError::Params("the bodies have no extent".into()));
}
let tolerance = number(p, "tolerance")?.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * diag);
if tolerance < MIN_RELATIVE_TOLERANCE * diag {
return Err(EngineError::Params(format!("`tolerance` must be at least {} mm", MIN_RELATIVE_TOLERANCE * diag)));
}
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * diag);
if h < MIN_RELATIVE_SIZE * diag {
return Err(EngineError::Params(format!("`size` must be at least {} mm", MIN_RELATIVE_SIZE * diag)));
}
let inputs: Vec<Input> = shapes.iter().map(|b| shape_input(b, Some(tolerance))).collect::<Result<_>>()?;
let mut surfaces = Vec::with_capacity(inputs.len());
for b in &inputs {
let options = super::mesh::surface_options(p, &b.mesh)?;
surfaces.push(
BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &options)
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?,
);
}
let cells: Vec<Cell> = surfaces.iter().zip(&inputs).map(|(surface, b)| Cell { name: &b.name, surface, faces: &b.surfaces }).collect();
let options = HexOptions {
method: HexMethod::Mapped,
quadratic,
chordal_tolerance: tolerance,
max_elements: fieldcraft_model::MAX_ELEMENTS / 8,
..HexOptions::default()
};
let cm = hex_mesh_cells(&cells, &UniformSize(h), &options).map_err(|e| EngineError::Params(e.to_string()))?;
let m = &cm.mesh;
// Replace the mesh: definitions stay, nodes, elements and geometry tags are new.
let mut model = s.model.without_mesh();
for (i, xyz) in m.nodes.iter().enumerate() {
model.set_node(label(i)?, *xyz)?;
}
let mut body_tags = Vec::with_capacity(inputs.len());
for b in &inputs {
body_tags.push(model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?);
}
for e in 0..m.element_count() {
let cell = cm.element_cell.get(e).map_or(0, |&c| c as usize);
let nodes: Vec<u32> = m.element(e).unwrap_or(&[]).iter().map(|&i| i + 1).collect();
model.set_element(label(e)?, m.kind, nodes)?;
model.tag_element(label(e)?, body_tags.get(cell).copied().ok_or_else(|| EngineError::Params("element without a cell".into()))?)?;
}
tag_faces(&mut model, &inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), &m.boundary, 1)?;
s.model = model;
let blocks: Vec<Value> =
inputs.iter().zip(&cm.blocks).map(|(b, d)| json!({"body": b.name, "divisions": d, "elements": d.iter().product::<usize>()})).collect();
Ok(json!({
"cells": inputs.len(),
"interfaces": cm.interfaces,
"description": m.report.description,
"kind": m.kind.name(),
"elements": m.element_count(),
"nodes": m.nodes.len(),
"boundary_faces": m.boundary.len(),
"quality": {"min_scaled_jacobian": m.report.min_scaled_jacobian, "mean_scaled_jacobian": m.report.mean_scaled_jacobian},
"volume": m.report.volume,
"surface_volume": m.report.surface_volume,
"size": h,
"blocks": blocks,
}))
}
/// A 1-based model label for 0-based index `i`.
fn label(i: usize) -> Result<u32> {
u32::try_from(i).ok().and_then(|x| x.checked_add(1)).ok_or_else(|| EngineError::Params("too many nodes or elements".into()))
}
/// The `order` parameter: false for hex8, true for hex20.
fn order(p: &Value) -> Result<bool> {
match p.get("order") {
None => Ok(false),
Some(v) => match v.as_u64() {
Some(1) => Ok(false),
Some(2) => Ok(true),
_ => Err(EngineError::Params("`order` must be 1 (hex8) or 2 (hex20)".into())),
},
}
}
fn hex_generate(s: &mut Session, p: &Value) -> Result<Value> {
let all = [HexMethod::Auto, HexMethod::Swept, HexMethod::Extrude, HexMethod::Sweep, HexMethod::Revolve, HexMethod::Mapped];
generate(s, p, HexMethod::Auto, &all)
@@ -145,14 +273,7 @@ fn hex_mapped(s: &mut Session, p: &Value) -> Result<Value> {
/// Mesh with `default` or the `method` parameter, which must be one of `allowed`.
fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod]) -> Result<Value> {
let size = number(p, "size")?;
let quadratic = match p.get("order") {
None => false,
Some(v) => match v.as_u64() {
Some(1) => false,
Some(2) => true,
_ => return Err(EngineError::Params("`order` must be 1 (hex8) or 2 (hex20)".into())),
},
};
let quadratic = order(p)?;
let method = match p.get("method") {
None => default,
Some(v) => v.as_str().and_then(HexMethod::from_name).filter(|m| allowed.contains(m)).ok_or_else(|| {
@@ -197,7 +318,7 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
model.tag_element(element_label, body_tag)?;
element_label = element_label.checked_add(1).ok_or_else(|| EngineError::Params("too many elements".into()))?;
}
tag_faces(&mut model, b, m, first_element)?;
tag_faces(&mut model, std::slice::from_ref(b), |_| 0, &m.boundary, first_element)?;
report.push(json!({
"body": b.name,
"method": m.report.method.name(),
@@ -215,22 +336,26 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
Ok(json!({"bodies": report, "nodes": node_label - 1, "elements": element_label - 1}))
}
/// Tag every boundary element face with its B-rep face.
fn tag_faces(model: &mut Model, b: &Input<'_>, m: &HexMesh, first_element: u32) -> Result<()> {
let mut tags: Vec<Option<u32>> = Vec::new();
for f in &m.boundary {
let i = f.brep_face as usize;
if tags.len() <= i {
tags.resize(i + 1, None);
}
let tag = match tags.get(i).copied().flatten() {
Some(t) => t,
/// Tag every boundary element face with the B-rep face it lies on, of the body
/// (`bodies[cell(element)]`) its element meshes.
pub(super) fn tag_faces(
model: &mut Model,
bodies: &[Input<'_>],
cell: impl Fn(u32) -> usize,
boundary: &[BoundaryFace],
first_element: u32,
) -> Result<()> {
let mut tags: std::collections::BTreeMap<(usize, u32), u32> = std::collections::BTreeMap::new();
for f in boundary {
let c = cell(f.element);
let tag = match tags.get(&(c, f.brep_face)) {
Some(&t) => t,
None => {
let b = bodies.get(c).ok_or_else(|| EngineError::Params("a boundary face without its body".into()))?;
let i = f.brep_face as usize;
let name = b.face_names.get(i).cloned().unwrap_or_else(|| i.to_string());
let t = model.geometry_tag(&GeometryRef::face(b.name.as_str(), name)?)?;
if let Some(slot) = tags.get_mut(i) {
*slot = Some(t);
}
tags.insert((c, f.brep_face), t);
t
}
};
@@ -291,6 +416,89 @@ mod tests {
assert_eq!(s.model.element_counts(), vec![(fieldcraft_model::ElementKind::Hex8, n)]);
}
/// A 20 × 10 × 10 box split by SolidCraft into four cells (x = 10, then y = 5 on each half),
/// opened as a design; with `t_junction` the second half is not split, so the first half's
/// two cells meet it on one face.
fn split_box(t_junction: bool) -> (crate::tests::TempDir, Session) {
let mut commands = vec![
json!({"command": "solid.box", "params": {"length": 20, "width": 10, "height": 10, "body_name": "Box"}}),
json!({"command": "solid.split_body", "params": {"body": "Box", "plane": {"origin": [10, 0, 0], "normal": [1, 0, 0]}}}),
json!({"command": "solid.split_body", "params": {"body": "Box (1)", "plane": {"origin": [0, 5, 0], "normal": [0, 1, 0]}}}),
];
if !t_junction {
commands.push(json!({"command": "solid.split_body", "params": {"body": "Box", "plane": {"origin": [0, 5, 0], "normal": [0, 1, 0]}}}));
}
let bytes = fieldcraft_geometry::script_design(&json!({"commands": commands}).to_string()).unwrap();
let (dir, path) = crate::tests::design_file(&bytes);
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path})).unwrap();
(dir, s)
}
#[test]
fn split_cells_mesh_into_one_conforming_hex_mesh() {
let (_dir, mut s) = split_box(false);
let r = s.execute("mesh.hex.blocks", &json!({"size": 2.5})).unwrap();
assert_eq!(r["cells"], 4, "{r}");
assert_eq!(r["interfaces"], 4, "{r}");
assert_eq!(r["elements"], 8 * 4 * 4, "{r}");
// One 9 × 5 × 5 grid of nodes: the cells' nodes are merged on the interfaces.
assert_eq!(r["nodes"], 9 * 5 * 5, "{r}");
assert_eq!(s.model.element_counts(), vec![(fieldcraft_model::ElementKind::Hex8, 128)]);
assert!((r["volume"].as_f64().unwrap() - 2000.0).abs() < 1e-6, "{r}");
// Each cell's elements carry its body, and the outer faces their B-rep faces: a set of
// all four bodies skins the box with no internal faces.
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
let names: Vec<Value> = bodies.as_array().unwrap().iter().map(|b| json!({"body": b["name"]})).collect();
s.execute("model.set.create", &json!({"name": "all", "geometry": names})).unwrap();
let all = s.execute("model.set.resolve", &json!({"name": "all"})).unwrap();
assert_eq!(all["elements"]["count"], 128, "{all}");
assert_eq!(all["faces"]["count"], r["boundary_faces"], "{all}");
assert_eq!(r["boundary_faces"], 2 * (8 * 4 + 8 * 4 + 4 * 4), "{r}");
let first = bodies[0]["name"].as_str().unwrap();
s.execute("model.set.create", &json!({"name": "one", "geometry": [{"body": first}]})).unwrap();
assert_eq!(s.execute("model.set.resolve", &json!({"name": "one"})).unwrap()["elements"]["count"], 32);
// Hex20 shares mid-edge nodes across the interfaces as well.
let q = s.execute("mesh.hex.blocks", &json!({"size": 2.5, "order": 2})).unwrap();
assert_eq!(q["kind"], "hex20");
assert_eq!(q["nodes"], 9 * 5 * 5 + 8 * 5 * 5 + 9 * 4 * 5 + 9 * 5 * 4, "{q}");
// A list of bodies meshes those only.
let two = s.execute("mesh.hex.blocks", &json!({"bodies": [bodies[0]["name"], bodies[1]["name"]], "size": 2.5})).unwrap();
assert_eq!(two["cells"], 2);
}
#[test]
fn cells_that_do_not_conform_and_hostile_params_are_errors() {
let (_dir, mut s) = split_box(true);
let before = s.model.clone();
let e = s.execute("mesh.hex.blocks", &json!({"size": 2.5})).unwrap_err().to_string();
assert!(e.contains("do not conform") && e.contains("`Box`"), "{e}");
assert_eq!(s.model, before);
for p in [
json!({"size": 0}),
json!({"size": -1}),
json!({"size": "big"}),
json!({"size": 1e-300}),
json!({"order": 3}),
json!({"tolerance": -1}),
json!({"tolerance": 1e-300}),
json!({"bodies": "Box"}),
json!({"bodies": []}),
json!({"bodies": ["Nope"]}),
json!({"bodies": ["Box", "Box"]}),
json!({"bodies": [7]}),
json!({"size": 1e-3}),
] {
assert!(s.execute("mesh.hex.blocks", &p).is_err(), "{p}");
assert_eq!(s.model, before, "{p}");
}
assert!(Session::default().execute("mesh.hex.blocks", &json!({})).is_err());
// One cell that is no block says which.
let (_dir, mut plate) = opened();
let e = plate.execute("mesh.hex.blocks", &json!({})).unwrap_err().to_string();
assert!(e.contains("`Body1`") && e.contains("not a logical cube"), "{e}");
}
#[test]
fn hostile_params_are_errors_and_change_nothing() {
let (_dir, mut s) = opened();
+1 -1
View File
@@ -93,7 +93,7 @@ const TET_MAX_STEINER: u64 = 10_000_000;
const TET_MAX_TETS: u64 = 50_000_000;
/// An optional whole-number parameter in `range`.
fn optional_count(p: &Value, key: &str, default: u64, range: std::ops::RangeInclusive<u64>) -> Result<usize> {
pub(super) fn optional_count(p: &Value, key: &str, default: u64, range: std::ops::RangeInclusive<u64>) -> Result<usize> {
let v = match p.get(key) {
None => default,
Some(v) => v
+3
View File
@@ -9,6 +9,7 @@ mod geometry;
mod hex;
pub(crate) mod mesh;
mod model;
pub(crate) mod octree;
mod results;
pub(crate) mod solve;
mod tags;
@@ -36,10 +37,12 @@ pub static ALL: &[CommandSpec] = &[
mesh::SURFACE_CHECK,
mesh::PLANAR,
mesh::TET_GENERATE,
octree::HEX_OCTREE,
block::BLOCK,
hex::HEX_GENERATE,
hex::HEX_SWEEP,
hex::HEX_MAPPED,
hex::HEX_BLOCKS,
tags::TAG,
tags::TAG_CLEAR,
model::UNITS,
+177
View File
@@ -0,0 +1,177 @@
//! Unstructured all-hexahedral meshing (`mesh.hex.octree`) of the open design's bodies or of
//! 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};
use super::hex::{Input, inputs, number, tag_faces};
use super::mesh::optional_count;
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 mesh 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"#,
kind: CommandKind::Model,
run: hex_octree,
};
/// Smallest element size, relative to the bounding-box diagonal (the grid's cell count grows
/// with the cube of its inverse).
const MIN_RELATIVE_SIZE: f64 = 1e-3;
/// Default element size, relative to the bounding-box diagonal.
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
/// Caps on the options.
const MAX_DEPTH: u64 = 5;
const MAX_ATTEMPTS: u64 = 8;
const MAX_SWEEPS: u64 = 10_000;
const MAX_HEX_ELEMENTS: u64 = 10_000_000;
/// The session default of `max_elements` ([`crate::Limits::max_hex_elements`]).
pub(crate) const DEFAULT_MAX_ELEMENTS: usize = 2_000_000;
fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
let size = number(p, "size")?;
let defaults = OctreeOptions::default();
let options = OctreeOptions {
max_depth: u8::try_from(optional_count(p, "max_depth", u64::from(defaults.max_depth), 0..=MAX_DEPTH)?).unwrap_or(defaults.max_depth),
max_elements: optional_count(p, "max_elements", s.limits.max_hex_elements as u64, 1..=MAX_HEX_ELEMENTS)?.min(MAX_ELEMENTS),
attempts: optional_count(p, "attempts", defaults.attempts as u64, 1..=MAX_ATTEMPTS)?,
max_sweeps: optional_count(p, "max_sweeps", defaults.max_sweeps as u64, 0..=MAX_SWEEPS)?,
..defaults
};
let bodies = inputs(s, p)?;
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);
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)));
}
let surface_options = super::mesh::surface_options(p, &b.mesh)?;
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::Params(format!("body `{}`: {e}", b.name)))?;
total += mesh.hexes.len();
meshes.push((b, mesh));
}
// Replace the mesh: definitions stay, nodes, elements and geometry tags are new.
let mut model = s.model.without_mesh();
let mut report = Vec::with_capacity(meshes.len());
let (mut node_label, mut element_label) = (1u32, 1u32);
for (b, m) in &meshes {
let first_node = node_label;
for xyz in &m.nodes {
model.set_node(node_label, *xyz)?;
node_label = node_label.checked_add(1).ok_or_else(|| EngineError::Params("too many nodes".into()))?;
}
let body_tag = model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?;
let first_element = element_label;
for hx in &m.hexes {
model.set_element(element_label, ElementKind::Hex8, hx.iter().map(|&i| first_node + i).collect())?;
model.tag_element(element_label, body_tag)?;
element_label = element_label.checked_add(1).ok_or_else(|| EngineError::Params("too many elements".into()))?;
}
tag_faces(&mut model, std::slice::from_ref(b), |_| 0, &m.boundary, first_element)?;
let r = &m.report;
report.push(json!({
"body": b.name,
"kind": "hex8",
"elements": m.hexes.len(),
"nodes": m.nodes.len(),
"boundary_faces": m.boundary.len(),
"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},
"features": {"corners": r.corners, "curves": r.curves, "patches": r.patches},
"attempts": r.attempts,
"retries": r.retries,
"sweeps": r.sweeps,
}));
}
s.model = model;
Ok(json!({"bodies": report, "nodes": node_label - 1, "elements": element_label - 1}))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use fieldcraft_geometry::open_design;
use fieldcraft_geometry::sample::{Plate, plate_design};
use serde_json::json;
use crate::Session;
fn opened() -> Session {
let plate = Plate::default();
Session { design: Some(Arc::new(open_design(&plate_design(&plate).unwrap()).unwrap())), ..Session::default() }
}
#[test]
fn plate_with_a_hole_meshes_all_hex_and_face_sets_resolve() {
let mut s = opened();
let r = s.execute("mesh.hex.octree", &json!({"body": "Body1", "size": 5.0, "max_depth": 2})).unwrap();
let body = &r["bodies"][0];
let n = body["elements"].as_u64().unwrap() as usize;
assert!(n > 50, "{r}");
assert!(body["quality"]["min_scaled_jacobian"].as_f64().unwrap() > 0.0, "{r}");
assert_eq!(s.model.element_counts(), vec![(fieldcraft_model::ElementKind::Hex8, n)]);
let (v, sv) = (body["volume"].as_f64().unwrap(), body["surface_volume"].as_f64().unwrap());
assert!((v - sv).abs() < 0.02 * sv, "{r}");
// The certified quality report agrees.
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["valid"].as_u64().unwrap() as usize, n);
// Every boundary face is tagged: the skin of the body set; each named face resolves.
s.execute("model.set.create", &json!({"name": "skin", "geometry": [{"body": "Body1"}]})).unwrap();
let skin = s.execute("model.set.resolve", &json!({"name": "skin"})).unwrap();
assert_eq!(skin["faces"]["count"], body["boundary_faces"], "{skin}");
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
for f in bodies[0]["faces"].as_array().unwrap() {
let name = f["name"].as_str().unwrap();
s.execute("model.set.create", &json!({"name": "f", "geometry": [{"body": "Body1", "face": name}]})).unwrap();
let set = s.execute("model.set.resolve", &json!({"name": "f"})).unwrap();
assert!(set["faces"]["count"].as_u64().unwrap() > 0, "face {name}: {set}");
s.execute("model.set.delete", &json!({"name": "f"})).unwrap();
}
}
#[test]
fn hostile_params_are_errors_and_change_nothing() {
let mut s = opened();
let before = s.model.clone();
for p in [
json!({"body": "Body1", "size": 0}),
json!({"body": "Body1", "size": -1}),
json!({"body": "Body1", "size": 1e-300}),
json!({"body": "Body1", "size": "big"}),
json!({"body": "Body1", "tolerance": 1e-300}),
json!({"body": "Body1", "tolerance": -1}),
json!({"body": "Body1", "max_depth": 9}),
json!({"body": "Body1", "max_depth": -1}),
json!({"body": "Body1", "max_elements": 0}),
json!({"body": "Body1", "max_elements": 1e300}),
json!({"body": "Body1", "attempts": 0}),
json!({"body": "Body1", "attempts": 100}),
json!({"body": "Body1", "max_sweeps": "many"}),
json!({"body": "Body1", "size": 5.0, "max_depth": 2, "max_elements": 10}),
json!({"body": "Nope"}),
json!({"body": 7}),
json!({"surface": 0}),
json!({"surface": -1}),
json!({"body": "Body1", "surface": 0}),
] {
let r = s.execute("mesh.hex.octree", &p);
assert!(r.is_err(), "{p}: {r:?}");
assert_eq!(s.model, before, "{p}");
}
assert!(Session::default().execute("mesh.hex.octree", &json!({})).is_err());
}
}
+8 -3
View File
@@ -34,7 +34,7 @@ pub const STATIC: CommandSpec = CommandSpec {
pub const NONLINEAR: CommandSpec = CommandSpec {
id: "solve.nonlinear",
label: "Solve Nonlinear Static Step",
params: r#"{"step": "Inflate", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_newton_iterations": 100000, "threads": 8} — solves a nonlinear static step (the only one when `step` is left out) in load increments with Newton–Raphson iterations and keeps the result in the session: the final displacements, reactions, Cauchy stresses and logarithmic strains, every increment's load factor, iterations and residuals (force, displacement and energy, relative to their tolerances' scales), and a displacement frame per converged increment; the caps may only be lowered (`max_newton_iterations` over the whole step); `threads` as for solve.static"#,
params: r#"{"step": "Inflate", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_newton_iterations": 100000, "threads": 8} — solves a nonlinear static step (the only one when `step` is left out) in load increments with Newton–Raphson iterations and keeps the result in the session: the final displacements, reactions, Cauchy stresses and logarithmic strains (small strains without nonlinear geometry), the equivalent plastic strain of plastic materials (the largest in the summary), every increment's load factor, iterations and residuals (force, displacement and energy, relative to their tolerances' scales), and a displacement frame per converged increment; the caps may only be lowered (`max_newton_iterations` over the whole step); `threads` as for solve.static"#,
kind: CommandKind::Model,
run: run_nonlinear,
};
@@ -82,7 +82,7 @@ pub const FLUID: CommandSpec = CommandSpec {
pub const PROBE: CommandSpec = CommandSpec {
id: "solve.probe",
label: "Probe Results",
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1, "frame": 0} — the stored result at nodes: displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses, shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1, "frame": 0} — the stored result at nodes: displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
kind: CommandKind::Model,
run: probe,
};
@@ -220,6 +220,7 @@ fn run_nonlinear(s: &mut Session, p: &Value) -> Result<Value> {
"frames": history.map(|h| h.load_factors()),
"max_displacement": largest(r.nodes.iter().zip(&r.displacement).map(|(l, u)| (*l, crate::results::magnitude(*u)))),
"max_von_mises": largest(r.nodes.iter().zip(&r.von_mises).map(|(l, v)| (*l, *v))),
"max_plastic_strain": largest(r.elements.iter().flat_map(|e| e.plastic_strain.iter().map(|v| (e.label, *v)))),
"applied_total": r.applied_total,
"reaction_total": r.reaction_total,
"reference_points": r.reference_points.iter().map(|p| json!({
@@ -613,7 +614,11 @@ fn by_label<T>(list: &[T], label: u32, key: impl Fn(&T) -> u32) -> Option<&T> {
/// The stored results of one element of a static step.
fn element_result(r: &StaticResult, label: u32) -> Option<Value> {
if let Some(e) = by_label(&r.elements, label, |e| e.label) {
return Some(json!({"element": label, "kind": e.kind.name(), "stress": e.nodal}));
let mut v = json!({"element": label, "kind": e.kind.name(), "stress": e.nodal});
if !e.plastic_strain.is_empty() {
v["plastic_strain"] = json!(e.plastic_strain);
}
return Some(v);
}
if let Some(e) = by_label(&r.shells, label, |e| e.label) {
let s = &e.stress;
+19 -5
View File
@@ -135,6 +135,8 @@ pub struct Limits {
pub max_steiner: usize,
/// `mesh.tet.generate`'s `max_tets`.
pub max_tets: usize,
/// `mesh.hex.octree`'s `max_elements`.
pub max_hex_elements: usize,
/// Threads the solvers use (the `solve.*` commands' `threads`): by default
/// `FIELDCRAFT_THREADS` or the available cores, 1 to [`fieldcraft_parallel::MAX_THREADS`].
/// Results do not depend on it.
@@ -144,7 +146,12 @@ pub struct Limits {
impl Default for Limits {
fn default() -> Self {
let cdt = fieldcraft_mesh::cdt::CdtOptions::default();
Limits { max_steiner: cdt.max_steiner, max_tets: cdt.max_tets, threads: fieldcraft_parallel::default_threads() }
Limits {
max_steiner: cdt.max_steiner,
max_tets: cdt.max_tets,
max_hex_elements: cmd::octree::DEFAULT_MAX_ELEMENTS,
threads: fieldcraft_parallel::default_threads(),
}
}
}
@@ -471,9 +478,11 @@ mod tests {
json!({"geometry": {"body": "Bar", "vertex": "v"}, "nodes": [1], "edges": [{"element": 1, "edge": 0}]}),
];
// Small work caps, so input that would mesh up to the production caps (needle triangles
// need Steiner points all along them) is refused at once. The caps themselves are what
// need Steiner points all along them, an octree hex mesh is fitted and untangled) is
// refused at once. The caps themselves are what
// `tet_generate_honours_the_session_caps` checks.
let capped = || Session { limits: Limits { max_steiner: 64, max_tets: 100_000, ..Limits::default() }, ..Session::default() };
let capped =
|| Session { limits: Limits { max_steiner: 64, max_tets: 100_000, max_hex_elements: 500, ..Limits::default() }, ..Session::default() };
for spec in commands() {
for p in &hostile {
let mut s = capped();
@@ -530,9 +539,14 @@ mod tests {
/// A design file in a fresh temporary directory (removed when the guard drops).
pub(crate) fn sample_design_file(plate: &Plate) -> (TempDir, String) {
design_file(&plate_design(plate).unwrap())
}
/// Design file bytes written to a temporary directory.
pub(crate) fn design_file(bytes: &[u8]) -> (TempDir, String) {
let dir = TempDir::new();
let path = dir.0.join("plate.solidcraft");
std::fs::write(&path, plate_design(plate).unwrap()).unwrap();
let path = dir.0.join("design.solidcraft");
std::fs::write(&path, bytes).unwrap();
(dir, path.to_string_lossy().into_owned())
}
+13 -1
View File
@@ -32,6 +32,8 @@ pub struct Palette {
pub mesh_edge: Rgb,
pub geometry: Rgb,
pub geometry_edge: Rgb,
/// The bodies of a design with several, in turn.
pub bodies: [Rgb; 8],
/// Faces of nodes without a value in a contour plot.
pub no_result: Rgb,
/// The contour colour scale, low to high.
@@ -52,6 +54,16 @@ impl Palette {
mesh_edge: Rgb(0x1e, 0x23, 0x2a),
geometry: Rgb(0xb4, 0xba, 0xc4),
geometry_edge: Rgb(0x2a, 0x30, 0x3a),
bodies: [
Rgb(0x8f, 0xb4, 0xd9),
Rgb(0xe0, 0xa4, 0x6c),
Rgb(0x8c, 0xc4, 0x8a),
Rgb(0xd6, 0x8c, 0x96),
Rgb(0xb0, 0x9c, 0xd8),
Rgb(0xd8, 0xc8, 0x70),
Rgb(0x7c, 0xc4, 0xc0),
Rgb(0xc0, 0xa0, 0x84),
],
no_result: Rgb(0x9a, 0xa0, 0xaa),
contour: [Rgb(0x2b, 0x4f, 0xd8), Rgb(0x2f, 0xb8, 0xe0), Rgb(0x3c, 0xc8, 0x64), Rgb(0xf0, 0xd2, 0x3c), Rgb(0xe0, 0x40, 0x2e)],
glyphs: GlyphColors {
@@ -125,7 +137,7 @@ fn model_scene(d: &DisplayMesh, p: &Palette) -> Scene {
s.tris = d.tris.iter().map(|t| SceneTri { p: t.p, normal: t.normal, color: p.element(t.kind) }).collect();
let faces_drawn = d.tris.is_empty();
if faces_drawn {
s.tris.extend(d.surface_tris.iter().map(|t| SceneTri { p: t.p, normal: t.normal, color: p.geometry }));
s.tris.extend(d.surface_tris.iter().map(|t| SceneTri { p: t.p, normal: t.normal, color: d.body_color(t, p.geometry, &p.bodies) }));
}
s.lines = d.edges.iter().map(|e| SceneLine { a: e.p[0], b: e.p[1], color: p.mesh_edge, width: EDGE_WIDTH }).collect();
let edge = if faces_drawn { p.mesh_edge } else { p.geometry_edge };
+168 -33
View File
@@ -36,11 +36,22 @@
//! §6.6, eqs. 6.165–6.169 and 6.193, in Kirchhoff form). The split is what the mixed (hybrid)
//! elements need: they take the isochoric part from [`MaterialModel::isochoric`] and the
//! volumetric one from [`MaterialModel::volumetric`] with the element's own dilatation.
//! - **Mooney–Rivlin and Yeoh**: decoupled polynomials
//! W̄ = C₁₀ (Ī₁ − 3) + C₀₁ (Ī₂ − 3) + C₂₀ (Ī₁ − 3)² + C₃₀ (Ī₁ − 3)³ with the neo-Hookean volume; their fictitious stress and
//! elasticity from Holzapfel 2000, eqs. 6.193 and 6.168 ([`MaterialModel::Polynomial`]).
//! - **J2 plasticity** ([`crate::elastoplastic`]): Simo's multiplicative plasticity with the
//! exponential return map in the principal logarithmic strains (finite strain), or the
//! additive radial return (small strain, [`MaterialModel::small_strain`]); decoupled too, the
//! return deviatoric and the Hencky volume K/2 (ln J)².
use std::sync::Arc;
use fieldcraft_model::{Elastic, Hyperelastic};
use crate::dense::det3;
use crate::elastoplastic;
use crate::material::{Elasticity, elasticity, stress};
use crate::plasticity::J2;
use crate::rotation::{IDENTITY, Mat3, mul, transpose, voigt};
use crate::{Error, Result};
@@ -61,11 +72,24 @@ pub struct Response {
#[derive(Debug, Clone, PartialEq)]
pub enum MaterialModel {
StVenantKirchhoff(Box<Elasticity>),
NeoHookean { shear: f64, bulk: f64 },
NeoHookean {
shear: f64,
bulk: f64,
},
/// A decoupled polynomial in the isochoric invariants,
/// W̄ = C₁₀ (Ī₁ − 3) + C₀₁ (Ī₂ − 3) + C₂₀ (Ī₁ − 3)² + C₃₀ (Ī₁ − 3)³ with `c` = (C₁₀, C₀₁, C₂₀, C₃₀), and U = κ/2 (J − 1)²:
/// Mooney–Rivlin and Yeoh.
Polynomial {
name: &'static str,
c: [f64; 4],
bulk: f64,
},
/// Von Mises plasticity (shared: one per material, whatever the element count).
Plastic(Arc<J2>),
}
/// The 6 × 6 matrix of the symmetric fourth-order identity 𝕀 (engineering-shear columns).
fn identity4() -> Tangent {
pub(crate) fn identity4() -> Tangent {
let mut i4 = [[0.0; 6]; 6];
for k in 0..6 {
i4[k][k] = if k < 3 { 1.0 } else { 0.5 };
@@ -110,27 +134,35 @@ fn jacobian(f: &Mat3) -> Result<f64> {
}
impl MaterialModel {
/// The model of a material's elastic behaviour in a nonlinear step: neo-Hookean when the
/// material asks for it (its isotropic constants give μ and κ), St Venant–Kirchhoff
/// The model of a material's elastic behaviour in a nonlinear step: the hyperelastic option
/// when the material has one (its isotropic constants give μ and κ), St Venant–Kirchhoff
/// otherwise.
pub fn from_model(elastic: &Elastic, hyperelastic: Option<&Hyperelastic>) -> Result<MaterialModel> {
match hyperelastic {
None => Ok(MaterialModel::StVenantKirchhoff(Box::new(elasticity(elastic)?))),
Some(Hyperelastic::NeoHookean {}) => {
let (young, poisson) = crate::material::isotropic_constants(elastic, "neo-Hookean")?;
Some(h) => {
let (young, poisson) = crate::material::isotropic_constants(elastic, h.name())?;
let (shear, bulk) = Hyperelastic::moduli(young, poisson);
Ok(MaterialModel::NeoHookean { shear, bulk })
Ok(Self::hyperelastic(h, shear, bulk))
}
}
}
/// The hyperelastic model of an option with small-strain moduli μ and κ.
fn hyperelastic(h: &Hyperelastic, shear: f64, bulk: f64) -> MaterialModel {
match h {
Hyperelastic::NeoHookean {} => MaterialModel::NeoHookean { shear, bulk },
_ => MaterialModel::Polynomial { name: h.name(), c: h.constants(shear), bulk },
}
}
/// The model of an element whose elasticity is `d` (the small-strain one) and whose material
/// has the large-strain option `hyperelastic`: neo-Hookean takes μ = D₄₄ and κ = D₁₂ + ⅔ μ of
/// an isotropic `d`, St Venant–Kirchhoff `d` itself.
/// has the large-strain option `hyperelastic`: the hyperelastic models take μ = D₄₄ and
/// κ = D₁₂ + ⅔ μ of an isotropic `d`, St Venant–Kirchhoff `d` itself.
pub fn from_elasticity(d: &Elasticity, hyperelastic: Option<&Hyperelastic>) -> MaterialModel {
match hyperelastic {
None => MaterialModel::StVenantKirchhoff(Box::new(*d)),
Some(Hyperelastic::NeoHookean {}) => MaterialModel::NeoHookean { shear: d[3][3], bulk: d[0][1] + 2.0 / 3.0 * d[3][3] },
Some(h) => Self::hyperelastic(h, d[3][3], d[0][1] + 2.0 / 3.0 * d[3][3]),
}
}
@@ -138,23 +170,43 @@ impl MaterialModel {
match self {
MaterialModel::StVenantKirchhoff(_) => "St Venant–Kirchhoff",
MaterialModel::NeoHookean { .. } => "neo-Hookean",
MaterialModel::Polynomial { name, .. } => name,
MaterialModel::Plastic(_) => "J2 plastic",
}
}
/// History variables per integration point.
pub fn history_len(&self) -> usize {
0
match self {
MaterialModel::Plastic(_) => elastoplastic::HISTORY,
_ => 0,
}
}
/// Whether the response depends on the path (the material keeps history), so the solver
/// treats it with the inelastic kinematics and predictors.
pub fn is_path_dependent(&self) -> bool {
self.history_len() > 0
}
/// The equivalent plastic strain in one point's history; `None` for an elastic material.
pub fn plastic_strain(&self, history: &[f64]) -> Option<f64> {
match self {
MaterialModel::Plastic(_) => Some(elastoplastic::value(history, elastoplastic::PLASTIC_STRAIN)),
_ => None,
}
}
/// Whether the strain energy splits into isochoric and volumetric parts (what mixed
/// elements need).
pub fn is_decoupled(&self) -> bool {
matches!(self, MaterialModel::NeoHookean { .. })
!matches!(self, MaterialModel::StVenantKirchhoff(_))
}
/// The Kirchhoff stress and spatial tangent at deformation gradient `f`, from the history
/// The Kirchhoff stress and spatial tangent at deformation gradient `f` and (for
/// temperature-dependent plasticity) temperature `temperature`, from the history
/// `committed` (written, updated, to `updated`).
pub fn response(&self, f: &Mat3, committed: &[f64], updated: &mut [f64]) -> Result<Response> {
pub fn response(&self, f: &Mat3, temperature: Option<f64>, committed: &[f64], updated: &mut [f64]) -> Result<Response> {
updated.iter_mut().zip(committed).for_each(|(u, c)| *u = *c);
let j = jacobian(f)?;
match self {
@@ -165,18 +217,25 @@ impl MaterialModel {
let t = push_forward(f);
Ok(Response { stress: std::array::from_fn(|a| (0..6).map(|b| t[a][b] * s[b]).sum()), tangent: sandwich(&t, d) })
}
MaterialModel::NeoHookean { .. } => {
let iso = self.isochoric(f)?;
let (p, dp) = self.volumetric(j).ok_or_else(|| Error::Invalid("volumetric response".into()))?;
MaterialModel::NeoHookean { .. } | MaterialModel::Polynomial { .. } | MaterialModel::Plastic(_) => {
let iso = self.isochoric(f, temperature, committed, updated)?;
let (p, dp) = self.volumetric(j, false).ok_or_else(|| Error::Invalid("volumetric response".into()))?;
Ok(add_pressure(&iso, j, p, j * dp))
}
}
}
/// The isochoric part of a decoupled material (τ_iso = dev τ̄ and its tangent).
pub fn isochoric(&self, f: &Mat3) -> Result<Response> {
let &MaterialModel::NeoHookean { shear: mu, .. } = self else {
return Err(Error::NotSupported(format!("the isochoric–volumetric split of a {} material", self.name())));
/// The isochoric part of a decoupled material (τ_iso = dev τ̄ and its tangent), from the
/// history `committed`; the history that changes is written to `updated`, which the caller
/// starts as a copy of `committed`.
pub fn isochoric(&self, f: &Mat3, temperature: Option<f64>, committed: &[f64], updated: &mut [f64]) -> Result<Response> {
let mu = match self {
MaterialModel::NeoHookean { shear, .. } => *shear,
MaterialModel::Plastic(j2) => return elastoplastic::finite_isochoric(j2, f, temperature, committed, updated),
MaterialModel::Polynomial { c, .. } => return polynomial_isochoric(c, f),
MaterialModel::StVenantKirchhoff(_) => {
return Err(Error::NotSupported(format!("the isochoric–volumetric split of a {} material", self.name())));
}
};
let j = jacobian(f)?;
let scale = j.powf(-2.0 / 3.0);
@@ -199,13 +258,74 @@ impl MaterialModel {
Ok(Response { stress: tau, tangent: c })
}
/// U′(J) and U″(J) of a decoupled material's volumetric energy.
pub fn volumetric(&self, j: f64) -> Option<(f64, f64)> {
match *self {
MaterialModel::NeoHookean { bulk, .. } => Some((bulk * (j - 1.0), bulk)),
/// U′(J) and U″(J) of a decoupled material's volumetric energy at dilatation J (with
/// `small`, J = 1 + tr ε of small strain, where every material's is K/2 (J − 1)²).
pub fn volumetric(&self, j: f64, small: bool) -> Option<(f64, f64)> {
match self {
MaterialModel::NeoHookean { bulk, .. } | MaterialModel::Polynomial { bulk, .. } => Some((bulk * (j - 1.0), *bulk)),
MaterialModel::Plastic(j2) => elastoplastic::volumetric(j2, j, small),
MaterialModel::StVenantKirchhoff(_) => None,
}
}
/// Small strain: the stress and its tangent at the (mechanical) strain `strain`
/// (engineering shears), from the history `committed` (written, updated, to `updated`);
/// `bulk` false keeps the deviatoric part only (what a mixed element takes). St
/// Venant–Kirchhoff is linear elasticity here; hyperelastic materials have no small-strain
/// form.
pub fn small_strain(&self, strain: &[f64; 6], temperature: Option<f64>, committed: &[f64], updated: &mut [f64], bulk: bool) -> Result<Response> {
updated.iter_mut().zip(committed).for_each(|(u, c)| *u = *c);
match self {
MaterialModel::Plastic(j2) => elastoplastic::small(j2, strain, temperature, committed, updated, bulk),
MaterialModel::StVenantKirchhoff(d) if bulk => Ok(Response { stress: stress(d, strain), tangent: **d }),
_ => Err(Error::NotSupported(format!("the {} material at small strain{}", self.name(), if bulk { "" } else { " in mixed elements" }))),
}
}
}
/// The isochoric response of the polynomial energy W̄(Ī₁, Ī₂) with `c` = (C₁₀, C₀₁, C₂₀, C₃₀)
/// (Holzapfel 2000, §6.5–6.6, in Kirchhoff form): the fictitious stress
/// τ̄ = 2 (Ψ₁ + Ī₁ Ψ₂) b̄ − 2 Ψ₂ b̄², τ_iso = dev τ̄, and with the fictitious elasticity
/// c̄ = 4 (Ψ₁₁ + Ψ₂) b̄ ⊗ b̄ − 4 Ψ₂ b̄ ⊙ b̄ (eq. 6.193 pushed forward, Ψ₁₂ = Ψ₂₂ = 0 here),
/// c_iso = ℙ : c̄ : ℙ + ⅔ tr τ̄ ℙ − ⅔ (τ_iso ⊗ I + I ⊗ τ_iso) (eq. 6.168). With C₁₀ alone it is
/// the neo-Hookean response.
fn polynomial_isochoric(c: &[f64; 4], f: &Mat3) -> Result<Response> {
let [c10, c01, c20, c30] = *c;
let j = jacobian(f)?;
let scale = j.powf(-2.0 / 3.0);
let b = mul(f, &transpose(f)).map(|row| row.map(|v| scale * v));
let b2 = mul(&b, &b);
let i1 = b[0][0] + b[1][1] + b[2][2];
let x = i1 - 3.0;
let (psi1, psi2, psi11) = (c10 + 2.0 * c20 * x + 3.0 * c30 * x * x, c01, 2.0 * c20 + 6.0 * c30 * x);
let (bv, b2v) = (voigt(&b), voigt(&b2));
let fict: [f64; 6] = std::array::from_fn(|a| 2.0 * (psi1 + i1 * psi2) * bv[a] - 2.0 * psi2 * b2v[a]);
let tr = fict[0] + fict[1] + fict[2];
let mut tau = fict;
for v in tau.iter_mut().take(3) {
*v -= tr / 3.0;
}
let mut cbar = [[0.0; 6]; 6];
for (a, &(i, jj)) in PAIRS.iter().enumerate() {
for (bb, &(k, l)) in PAIRS.iter().enumerate() {
cbar[a][bb] = 4.0 * (psi11 + psi2) * bv[a] * bv[bb] - 2.0 * psi2 * (b[i][k] * b[jj][l] + b[i][l] * b[jj][k]);
}
}
// ℙ : c̄ : ℙ = c̄ − ⅓ I ⊗ (I : c̄) − ⅓ (c̄ : I) ⊗ I + ⅑ (I : c̄ : I) I ⊗ I.
let row: [f64; 6] = std::array::from_fn(|a| (0..3).map(|bb| cbar[a][bb]).sum());
let col: [f64; 6] = std::array::from_fn(|bb| (0..3).map(|a| cbar[a][bb]).sum());
let all: f64 = (0..3).map(|a| row[a]).sum();
let i4 = identity4();
let mut out = [[0.0; 6]; 6];
for a in 0..6 {
for bb in 0..6 {
let (ia, ib) = (if a < 3 { 1.0 } else { 0.0 }, if bb < 3 { 1.0 } else { 0.0 });
let projected = cbar[a][bb] - ia * col[bb] / 3.0 - row[a] * ib / 3.0 + ia * ib * all / 9.0;
let projector = i4[a][bb] - ia * ib / 3.0;
out[a][bb] = projected + 2.0 / 3.0 * tr * projector - 2.0 / 3.0 * (tau[a] * ib + ia * tau[bb]);
}
}
Ok(Response { stress: tau, tangent: out })
}
/// An isochoric response with a pressure p acting on dilatation J: τ + J p I, and the tangent
@@ -269,7 +389,7 @@ mod tests {
/// The tangent's consistency: dS = C : dE with C the pull-back of c, checked by central
/// differences of S along several directions dF (dE = sym(Fᵀ dF), engineering shears).
fn check_tangent(m: &MaterialModel, f: &Mat3) {
let r = m.response(f, &[], &mut []).unwrap();
let r = m.response(f, None, &[], &mut []).unwrap();
let (inv, _) = inverse3(f).unwrap();
let back = sandwich(&push_forward(&inv), &r.tangent);
let scale = back.iter().flatten().fold(0.0f64, |a, v| a.max(v.abs()));
@@ -280,7 +400,7 @@ mod tests {
df[(k + 1) % 3][(k + 2) % 3] += 0.3;
let at = |s: f64| {
let fp: Mat3 = std::array::from_fn(|i| std::array::from_fn(|j| f[i][j] + s * df[i][j]));
pull_back_stress(&fp, &m.response(&fp, &[], &mut []).unwrap().stress)
pull_back_stress(&fp, &m.response(&fp, None, &[], &mut []).unwrap().stress)
};
let (sp, sm) = (at(h), at(-h));
let ftdf = mul(&transpose(f), &df);
@@ -300,13 +420,28 @@ mod tests {
check_tangent(&m, &deformation());
check_tangent(&m, &IDENTITY);
// Symmetric (hyperelastic): major symmetry of c.
let c = m.response(&deformation(), &[], &mut []).unwrap().tangent;
let c = m.response(&deformation(), None, &[], &mut []).unwrap().tangent;
for a in 0..6 {
for b in 0..6 {
assert!((c[a][b] - c[b][a]).abs() < 1e-9 * 5000.0, "{a} {b}");
}
}
}
// Mooney–Rivlin and Yeoh: consistent and symmetric; with C₁₀ alone the neo-Hookean model.
for m in [
MaterialModel::Polynomial { name: "Mooney–Rivlin", c: [120.0, 30.0, 0.0, 0.0], bulk: 5000.0 },
MaterialModel::Polynomial { name: "Yeoh", c: [150.0, 0.0, -12.0, 1.5], bulk: 5000.0 },
] {
check_tangent(&m, &deformation());
check_tangent(&m, &IDENTITY);
let c = m.response(&deformation(), None, &[], &mut []).unwrap().tangent;
assert!((0..36).all(|k| (c[k / 6][k % 6] - c[k % 6][k / 6]).abs() < 1e-9 * 5000.0), "{} symmetric", m.name());
}
let neo = MaterialModel::NeoHookean { shear: 300.0, bulk: 5000.0 };
let poly = MaterialModel::Polynomial { name: "neo", c: [150.0, 0.0, 0.0, 0.0], bulk: 5000.0 };
let (a, b) = (neo.response(&deformation(), None, &[], &mut []).unwrap(), poly.response(&deformation(), None, &[], &mut []).unwrap());
assert!(a.stress.iter().zip(&b.stress).all(|(x, y)| (x - y).abs() < 1e-11 * 300.0));
assert!(a.tangent.iter().flatten().zip(b.tangent.iter().flatten()).all(|(x, y)| (x - y).abs() < 1e-10 * 5000.0));
let ortho = crate::material::orthotropic([1000.0, 300.0, 500.0], [0.3, 0.2, 0.25], [100.0, 120.0, 90.0]).unwrap();
check_tangent(&MaterialModel::StVenantKirchhoff(Box::new(ortho)), &deformation());
}
@@ -317,7 +452,7 @@ mod tests {
let d = isotropic(e, nu).unwrap();
let neo = MaterialModel::from_model(&Elastic::Isotropic { young: e, poisson: nu }, Some(&Hyperelastic::NeoHookean {})).unwrap();
for m in [MaterialModel::StVenantKirchhoff(Box::new(d)), neo] {
let c = m.response(&IDENTITY, &[], &mut []).unwrap().tangent;
let c = m.response(&IDENTITY, None, &[], &mut []).unwrap().tangent;
for a in 0..6 {
for b in 0..6 {
assert!((c[a][b] - d[a][b]).abs() < 1e-9 * e, "{}: {a} {b}", m.name());
@@ -329,7 +464,7 @@ mod tests {
let k = crate::rotation::skew(&axis);
let k2 = mul(&k, &k);
let rot: Mat3 = std::array::from_fn(|i| std::array::from_fn(|j| IDENTITY[i][j] + s * k[i][j] + (1.0 - cth) * k2[i][j]));
let tau = m.response(&rot, &[], &mut []).unwrap().stress;
let tau = m.response(&rot, None, &[], &mut []).unwrap().stress;
assert!(tau.iter().all(|v| v.abs() < 1e-12 * e), "{}: {tau:?}", m.name());
}
}
@@ -343,14 +478,14 @@ mod tests {
let (lam, l) = (1.5f64, 0.85f64);
let f = [[lam, 0.0, 0.0], [0.0, l, 0.0], [0.0, 0.0, l]];
let j = lam * l * l;
let tau = m.response(&f, &[], &mut []).unwrap().stress;
let tau = m.response(&f, None, &[], &mut []).unwrap().stress;
let i1 = lam * lam + 2.0 * l * l;
let sxx = mu / j.powf(5.0 / 3.0) * (lam * lam - i1 / 3.0) + kappa * (j - 1.0);
let syy = mu / j.powf(5.0 / 3.0) * (l * l - i1 / 3.0) + kappa * (j - 1.0);
assert!((tau[0] / j - sxx).abs() < 1e-10 * mu && (tau[1] / j - syy).abs() < 1e-10 * mu, "{tau:?}");
let e = logarithmic_strain(&f).unwrap();
assert!((e[0] - lam.ln()).abs() < 1e-14 && (e[1] - l.ln()).abs() < 1e-14 && e[3].abs() < 1e-14, "{e:?}");
assert!(m.response(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]], &[], &mut []).is_err(), "inverted");
assert!(MaterialModel::StVenantKirchhoff(Box::new(isotropic(1.0, 0.3).unwrap())).isochoric(&IDENTITY).is_err());
assert!(m.response(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]], None, &[], &mut []).is_err(), "inverted");
assert!(MaterialModel::StVenantKirchhoff(Box::new(isotropic(1.0, 0.3).unwrap())).isochoric(&IDENTITY, None, &[], &mut []).is_err());
}
}
+407
View File
@@ -0,0 +1,407 @@
//! Von Mises (J2) elastoplasticity at a material point of implicit (Newton) analysis: the
//! stress after an increment and its consistent tangent, at finite strain and at small strain.
//! Both kinematics call the radial return of [`crate::plasticity`], the update explicit
//! dynamics uses, so the yield surface, the hardening (bilinear isotropic or linear kinematic,
//! tabulated, Johnson–Cook) and the plastic strain increment of a trial state are one
//! implementation for both solvers.
//!
//! **Finite strain** (J. C. Simo, *Algorithms for static and dynamic multiplicative plasticity
//! that preserve the classical return mapping schemes of the infinitesimal theory*, CMAME
//! 99:61–112, 1992; Simo & Hughes 1998, ch. 9): the multiplicative split F = Fₑ Fₚ with the
//! elastic left Cauchy–Green tensor bₑ = F Cₚ⁻¹ Fᵀ and a Hencky (quadratic in the logarithmic
//! strain) elastic energy, so the Kirchhoff stress is τ = K ln J I + 2G dev εₑ with
//! εₑ = ½ ln bₑ. The history is Cₚ⁻¹ (stored as Cₚ⁻¹ − I, so the virgin state is zeros) and the
//! equivalent plastic strain. An increment freezes Cₚ⁻¹ at its committed value: the trial
//! bₑᵗʳ = F Cₚ⁻¹ Fᵀ, its principal stretches and directions, the trial logarithmic strains
//! ε_A = ½ ln λ_A², and then the **return map of the infinitesimal theory on the principal
//! values** (the exponential map integrates the flow rule exactly in the principal frame, which
//! the isotropy of the model keeps fixed during the return). The update writes back
//! bₑ = Σ exp(2 εₑ,A) n_A ⊗ n_A and Cₚ⁻¹ = F⁻¹ bₑ F⁻ᵀ, which keeps det Cₚ = 1 (plastic flow
//! is isochoric).
//!
//! **Its tangent.** With Cₚ⁻¹ frozen, the Lie derivative of bₑᵗʳ vanishes, so for a rate of
//! deformation d the trial strain changes by dεᵗʳ = ½ (∂ ln b/∂b) : (d b + b d), and
//! L_v τ = 𝔻ᵉᵖ : dεᵗʳ − (d τ + τ d), with 𝔻ᵉᵖ the algorithmic tangent of the return map
//! ([`crate::plasticity::J2::tangent`]). In the principal frame of b the derivative of the
//! logarithm is the Daleckii–Krein divided difference, so dεᵗʳ_AB = θ_AB d_AB with θ_AA = 1 and
//! θ_AB = ½ (λ²_A + λ²_B) ln(λ²_A/λ²_B)/(λ²_A − λ²_B) = artanh(r)/r, r = (λ²_A − λ²_B)/(λ²_A + λ²_B),
//! smooth through equal stretches (its series 1 + r²/3 + r⁴/5 + … near r = 0), so uniaxial and
//! undeformed states need no special case (Simo 1992, §3.3, gives the same tangent in spectral
//! form; R. Bhatia, *Matrix Analysis*, Springer 1997, §V.3, for the divided differences). The
//! tangent is symmetric for isotropic hardening: 𝔻ᵉᵖ and the θ scaling share the principal
//! frame.
//!
//! **Kinematic hardening at finite strain** is refused: a back stress is not coaxial with bₑ, so
//! the principal-frame return no longer holds, and it needs a formulation of its own (for
//! example the Lagrangian logarithmic strain space of C. Miehe, N. Apel, M. Lambrecht, CMAME
//! 191:5383–5425, 2002). It works with small strain.
//!
//! **Small strain** (Simo & Hughes 1998, §3.3): ε = εₑ + εₚ additively, the history the plastic
//! strain εₚ, the back stress α and the equivalent plastic strain; the trial εₑ = ε − εₚ,ₙ goes
//! through the same return, and εₚ advances by √(3/2) Δε̄ₚ n.
//!
//! **Mixed (hybrid) elements** take the isochoric part (the deviatoric return, without K) and the
//! volumetric energy U(J) = K/2 (ln J)², U′ = K ln J / J (finite strain), or U = K/2 (J − 1)² with
//! J the 1 + tr ε of small strain: J2 plasticity is pressure insensitive and isochoric, so the
//! split is exact.
//!
//! **History layout** per integration point ([`HISTORY`] values): 0–5 Cₚ⁻¹ − I (finite strain,
//! tensor components) or εₚ (small strain, engineering shears), 6–11 the back stress α, 12 the
//! equivalent plastic strain.
use crate::constitutive::{Response, Tangent};
use crate::dense::{inverse3, symmetric_eigen};
use crate::plasticity::{J2, PointState, Return};
use crate::rotation::{IDENTITY, Mat3, mul, tensor, transpose, voigt};
use crate::{Error, Result};
/// History values per integration point.
pub const HISTORY: usize = 13;
/// Where the equivalent plastic strain is in a point's history.
pub const PLASTIC_STRAIN: usize = 12;
/// History value `i` (0 when the slice is short).
pub(crate) fn value(h: &[f64], i: usize) -> f64 {
h.get(i).copied().unwrap_or(0.0)
}
/// Cₚ⁻¹ of a finite-strain history (stored as Cₚ⁻¹ − I).
fn plastic_stretch(h: &[f64]) -> Mat3 {
let stored = tensor(&std::array::from_fn(|k| value(h, k)));
std::array::from_fn(|i| std::array::from_fn(|j| IDENTITY[i][j] + stored[i][j]))
}
/// Store Cₚ⁻¹ as Cₚ⁻¹ − I in the first six history values.
fn store_plastic_stretch(cp: &Mat3, h: &mut [f64]) {
let v = voigt(cp);
for (k, slot) in h.iter_mut().take(6).enumerate() {
*slot = v[k] - if k < 3 { 1.0 } else { 0.0 };
}
}
/// artanh(r)/r, smooth through r = 0.
fn log_divided_difference(r: f64) -> f64 {
if r.abs() < 1e-4 {
let r2 = r * r;
1.0 + r2 * (1.0 / 3.0 + r2 * (1.0 / 5.0 + r2 / 7.0))
} else {
r.atanh() / r
}
}
/// The state a return starts from: the committed back stress and plastic strain, at the point's
/// temperature (Johnson–Cook softening; room temperature without one).
fn start(j2: &J2, committed: &[f64], temperature: Option<f64>) -> PointState {
let mut s = PointState::new(temperature.unwrap_or_else(|| j2.initial_temperature()));
s.back = std::array::from_fn(|k| value(committed, 6 + k));
s.plastic_strain = value(committed, PLASTIC_STRAIN);
s
}
fn singular() -> Error {
Error::Degenerate("the plastic material point has no tangent: its hardening falls faster than −3G (a softening material localises)".into())
}
/// The deviatoric trial stress 2G dev ε of a strain 6-vector (engineering shears), as tensor
/// components.
fn trial_deviator(j2: &J2, e: &[f64; 6]) -> [f64; 6] {
let mean = (e[0] + e[1] + e[2]) / 3.0;
let g2 = 2.0 * j2.shear;
[g2 * (e[0] - mean), g2 * (e[1] - mean), g2 * (e[2] - mean), j2.shear * e[3], j2.shear * e[4], j2.shear * e[5]]
}
/// Finite strain: the isochoric (deviatoric) Kirchhoff stress and its spatial tangent at
/// deformation gradient `f`, from the history `committed` (written, updated, to `updated`). Add
/// the volumetric part with [`volumetric`] (or the mixed element's pressure) for the whole
/// response.
pub fn finite_isochoric(j2: &J2, f: &Mat3, temperature: Option<f64>, committed: &[f64], updated: &mut [f64]) -> Result<Response> {
if let Some(crate::plasticity::Flow::Linear { kinematic: true, .. }) = j2.flow {
return Err(Error::NotSupported(
"kinematic hardening at finite strain (its back stress is not coaxial with the elastic stretch; use nonlinear_geometry false or isotropic hardening)"
.into(),
));
}
let b = mul(&mul(f, &plastic_stretch(committed)), &transpose(f));
let (l, q) = symmetric_eigen(&b)
.filter(|(l, _)| l.iter().all(|x| *x > 0.0))
.ok_or_else(|| Error::Degenerate("the elastic stretch of a plastic material point is not positive (the material is inverted)".into()))?;
let eps = l.map(|x| 0.5 * x.ln());
let mut state = start(j2, committed, temperature);
let mut trial = trial_deviator(j2, &[eps[0], eps[1], eps[2], 0.0, 0.0, 0.0]);
let ret: Option<Return> = j2.radial_return(&mut state, &mut trial, 0.0);
// Back to the global frame: τ = Σ τ_A q_A ⊗ q_A, n likewise.
let spectral = |v: [f64; 3]| -> Mat3 { std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|a| v[a] * q[i][a] * q[j][a]).sum())) };
let tau = voigt(&spectral([trial[0], trial[1], trial[2]]));
let global = ret.map(|r| Return { normal: voigt(&spectral([r.normal[0], r.normal[1], r.normal[2]])), ..r });
let d = j2.tangent(global.as_ref(), false).ok_or_else(singular)?;
// θ_AB of the logarithm's derivative.
let theta: Mat3 =
std::array::from_fn(|a| std::array::from_fn(|bb| if a == bb { 1.0 } else { log_divided_difference((l[a] - l[bb]) / (l[a] + l[bb])) }));
let tau_m = tensor(&tau);
let qt = transpose(&q);
let mut c: Tangent = [[0.0; 6]; 6];
for (col, &(i, j)) in crate::constitutive::PAIRS.iter().enumerate() {
// The unit rate of deformation of this column (engineering shear 1: d_ij = d_ji = ½).
let mut dm = [[0.0; 3]; 3];
let half = if i == j { 1.0 } else { 0.5 };
dm[i][j] = half;
dm[j][i] = half;
let mut dp = mul(&mul(&qt, &dm), &q);
for a in 0..3 {
for bb in 0..3 {
dp[a][bb] *= theta[a][bb];
}
}
let de = voigt(&mul(&mul(&q, &dp), &qt));
let de = [de[0], de[1], de[2], 2.0 * de[3], 2.0 * de[4], 2.0 * de[5]];
let spin = voigt(&{
let (x, y) = (mul(&dm, &tau_m), mul(&tau_m, &dm));
std::array::from_fn(|r| std::array::from_fn(|s| x[r][s] + y[r][s]))
});
for row in 0..6 {
c[row][col] = (0..6).map(|k| d[row][k] * de[k]).sum::<f64>() - spin[row];
}
}
if let Some(r) = ret {
// bₑ = Σ exp(2 εₑ,A) q_A ⊗ q_A with εₑ = ε − √(3/2) Δε̄ₚ n, and Cₚ⁻¹ = F⁻¹ bₑ F⁻ᵀ.
let dg = (1.5f64).sqrt() * r.plastic_strain;
let be = spectral(std::array::from_fn(|a| (2.0 * (eps[a] - dg * r.normal[a])).exp()));
let (inv, _) = inverse3(f).ok_or_else(|| Error::Degenerate("the deformation gradient is singular".into()))?;
store_plastic_stretch(&mul(&mul(&inv, &be), &transpose(&inv)), updated);
if let Some(slot) = updated.get_mut(PLASTIC_STRAIN) {
*slot = state.plastic_strain;
}
}
Ok(Response { stress: tau, tangent: c })
}
/// U′(J) and U″(J) of the Hencky volumetric energy K/2 (ln J)² (finite strain), or of
/// K/2 (J − 1)² for the dilatation J = 1 + tr ε of small strain.
pub fn volumetric(j2: &J2, j: f64, small: bool) -> Option<(f64, f64)> {
if !(j > 0.0 && j.is_finite()) {
return None;
}
let k = j2.bulk;
Some(if small { (k * (j - 1.0), k) } else { (k * j.ln() / j, k * (1.0 - j.ln()) / (j * j)) })
}
/// Small strain: the stress and the consistent tangent at total (mechanical) strain `strain`
/// (engineering shears), from the history `committed` (written, updated, to `updated`); with
/// `bulk` false only the deviatoric part (what a mixed element takes).
pub fn small(j2: &J2, strain: &[f64; 6], temperature: Option<f64>, committed: &[f64], updated: &mut [f64], bulk: bool) -> Result<Response> {
let elastic: [f64; 6] = std::array::from_fn(|k| strain[k] - value(committed, k));
let mut state = start(j2, committed, temperature);
let mut stress = trial_deviator(j2, &elastic);
let ret = j2.radial_return(&mut state, &mut stress, 0.0);
if bulk {
let p = j2.bulk * (elastic[0] + elastic[1] + elastic[2]);
for s in stress.iter_mut().take(3) {
*s += p;
}
}
let tangent = j2.tangent(ret.as_ref(), bulk).ok_or_else(singular)?;
if let Some(r) = ret {
let dg = (1.5f64).sqrt() * r.plastic_strain;
for (k, slot) in updated.iter_mut().take(6).enumerate() {
*slot = value(committed, k) + dg * r.normal[k] * if k < 3 { 1.0 } else { 2.0 };
}
for (k, slot) in updated.iter_mut().skip(6).take(6).enumerate() {
*slot = state.back[k];
}
if let Some(slot) = updated.get_mut(PLASTIC_STRAIN) {
*slot = state.plastic_strain;
}
}
Ok(Response { stress, tangent })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constitutive::{add_pressure, push_forward};
use fieldcraft_model::{Elastic, Hardening, HardeningPoint, Plastic};
const E: f64 = 200_000.0;
const NU: f64 = 0.3;
fn j2(plastic: Plastic) -> J2 {
J2::from_model(&Elastic::Isotropic { young: E, poisson: NU }, Some(&plastic), None).unwrap()
}
fn bilinear(h: f64, hardening: Hardening) -> J2 {
j2(Plastic::Bilinear { yield_stress: 250.0, hardening_modulus: h, hardening })
}
/// The whole finite-strain response, isochoric plus the Hencky volume.
fn finite(m: &J2, f: &Mat3, committed: &[f64], updated: &mut [f64]) -> Response {
let iso = finite_isochoric(m, f, None, committed, updated).unwrap();
let j = crate::dense::det3(f);
let (p, dp) = volumetric(m, j, false).unwrap();
add_pressure(&iso, j, p, j * dp)
}
/// The pulled-back tangent C (dS = C : dE) against central differences of S along several
/// directions of F, as `constitutive::tests::check_tangent` does, from fixed history.
fn check_finite_tangent(m: &J2, f: &Mat3, committed: &[f64]) -> f64 {
let mut scratch = vec![0.0; HISTORY];
let r = finite(m, f, committed, &mut scratch);
let (inv, _) = inverse3(f).unwrap();
let t = push_forward(&inv);
let back: Tangent =
std::array::from_fn(|a| std::array::from_fn(|b| (0..6).map(|k| (0..6).map(|l| t[a][k] * r.tangent[k][l] * t[b][l]).sum::<f64>()).sum()));
let scale = back.iter().flatten().fold(0.0f64, |a, v| a.max(v.abs()));
let h = 1e-7;
let mut worst: f64 = 0.0;
for k in 0..9 {
let mut df = [[0.0; 3]; 3];
df[k / 3][k % 3] = 1.0;
df[(k + 1) % 3][(k + 2) % 3] += 0.3;
let at = |s: f64| {
let fp: Mat3 = std::array::from_fn(|i| std::array::from_fn(|j| f[i][j] + s * df[i][j]));
let mut scratch = vec![0.0; HISTORY];
let tau = finite(m, &fp, committed, &mut scratch).stress;
crate::rotation::rotate(&inverse3(&fp).unwrap().0, &tau)
};
let (sp, sm) = (at(h), at(-h));
let ftdf = mul(&transpose(f), &df);
let de = [ftdf[0][0], ftdf[1][1], ftdf[2][2], ftdf[0][1] + ftdf[1][0], ftdf[0][2] + ftdf[2][0], ftdf[1][2] + ftdf[2][1]];
for a in 0..6 {
let numeric = (sp[a] - sm[a]) / (2.0 * h);
let analytic: f64 = (0..6).map(|b| back[a][b] * de[b]).sum();
worst = worst.max((numeric - analytic).abs() / scale);
}
}
worst
}
#[test]
fn finite_strain_tangent_is_consistent_elastic_and_plastic() {
let deformations: [Mat3; 3] = [
[[1.02, 0.015, -0.005], [-0.01, 0.99, 0.02], [0.007, -0.012, 1.005]],
// Large: well into plasticity.
[[1.3, 0.15, -0.05], [-0.1, 0.85, 0.2], [0.07, -0.12, 1.05]],
// Uniaxial (two equal principal stretches).
[[1.2, 0.0, 0.0], [0.0, 0.92, 0.0], [0.0, 0.0, 0.92]],
];
let tables = Plastic::Table { points: vec![HardeningPoint { strain: 0.0, stress: 250.0 }, HardeningPoint { strain: 0.5, stress: 600.0 }] };
let jc = Plastic::JohnsonCook {
a: 350.0,
b: 275.0,
n: 0.36,
c: 0.0,
m: 1.0,
reference_strain_rate: 1.0,
room_temperature: 293.0,
melt_temperature: 1356.0,
specific_heat: None,
taylor_quinney: 0.9,
};
// A plastic history to start from: a prior stretch committed.
let mut prior = vec![0.0; HISTORY];
finite(&bilinear(2000.0, Hardening::Isotropic), &[[1.1, 0.02, 0.0], [0.0, 0.95, 0.03], [0.0, 0.0, 0.96]], &[0.0; HISTORY], &mut prior);
assert!(prior[PLASTIC_STRAIN] > 0.0);
for m in [bilinear(2000.0, Hardening::Isotropic), bilinear(0.0, Hardening::Isotropic), j2(tables), j2(jc)] {
for f in &deformations {
for committed in [&[0.0; HISTORY][..], &prior[..]] {
let worst = check_finite_tangent(&m, f, committed);
assert!(worst < 1e-7, "{:?} at {f:?}: {worst}", m.flow);
}
}
}
}
#[test]
fn finite_strain_tangent_is_symmetric_and_elastic_at_rest() {
let m = bilinear(2000.0, Hardening::Isotropic);
let mut scratch = vec![0.0; HISTORY];
let r = finite(&m, &[[1.3, 0.15, -0.05], [-0.1, 0.85, 0.2], [0.07, -0.12, 1.05]], &[0.0; HISTORY], &mut scratch);
for a in 0..6 {
for b in 0..6 {
assert!((r.tangent[a][b] - r.tangent[b][a]).abs() < 1e-9 * E, "{a} {b}");
}
}
let rest = finite(&m, &IDENTITY, &[0.0; HISTORY], &mut scratch);
let d = crate::material::isotropic(E, NU).unwrap();
for a in 0..6 {
for b in 0..6 {
assert!((rest.tangent[a][b] - d[a][b]).abs() < 1e-9 * E);
}
}
assert!(rest.stress.iter().all(|v| *v == 0.0));
}
/// Uniaxial stress along x at finite strain, from a virgin point to a logarithmic strain ε
/// in one increment (the lateral stretch found so σ_yy = 0): the Kirchhoff stress is the
/// small-strain curve in the logarithmic strain, τ = σ_y + E_t (ε − σ_y/E), because the
/// exponential map integrates a proportional path exactly.
#[test]
fn finite_uniaxial_matches_the_logarithmic_closed_form() {
let (y0, h) = (250.0, 2000.0);
let m = bilinear(h, Hardening::Isotropic);
let strain: f64 = 0.2;
let lam = strain.exp();
let sigma = |l: f64| {
let f = [[lam, 0.0, 0.0], [0.0, l, 0.0], [0.0, 0.0, l]];
let mut u = vec![0.0; HISTORY];
let r = finite(&m, &f, &[0.0; HISTORY], &mut u);
let j = lam * l * l;
(r.stress[0] / j, r.stress[1] / j, u)
};
// Secant on the lateral stretch.
let (mut a, mut b) = (1.0, 0.9);
for _ in 0..60 {
let (fa, fb) = (sigma(a).1, sigma(b).1);
if fb.abs() < 1e-11 || fb == fa {
break;
}
(a, b) = (b, b - fb * (b - a) / (fb - fa));
}
let (sxx, _, u) = sigma(b);
let j = lam * b * b;
// In Kirchhoff stress τ = J σ: τ = y0 + E_t (ε − y0/E) along the log strain path.
let et = E * h / (E + h);
let exact = y0 + et * (strain - y0 / E);
assert!((sxx * j - exact).abs() < 1e-8 * exact, "{} vs {exact}", sxx * j);
assert!((u[PLASTIC_STRAIN] - (exact - y0) / h).abs() < 1e-10);
// The plastic deformation is isochoric: det Cₚ⁻¹ = 1.
let cp = plastic_stretch(&u);
assert!((crate::dense::det3(&cp) - 1.0).abs() < 1e-12);
}
#[test]
fn small_strain_tangent_is_consistent_and_cyclic_kinematic_shows_bauschinger() {
let m = bilinear(10_000.0, Hardening::Kinematic);
let strain = [3e-3, -1e-3, 5e-4, 2e-3, -1e-3, 7e-4];
let mut prior = vec![0.0; HISTORY];
small(&m, &[2e-3, -1e-3, -1e-3, 0.0, 0.0, 0.0], None, &[0.0; HISTORY], &mut prior, true).unwrap();
for committed in [&[0.0; HISTORY][..], &prior[..]] {
let mut scratch = vec![0.0; HISTORY];
let r = small(&m, &strain, None, committed, &mut scratch, true).unwrap();
let h = 1e-9;
for b in 0..6 {
let at = |s: f64| {
let mut e = strain;
e[b] += s;
small(&m, &e, None, committed, &mut [0.0; HISTORY], true).unwrap().stress
};
let (p, q) = (at(h), at(-h));
for a in 0..6 {
let numeric = (p[a] - q[a]) / (2.0 * h);
assert!((numeric - r.tangent[a][b]).abs() < 1e-5 * E, "{a} {b}: {numeric} vs {}", r.tangent[a][b]);
}
}
}
// Kinematic hardening is refused at finite strain, named.
assert!(matches!(finite_isochoric(&m, &IDENTITY, None, &[0.0; HISTORY], &mut [0.0; HISTORY]), Err(Error::NotSupported(_))));
}
#[test]
fn softening_steeper_than_3g_has_no_tangent() {
let g = E / (2.0 * (1.0 + NU));
let points = vec![HardeningPoint { strain: 0.0, stress: 250.0 }, HardeningPoint { strain: 1e-4, stress: 250.0 - 4.0 * g * 1e-4 }];
let m = j2(Plastic::Table { points });
let r = small(&m, &[5e-3, -2.5e-3, -2.5e-3, 0.0, 0.0, 0.0], None, &[0.0; HISTORY], &mut [0.0; HISTORY], true);
assert!(matches!(r, Err(Error::Degenerate(_))), "{r:?}");
}
}
+483 -83
View File
@@ -1,6 +1,6 @@
//! Finite-strain (total Lagrangian) solid and 2D elements: internal forces and the consistent
//! tangent stiffness for nodal displacements u, and the Cauchy stress and logarithmic strain at
//! the integration points.
//! Solid and 2D elements of nonlinear steps: total Lagrangian (finite strain) or with small
//! strain and an inelastic material; internal forces and the consistent tangent stiffness for
//! nodal displacements u, and the Cauchy stress and the strain at the integration points.
//!
//! **Formulation.** Total Lagrangian: the integrals run over the reference (undeformed) element
//! with its own quadrature, F = I + Σ u_a ⊗ ∇₀N_a at each point, and the material returns the
@@ -26,7 +26,26 @@
//! symmetric; at u = 0 it is the linear hybrid stiffness K_dev + κ G M⁻¹ Gᵀ. The constant
//! pressure case is the mean dilatation method of J. C. Nagtegaal, D. M. Parks, J. R. Rice
//! (CMAME 4:153–177, 1974) at finite strain. St Venant–Kirchhoff has no such split, so hybrid
//! elements of an elastic material are refused in nonlinear steps.
//! elements of an elastic material are refused in nonlinear steps; J2 plasticity has it exactly
//! (the return is deviatoric, the volume Hencky's K/2 (ln J)², [`crate::elastoplastic`]).
//!
//! **Thermal strain** ([`Temperature`]): at finite strain the multiplicative split F = F_m F_θ
//! with the thermal stretches F_θ = diag(1 + αᵢ (T − T_ref)) at each point (T interpolated from
//! the nodes; V. A. Lubarda, *Constitutive theories based on the multiplicative decomposition of
//! deformation gradient: thermoelasticity, elastoplasticity, and biomechanics*, Appl. Mech. Rev.
//! 57(2):95–108, 2004): the material sees
//! F_m, the stress per reference volume is J_θ τ_m(F_m), and since F_θ is fixed over an
//! iteration the spatial tangent is J_θ c_m, so the loops run unchanged on the thermally
//! expanded volume J_θ dV₀ (the mixed elements project det F_m on it). The linear stretch
//! 1 + α ΔT is the engineering definition of α, so a free body grows by exactly α ΔT. At small
//! strain the split is additive, ε_m = ε − α ΔT. Reported strains are the total ones.
//!
//! **Small strain** ([`Kinematics::Small`], a step without nonlinear geometry with an inelastic
//! material): the same loops on the linear kinematics, ε = sym ∇u with the reference gradients,
//! f = ∫ Bᵀ σ dV₀ and K = ∫ Bᵀ 𝔻 B dV₀ with the material's consistent tangent 𝔻, and no
//! geometric stiffness. Mixed elements project the dilatation 1 + tr ε and the pressure K tr ε
//! the same way, which is the linear hybrid element of [`crate::hybrid`] while the material is
//! elastic.
//!
//! **2D elements**: plane strain (F₃₃ = 1) and axisymmetric (F₃₃ = r/R, the hoop stretch, with
//! the hoop column N/r of the current radius in Bₓ and a geometric term τ_θθ N_a N_b / r² on the
@@ -37,7 +56,8 @@
//! (enhanced assumed strain, J. C. Simo, F. Armero, IJNME 33:1413–1449, 1992) needs the internal
//! modes as element unknowns carried between iterations, and it develops spurious hourglass
//! modes in large compression (P. Wriggers, S. Reese, CMAME 135:201–209, 1996), which want a
//! stabilised variant of their own.
//! stabilised variant of their own. With small strain they are refused for inelastic materials
//! too: their internal modes would carry the plastic history between iterations.
use crate::constitutive::{MaterialModel, Response, add_pressure, logarithmic_strain};
use crate::dense::{Cholesky, Matrix, inverse3};
@@ -54,12 +74,87 @@ pub struct Finite {
pub tangent: Option<Matrix>,
}
/// The kinematics of an evaluation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kinematics {
/// Total Lagrangian, large displacements, rotations and strains.
Finite,
/// Small displacements and strains (ε = sym ∇u, the reference gradients, no geometric
/// stiffness): only the material is nonlinear.
Small,
}
/// The temperatures of an element in a nonlinear step: the linear expansion coefficients along
/// X, Y, Z (zero for a material that does not expand), the reference temperature at which the
/// thermal strain is zero, and the temperature at each node.
#[derive(Debug, Clone, PartialEq)]
pub struct Temperature {
pub alpha: [f64; 3],
pub reference: f64,
pub nodal: Vec<f64>,
}
impl Temperature {
fn check(&self, nodes: usize) -> Result<()> {
if self.nodal.len() != nodes {
return Err(Error::Invalid(format!("{} temperatures for a {nodes}-node element", self.nodal.len())));
}
if self.alpha.iter().chain(&self.nodal).chain([&self.reference]).any(|v| !v.is_finite()) {
return Err(Error::Invalid("temperatures and expansion coefficients must be finite".into()));
}
Ok(())
}
}
/// The temperature at a point and its thermal stretches ϑᵢ = 1 + αᵢ (T − T_ref).
#[derive(Debug, Clone, Copy)]
struct Heat {
temperature: Option<f64>,
stretch: [f64; 3],
}
impl Heat {
/// At a point of shape function values `n`.
fn at(t: Option<&Temperature>, n: &[f64]) -> Result<Heat> {
let Some(t) = t else { return Ok(Heat { temperature: None, stretch: [1.0; 3] }) };
let temperature: f64 = t.nodal.iter().zip(n).map(|(a, b)| a * b).sum();
let stretch = t.alpha.map(|a| 1.0 + a * (temperature - t.reference));
if !stretch.iter().all(|v| *v > 0.0 && v.is_finite()) {
return Err(Error::Degenerate(format!("the thermal stretches {stretch:?} at T = {temperature} are not positive")));
}
Ok(Heat { temperature: Some(temperature), stretch })
}
/// J_θ = ϑ₁ ϑ₂ ϑ₃.
fn volume(&self) -> f64 {
self.stretch.iter().product()
}
/// The mechanical part F_m = F F_θ⁻¹ of a deformation gradient.
fn mechanical(&self, f: &Mat3) -> Mat3 {
std::array::from_fn(|i| std::array::from_fn(|j| f[i][j] / self.stretch[j]))
}
/// The mechanical part ε − α (T − T_ref) of a small strain.
fn mechanical_strain(&self, e: &[f64; 6]) -> [f64; 6] {
std::array::from_fn(|k| if k < 3 { e[k] - (self.stretch[k] - 1.0) } else { e[k] })
}
}
/// What one integration point gives the element.
struct PointKinematics {
/// The point's share of the reference volume (of the thermally expanded volume J_θ dV₀ at
/// finite strain).
weight: f64,
/// The deformation gradient, and its mechanical part F_m = F F_θ⁻¹ the material sees.
total: Mat3,
f: Mat3,
/// det F_m, or the dilatation 1 + tr ε_m of small strain (what a mixed element projects).
j: f64,
/// ∇ₓN of every node.
/// The mechanical small strain ε − ε_th (engineering shears); zero at finite strain.
strain: [f64; 6],
temperature: Option<f64>,
/// ∇ₓN of every node (∇₀N at small strain).
gradients: Vec<[f64; 3]>,
/// Reference position.
x0: [f64; 3],
@@ -70,8 +165,22 @@ fn point_history(history: &[f64], len: usize, point: usize) -> &[f64] {
history.get(point * len..(point + 1) * len).unwrap_or_default()
}
/// Add f_a += w Bₓ,aᵀ τ and, with `k`, the material and geometric tangent.
fn add_point(force: &mut [f64], k: Option<&mut Matrix>, gradients: &[[f64; 3]], r: &Response, w: f64) {
/// The small strain of a displacement gradient F − I (engineering shears).
fn small_strain(f: &Mat3) -> [f64; 6] {
[f[0][0] - 1.0, f[1][1] - 1.0, f[2][2] - 1.0, f[0][1] + f[1][0], f[0][2] + f[2][0], f[1][2] + f[2][1]]
}
/// The response of one point's material with its history slices.
fn point_response(m: &MaterialModel, kin: Kinematics, p: &PointKinematics, committed: &[f64], updated: &mut [f64]) -> Result<Response> {
match kin {
Kinematics::Finite => m.response(&p.f, p.temperature, committed, updated),
Kinematics::Small => m.small_strain(&p.strain, p.temperature, committed, updated, true),
}
}
/// Add f_a += w Bₓ,aᵀ τ and, with `k`, the material tangent and (with `geometric`) the
/// geometric one.
fn add_point(force: &mut [f64], k: Option<&mut Matrix>, gradients: &[[f64; 3]], r: &Response, w: f64, geometric: bool) {
for (a, g) in gradients.iter().enumerate() {
let b = b_block(g);
for c in 0..3 {
@@ -82,6 +191,9 @@ fn add_point(force: &mut [f64], k: Option<&mut Matrix>, gradients: &[[f64; 3]],
}
let Some(k) = k else { return };
accumulate_symmetric(&mut k.data, gradients, &r.tangent, w);
if !geometric {
return;
}
let tau = crate::rotation::tensor(&r.stress);
let n = k.n;
for (a, ga) in gradients.iter().enumerate() {
@@ -122,14 +234,17 @@ fn check_history(m: &MaterialModel, points: usize, committed: &[f64], updated: &
}
impl Solid<'_> {
/// Refuse what has no finite-strain form here.
fn check_finite(&self, m: &MaterialModel, u: &[[f64; 3]]) -> Result<()> {
/// Refuse what has no form here.
fn check_nonlinear(&self, m: &MaterialModel, u: &[[f64; 3]], kin: Kinematics) -> Result<()> {
check_displacements(u, self.ty.nodes)?;
match self.formulation {
Formulation::IncompatibleModes => Err(Error::NotSupported(format!(
"{} incompatible modes in nonlinear steps (their finite-strain form, enhanced assumed strain, is not implemented; use full integration, hybrid or hex20)",
self.ty.kind.name()
))),
Formulation::IncompatibleModes => {
let why = match kin {
Kinematics::Finite => "in nonlinear steps (their finite-strain form, enhanced assumed strain, is not implemented",
Kinematics::Small => "with an inelastic material (their internal modes would carry the plastic history",
};
Err(Error::NotSupported(format!("{} incompatible modes {why}; use full integration, hybrid or hex20)", self.ty.kind.name())))
}
Formulation::Hybrid { .. } if !m.is_decoupled() => Err(Error::NotSupported(format!(
"hybrid elements of a {} material in nonlinear steps (the mixed formulation needs a volumetric–isochoric split of the strain energy; give the material the neo-Hookean option)",
m.name()
@@ -138,59 +253,87 @@ impl Solid<'_> {
}
}
/// F, J and the current gradients at every stiffness point.
fn kinematics(&self, u: &[[f64; 3]]) -> Result<Vec<PointKinematics>> {
/// F, J and the gradients at every stiffness point: the current ones at finite strain, the
/// reference ones at small strain.
fn kinematics(&self, u: &[[f64; 3]], kin: Kinematics, thermal: Option<&Temperature>) -> Result<Vec<PointKinematics>> {
let n = self.ty.nodes;
if let Some(t) = thermal {
t.check(n)?;
}
let mut out = Vec::new();
for p in self.rule().points() {
let g = self.gradients(p.xi)?;
let reference = g.dx.get(..n).unwrap_or_default();
let f = crate::constitutive::deformation_gradient(u, reference);
let (inv, j) = inverse3(&f).ok_or_else(|| inverted(self.ty.kind.name(), 0.0))?;
if !(j > 0.0 && j.is_finite()) {
return Err(inverted(self.ty.kind.name(), j));
let total = crate::constitutive::deformation_gradient(u, reference);
let (weight, x0) = (g.det * p.weight, self.position_of(g.shape.values()));
let heat = Heat::at(thermal, g.shape.values())?;
if kin == Kinematics::Small {
let strain = heat.mechanical_strain(&small_strain(&total));
let j = 1.0 + strain[0] + strain[1] + strain[2];
let gradients = reference.to_vec();
out.push(PointKinematics { weight, total, f: total, j, strain, temperature: heat.temperature, gradients, x0 });
continue;
}
let (inv, det) = inverse3(&total).ok_or_else(|| inverted(self.ty.kind.name(), 0.0))?;
let j = det / heat.volume();
if !(det > 0.0 && j.is_finite()) {
return Err(inverted(self.ty.kind.name(), det));
}
let gradients = reference.iter().map(|d| std::array::from_fn(|i| (0..3).map(|jj| d[jj] * inv[jj][i]).sum())).collect();
out.push(PointKinematics { weight: g.det * p.weight, f, j, gradients, x0: self.position_of(g.shape.values()) });
let f = heat.mechanical(&total);
out.push(PointKinematics { weight: weight * heat.volume(), total, f, j, strain: [0.0; 6], temperature: heat.temperature, gradients, x0 });
}
Ok(out)
}
/// The effective response (Kirchhoff stress and tangent) at every point, the forces, and the
/// tangent when `tangent`.
fn finite_points(
/// The effective response (Kirchhoff stress and tangent, or the stress of small strain) at
/// every point, the forces, and the tangent when `tangent`.
#[allow(clippy::too_many_arguments)]
fn nonlinear_points(
&self,
m: &MaterialModel,
u: &[[f64; 3]],
kin: Kinematics,
thermal: Option<&Temperature>,
committed: &[f64],
updated: &mut [f64],
tangent: bool,
) -> Result<(Finite, Vec<(PointKinematics, Response)>)> {
self.check_finite(m, u)?;
let points = self.kinematics(u)?;
self.check_nonlinear(m, u, kin)?;
let points = self.kinematics(u, kin, thermal)?;
let len = check_history(m, points.len(), committed, updated)?;
let ndof = 3 * self.ty.nodes;
let mut force = vec![0.0; ndof];
let mut k = tangent.then(|| Matrix::zeros(ndof));
let responses: Vec<Response> = if let Formulation::Hybrid { .. } = self.formulation {
self.mixed_responses(m, &points, k.as_mut())?
self.mixed_responses(m, &points, kin, committed, updated, k.as_mut())?
} else {
let mut out = Vec::with_capacity(points.len());
for (i, p) in points.iter().enumerate() {
let updated_point = updated.get_mut(i * len..(i + 1) * len).unwrap_or_default();
out.push(m.response(&p.f, point_history(committed, len, i), updated_point)?);
out.push(point_response(m, kin, p, point_history(committed, len, i), updated_point)?);
}
out
};
for (p, r) in points.iter().zip(&responses) {
add_point(&mut force, k.as_mut(), &p.gradients, r, p.weight);
add_point(&mut force, k.as_mut(), &p.gradients, r, p.weight, kin == Kinematics::Finite);
}
Ok((Finite { force, tangent: k }, points.into_iter().zip(responses).collect()))
}
/// The three-field mixed responses τ_iso + J p I with their tangents, and the dilatation
/// stiffness Gᵀ M⁻¹ H M⁻¹ G added to `k`.
fn mixed_responses(&self, m: &MaterialModel, points: &[PointKinematics], k: Option<&mut Matrix>) -> Result<Vec<Response>> {
/// The three-field mixed responses τ_iso + J p I with their tangents (small strain:
/// s + p I), and the dilatation stiffness Gᵀ M⁻¹ H M⁻¹ G added to `k`.
fn mixed_responses(
&self,
m: &MaterialModel,
points: &[PointKinematics],
kin: Kinematics,
committed: &[f64],
updated: &mut [f64],
k: Option<&mut Matrix>,
) -> Result<Vec<Response>> {
let small = kin == Kinematics::Small;
let len = m.history_len();
let (np, frame) = (self.np(), self.frame());
let basis: Vec<[f64; 4]> = points.iter().map(|p| pressure_basis(np, p.x0, &frame)).collect();
let mut mm = Matrix::zeros(np);
@@ -212,7 +355,7 @@ impl Solid<'_> {
if !(th > 0.0 && th.is_finite()) {
return Err(inverted(self.ty.kind.name(), th));
}
let (dp, ddp) = m.volumetric(th).ok_or_else(|| Error::Invalid("volumetric response".into()))?;
let (dp, ddp) = m.volumetric(th, small).ok_or_else(|| Error::Invalid("volumetric response".into()))?;
for a in 0..np {
pressure[a] += p.weight * nb[a] * dp;
for b in 0..np {
@@ -222,20 +365,28 @@ impl Solid<'_> {
}
chol.solve(&mut pressure);
let mut out = Vec::with_capacity(points.len());
for (p, nb) in points.iter().zip(&basis) {
for (i, (p, nb)) in points.iter().zip(&basis).enumerate() {
let pr: f64 = (0..np).map(|a| nb[a] * pressure[a]).sum();
out.push(add_pressure(&m.isochoric(&p.f)?, p.j, pr, 0.0));
let (c, u) = (point_history(committed, len, i), updated.get_mut(i * len..(i + 1) * len).unwrap_or_default());
out.push(if small {
let mut r = m.small_strain(&p.strain, p.temperature, c, u, false)?;
r.stress.iter_mut().take(3).for_each(|s| *s += pr);
r
} else {
add_pressure(&m.isochoric(&p.f, p.temperature, c, u)?, p.j, pr, 0.0)
});
}
if let Some(k) = k {
// A = M⁻¹ G, np × ndof; K += Aᵀ H A.
// A = M⁻¹ G, np × ndof; K += Aᵀ H A. At small strain G = ∫ N_p ∇₀N dV₀.
let ndof = k.n;
let mut a_cols = vec![vec![0.0; np]; ndof];
for (p, nb) in points.iter().zip(&basis) {
let jw = p.weight * if small { 1.0 } else { p.j };
for (node, g) in p.gradients.iter().enumerate() {
for c in 0..3 {
if let Some(col) = a_cols.get_mut(3 * node + c) {
for (x, n) in col.iter_mut().zip(nb) {
*x += p.weight * n * p.j * g[c];
*x += jw * n * g[c];
}
}
}
@@ -262,32 +413,71 @@ impl Solid<'_> {
/// with the material history `committed` at the start of the increment (points × the
/// material's history length) written, updated, to `updated`.
pub fn finite_strain(&self, m: &MaterialModel, u: &[[f64; 3]], committed: &[f64], updated: &mut [f64], tangent: bool) -> Result<Finite> {
Ok(self.finite_points(m, u, committed, updated, tangent)?.0)
Ok(self.nonlinear_points(m, u, Kinematics::Finite, None, committed, updated, tangent)?.0)
}
/// The Cauchy stress σ = τ/J and the logarithmic strain at every integration point (at its
/// reference position).
pub fn finite_strain_stresses(&self, m: &MaterialModel, u: &[[f64; 3]], committed: &[f64]) -> Result<Vec<PointStress>> {
/// As [`Self::finite_strain`] with either kinematics and the element's temperatures when
/// it has some; with small strain an inelastic material on the linear kinematics,
/// f = ∫ Bᵀ σ dV and K = ∫ Bᵀ 𝔻 B dV with the material's consistent tangent 𝔻.
#[allow(clippy::too_many_arguments)]
pub fn nonlinear(
&self,
m: &MaterialModel,
u: &[[f64; 3]],
kin: Kinematics,
thermal: Option<&Temperature>,
committed: &[f64],
updated: &mut [f64],
tangent: bool,
) -> Result<Finite> {
Ok(self.nonlinear_points(m, u, kin, thermal, committed, updated, tangent)?.0)
}
/// The Cauchy stress and the total strain (logarithmic at finite strain, ε at small strain)
/// at every integration point (at its reference position).
pub fn nonlinear_stresses(
&self,
m: &MaterialModel,
u: &[[f64; 3]],
kin: Kinematics,
thermal: Option<&Temperature>,
committed: &[f64],
) -> Result<Vec<PointStress>> {
let mut scratch = committed.to_vec();
let (_, points) = self.finite_points(m, u, committed, &mut scratch, false)?;
let (_, points) = self.nonlinear_points(m, u, kin, thermal, committed, &mut scratch, false)?;
points
.iter()
.map(|(p, r)| {
let strain = logarithmic_strain(&p.f).ok_or_else(|| inverted(self.ty.kind.name(), p.j))?;
if kin == Kinematics::Small {
return Ok(PointStress { xyz: p.x0, strain: small_strain(&p.total), stress: r.stress });
}
let strain = logarithmic_strain(&p.total).ok_or_else(|| inverted(self.ty.kind.name(), p.j))?;
Ok(PointStress { xyz: p.x0, strain, stress: r.stress.map(|v| v / p.j) })
})
.collect()
}
}
/// One integration point of a 2D element in a nonlinear step: the total deformation gradient,
/// det F_m, the reference position and the response.
struct PlanePoint {
f: Mat3,
j: f64,
x: [f64; 3],
response: Response,
}
impl Plane<'_> {
fn check_finite(&self, u: &[[f64; 2]]) -> Result<()> {
fn check_nonlinear(&self, u: &[[f64; 2]], kin: Kinematics) -> Result<()> {
check_displacements(u, self.ty.nodes)?;
if let Theory::PlaneStress { .. } = self.theory {
return Err(Error::NotSupported(
"plane stress elements in nonlinear steps (the thickness change needs a local iteration; use plane strain, axisymmetric or solid elements)"
.into(),
));
let why = match kin {
Kinematics::Finite => "the thickness change needs a local iteration",
Kinematics::Small => "σ_zz = 0 needs a local iteration of the inelastic material",
};
return Err(Error::NotSupported(format!(
"plane stress elements in nonlinear steps ({why}; use plane strain, axisymmetric or solid elements)"
)));
}
if self.formulation == Formulation::IncompatibleModes {
return Err(Error::NotSupported(format!(
@@ -298,24 +488,31 @@ impl Plane<'_> {
Ok(())
}
/// Per point: (dv, F, J, current columns (∂N/∂x, ∂N/∂y, N/r), reference position, response).
#[allow(clippy::type_complexity)]
fn finite_points(
/// Per point: F, J, the reference position, the small strain and the response; the forces
/// and the tangent.
#[allow(clippy::too_many_arguments)]
fn nonlinear_points(
&self,
m: &MaterialModel,
u: &[[f64; 2]],
kin: Kinematics,
thermal: Option<&Temperature>,
committed: &[f64],
updated: &mut [f64],
tangent: bool,
) -> Result<(Finite, Vec<(Mat3, f64, [f64; 3], Response)>)> {
self.check_finite(u)?;
) -> Result<(Finite, Vec<PlanePoint>)> {
self.check_nonlinear(u, kin)?;
let n = self.ty.nodes;
if let Some(t) = thermal {
t.check(n)?;
}
let rule = self.rule().points();
let len = check_history(m, rule.len(), committed, updated)?;
let ndof = 2 * n;
let mut force = vec![0.0; ndof];
let mut k = tangent.then(|| Matrix::zeros(ndof));
let axisymmetric = self.theory == Theory::Axisymmetric;
let small = kin == Kinematics::Small;
let mut out = Vec::with_capacity(rule.len());
for (i, p) in rule.iter().enumerate() {
let at = self.at(p)?;
@@ -331,37 +528,47 @@ impl Plane<'_> {
f[2][2] += ua[0] * g[2];
}
}
let det2 = f[0][0] * f[1][1] - f[0][1] * f[1][0];
let j = det2 * f[2][2];
if !(j > 0.0 && j.is_finite() && f[2][2] > 0.0) {
return Err(inverted(&format!("2D {}", self.ty.kind.name()), j));
}
let inv = [[f[1][1] / det2, -f[0][1] / det2], [-f[1][0] / det2, f[0][0] / det2]];
let current: Vec<[f64; 3]> =
cols.iter().map(|g| [g[0] * inv[0][0] + g[1] * inv[1][0], g[0] * inv[0][1] + g[1] * inv[1][1], g[2] / f[2][2]]).collect();
let updated_point = updated.get_mut(i * len..(i + 1) * len).unwrap_or_default();
let r = m.response(&f, point_history(committed, len, i), updated_point)?;
let heat = Heat::at(thermal, at.shape.values())?;
let (j, dv, current, r) = if small {
let strain = heat.mechanical_strain(&small_strain(&f));
let r = m.small_strain(&strain, heat.temperature, point_history(committed, len, i), updated_point, true)?;
(1.0, at.dv, cols.to_vec(), r)
} else {
let det2 = f[0][0] * f[1][1] - f[0][1] * f[1][0];
let j = det2 * f[2][2];
if !(j > 0.0 && j.is_finite() && f[2][2] > 0.0) {
return Err(inverted(&format!("2D {}", self.ty.kind.name()), j));
}
let inv = [[f[1][1] / det2, -f[0][1] / det2], [-f[1][0] / det2, f[0][0] / det2]];
let current: Vec<[f64; 3]> =
cols.iter().map(|g| [g[0] * inv[0][0] + g[1] * inv[1][0], g[0] * inv[0][1] + g[1] * inv[1][1], g[2] / f[2][2]]).collect();
let r = m.response(&heat.mechanical(&f), heat.temperature, point_history(committed, len, i), updated_point)?;
(j / heat.volume(), at.dv * heat.volume(), current, r)
};
let tau = [r.stress[0], r.stress[1], r.stress[2], r.stress[3]];
for (a, g) in current.iter().enumerate() {
if let Some(x) = force.get_mut(2 * a) {
*x += at.dv * (g[0] * tau[0] + g[2] * tau[2] + g[1] * tau[3]);
*x += dv * (g[0] * tau[0] + g[2] * tau[2] + g[1] * tau[3]);
}
if let Some(x) = force.get_mut(2 * a + 1) {
*x += at.dv * (g[1] * tau[1] + g[0] * tau[3]);
*x += dv * (g[1] * tau[1] + g[0] * tau[3]);
}
}
if let Some(k) = k.as_mut() {
let c4: [[f64; 4]; 4] = std::array::from_fn(|a| std::array::from_fn(|b| r.tangent[a][b]));
crate::plane::accumulate(&mut k.data, &current, &c4, at.dv);
for (a, ga) in current.iter().enumerate() {
for (b, gb) in current.iter().enumerate() {
let s = ga[0] * gb[0] * tau[0] + ga[1] * gb[1] * tau[1] + (ga[0] * gb[1] + ga[1] * gb[0]) * tau[3];
k.add(2 * a, 2 * b, at.dv * (s + tau[2] * ga[2] * gb[2]));
k.add(2 * a + 1, 2 * b + 1, at.dv * s);
crate::plane::accumulate(&mut k.data, &current, &c4, dv);
if !small {
for (a, ga) in current.iter().enumerate() {
for (b, gb) in current.iter().enumerate() {
let s = ga[0] * gb[0] * tau[0] + ga[1] * gb[1] * tau[1] + (ga[0] * gb[1] + ga[1] * gb[0]) * tau[3];
k.add(2 * a, 2 * b, dv * (s + tau[2] * ga[2] * gb[2]));
k.add(2 * a + 1, 2 * b + 1, dv * s);
}
}
}
}
out.push((f, j, at.x, r));
out.push(PlanePoint { f, j, x: at.x, response: r });
}
Ok((Finite { force, tangent: k }, out))
}
@@ -369,19 +576,44 @@ impl Plane<'_> {
/// Internal forces (u, v per node) and (with `tangent`) the tangent stiffness at nodal
/// displacements `u`, as for [`Solid::finite_strain`].
pub fn finite_strain(&self, m: &MaterialModel, u: &[[f64; 2]], committed: &[f64], updated: &mut [f64], tangent: bool) -> Result<Finite> {
Ok(self.finite_points(m, u, committed, updated, tangent)?.0)
Ok(self.nonlinear_points(m, u, Kinematics::Finite, None, committed, updated, tangent)?.0)
}
/// The Cauchy stress and logarithmic strain at every integration point, as the solid
/// As [`Solid::nonlinear`], on the 2D element.
#[allow(clippy::too_many_arguments)]
pub fn nonlinear(
&self,
m: &MaterialModel,
u: &[[f64; 2]],
kin: Kinematics,
thermal: Option<&Temperature>,
committed: &[f64],
updated: &mut [f64],
tangent: bool,
) -> Result<Finite> {
Ok(self.nonlinear_points(m, u, kin, thermal, committed, updated, tangent)?.0)
}
/// The Cauchy stress and the total strain at every integration point, as the solid
/// elements' 6-vectors (axisymmetric: rr, axial, hoop, r-axial).
pub fn finite_strain_stresses(&self, m: &MaterialModel, u: &[[f64; 2]], committed: &[f64]) -> Result<Vec<PointStress>> {
pub fn nonlinear_stresses(
&self,
m: &MaterialModel,
u: &[[f64; 2]],
kin: Kinematics,
thermal: Option<&Temperature>,
committed: &[f64],
) -> Result<Vec<PointStress>> {
let mut scratch = committed.to_vec();
let (_, points) = self.finite_points(m, u, committed, &mut scratch, false)?;
let (_, points) = self.nonlinear_points(m, u, kin, thermal, committed, &mut scratch, false)?;
points
.iter()
.map(|(f, j, x, r)| {
let strain = logarithmic_strain(f).ok_or_else(|| inverted(&format!("2D {}", self.ty.kind.name()), *j))?;
Ok(PointStress { xyz: *x, strain, stress: r.stress.map(|v| v / j) })
.map(|p| {
if kin == Kinematics::Small {
return Ok(PointStress { xyz: p.x, strain: small_strain(&p.f), stress: p.response.stress });
}
let strain = logarithmic_strain(&p.f).ok_or_else(|| inverted(&format!("2D {}", self.ty.kind.name()), p.j))?;
Ok(PointStress { xyz: p.x, strain, stress: p.response.stress.map(|v| v / p.j) })
})
.collect()
}
@@ -420,20 +652,26 @@ mod tests {
/// Central differences of the internal forces against the tangent.
fn check_tangent(solid: &Solid, m: &MaterialModel, u: &[[f64; 3]]) {
let k = solid.finite_strain(m, u, &[], &mut [], true).unwrap().tangent.unwrap();
check_tangent_from(solid, m, u, Kinematics::Finite, &[], 1e-6);
}
/// The same from a committed history, with either kinematics.
fn check_tangent_from(solid: &Solid, m: &MaterialModel, u: &[[f64; 3]], kin: Kinematics, committed: &[f64], tol: f64) {
let mut scratch = committed.to_vec();
let k = solid.nonlinear_points(m, u, kin, None, committed, &mut scratch, true).unwrap().0.tangent.unwrap();
let scale = k.data.iter().fold(0.0f64, |a, v| a.max(v.abs()));
let n = 3 * u.len();
let h = 1e-6;
let h = if kin == Kinematics::Small { 1e-9 } else { 1e-6 };
for j in 0..n {
let shifted = |s: f64| {
let mut v = u.to_vec();
v[j / 3][j % 3] += s;
solid.finite_strain(m, &v, &[], &mut [], false).unwrap().force
solid.nonlinear_points(m, &v, kin, None, committed, &mut committed.to_vec(), false).unwrap().0.force
};
let (p, q) = (shifted(h), shifted(-h));
for i in 0..n {
let numeric = (p[i] - q[i]) / (2.0 * h);
assert!((numeric - k.get(i, j)).abs() < 1e-6 * scale, "{:?} {i} {j}: {numeric} vs {}", solid.ty.kind, k.get(i, j));
assert!((numeric - k.get(i, j)).abs() < tol * scale, "{:?} {kin:?} {i} {j}: {numeric} vs {}", solid.ty.kind, k.get(i, j));
}
}
for i in 0..n {
@@ -465,6 +703,10 @@ mod tests {
for (kind, x, np) in [(ElementKind::Hex8, &x8, 1), (ElementKind::Hex20, &x20, 4), (ElementKind::Tet10, &x10, 1)] {
let hybrid = Solid::new(kind, x, Formulation::Hybrid { pressures: np }).unwrap();
check_tangent(&hybrid, &neo, &displacement(x.len(), 0.08));
let mr = MaterialModel::Polynomial { name: "Mooney–Rivlin", c: [100.0, 50.0, 0.0, 0.0], bulk: 3000.0 };
let yeoh = MaterialModel::Polynomial { name: "Yeoh", c: [150.0, 0.0, -10.0, 2.0], bulk: 3000.0 };
check_tangent(&hybrid, &mr, &displacement(x.len(), 0.08));
check_tangent(&hybrid, &yeoh, &displacement(x.len(), 0.08));
assert!(hybrid.finite_strain(&svk, &displacement(x.len(), 0.0), &[], &mut [], false).is_err(), "SVK has no split");
}
let im = Solid::new(ElementKind::Hex8, &x8, Formulation::IncompatibleModes).unwrap();
@@ -493,6 +735,164 @@ mod tests {
}
}
/// J2 plasticity in full and hybrid elements, at finite and small strain: the tangent is
/// the derivative of the forces from a plastic history too, and the plastic points are
/// really plastic.
#[test]
fn plastic_tangents_are_the_derivative_of_the_forces() {
use crate::elastoplastic::{HISTORY, PLASTIC_STRAIN};
use fieldcraft_model::{Elastic, Hardening, Plastic};
let j2 = crate::plasticity::J2::from_model(
&Elastic::Isotropic { young: 1000.0, poisson: 0.3 },
Some(&Plastic::Bilinear { yield_stress: 5.0, hardening_modulus: 50.0, hardening: Hardening::Isotropic }),
None,
)
.unwrap();
let m = MaterialModel::Plastic(std::sync::Arc::new(j2));
let (x20, x10) = (hex20(), tet10());
let x8: Vec<[f64; 3]> = x20[..8].to_vec();
for (kind, x, f) in [
(ElementKind::Hex8, &x8, Formulation::Full),
(ElementKind::Tet10, &x10, Formulation::Full),
(ElementKind::Hex20, &x20, Formulation::Reduced),
(ElementKind::Hex8, &x8, Formulation::Hybrid { pressures: 1 }),
(ElementKind::Hex20, &x20, Formulation::Hybrid { pressures: 4 }),
] {
let solid = Solid::new(kind, x, f).unwrap();
let points = solid.rule().points().len();
for (kin, scale) in [(Kinematics::Finite, 0.05), (Kinematics::Small, 0.05)] {
// A first increment from the virgin state, committed, then a second from it.
let mut committed = vec![0.0; points * HISTORY];
let first = displacement(x.len(), 0.5 * scale);
solid.nonlinear_points(&m, &first, kin, None, &vec![0.0; points * HISTORY], &mut committed, false).unwrap();
assert!((0..points).all(|p| committed[p * HISTORY + PLASTIC_STRAIN] > 0.0), "{kind:?} {f:?} {kin:?} yields everywhere");
check_tangent_from(&solid, &m, &displacement(x.len(), scale), kin, &committed, 1e-6);
}
}
// Incompatible modes have no inelastic form.
let im = Solid::new(ElementKind::Hex8, &x8, Formulation::IncompatibleModes).unwrap();
assert!(matches!(
im.nonlinear(&m, &displacement(8, 0.0), Kinematics::Small, None, &[0.0; 8 * HISTORY], &mut [0.0; 8 * HISTORY], true),
Err(Error::NotSupported(_))
));
}
#[test]
fn plastic_plane_strain_and_axisymmetric_tangents_are_the_derivative_of_the_forces() {
use crate::elastoplastic::{HISTORY, PLASTIC_STRAIN};
use fieldcraft_model::{Elastic, Hardening, Plastic};
let j2 = crate::plasticity::J2::from_model(
&Elastic::Isotropic { young: 1000.0, poisson: 0.3 },
Some(&Plastic::Bilinear { yield_stress: 5.0, hardening_modulus: 50.0, hardening: Hardening::Isotropic }),
None,
)
.unwrap();
let m = MaterialModel::Plastic(std::sync::Arc::new(j2));
let quad8 = [
[1.0, 0.0, 0.0],
[2.0, 0.1, 0.0],
[2.2, 1.0, 0.0],
[0.9, 1.1, 0.0],
[1.5, 0.0, 0.0],
[2.1, 0.55, 0.0],
[1.5, 1.1, 0.0],
[0.95, 0.5, 0.0],
];
for theory in [Theory::PlaneStrain { thickness: 2.0 }, Theory::Axisymmetric] {
for (kind, x) in [(ElementKind::Quad8, &quad8[..]), (ElementKind::Tri3, &quad8[..3])] {
let plane = Plane::new(kind, x, Formulation::Full, theory).unwrap();
let points = plane.rule().points().len();
for kin in [Kinematics::Finite, Kinematics::Small] {
let u = |scale: f64| -> Vec<[f64; 2]> { displacement(x.len(), scale).iter().map(|v| [v[0], v[1]]).collect() };
let mut committed = vec![0.0; points * HISTORY];
plane.nonlinear_points(&m, &u(0.03), kin, None, &vec![0.0; points * HISTORY], &mut committed, false).unwrap();
assert!((0..points).all(|p| committed[p * HISTORY + PLASTIC_STRAIN] > 0.0), "{theory:?} {kind:?} {kin:?}");
let at = u(0.06);
let k = plane.nonlinear_points(&m, &at, kin, None, &committed, &mut committed.clone(), true).unwrap().0.tangent.unwrap();
let scale = k.data.iter().fold(0.0f64, |a, v| a.max(v.abs()));
let h = 1e-8;
for j in 0..2 * x.len() {
let shifted = |s: f64| {
let mut v = at.clone();
v[j / 2][j % 2] += s;
plane.nonlinear_points(&m, &v, kin, None, &committed, &mut committed.clone(), false).unwrap().0.force
};
let (p, q) = (shifted(h), shifted(-h));
for i in 0..2 * x.len() {
let numeric = (p[i] - q[i]) / (2.0 * h);
assert!(
(numeric - k.get(i, j)).abs() < 1e-6 * scale,
"{theory:?} {kind:?} {kin:?} {i} {j}: {numeric} vs {}",
k.get(i, j)
);
}
}
}
}
}
let stress = Plane::new(ElementKind::Quad4, &quad8[..4], Formulation::Full, Theory::PlaneStress { thickness: 1.0 }).unwrap();
assert!(matches!(
stress.nonlinear(&m, &[[0.0; 2]; 4], Kinematics::Small, None, &[0.0; 4 * HISTORY], &mut [0.0; 4 * HISTORY], true),
Err(Error::NotSupported(_))
));
}
/// A temperature field: the tangent is the derivative of the forces (finite and small strain,
/// full and hybrid, elastic and plastic), and a free expansion u = (F_θ − I) X of a uniform
/// temperature stresses nothing.
#[test]
fn thermal_tangents_and_free_expansion() {
use crate::elastoplastic::HISTORY;
use fieldcraft_model::{Elastic, Hardening, Plastic};
let j2 = crate::plasticity::J2::from_model(
&Elastic::Isotropic { young: 1000.0, poisson: 0.3 },
Some(&Plastic::Bilinear { yield_stress: 5.0, hardening_modulus: 50.0, hardening: Hardening::Isotropic }),
None,
)
.unwrap();
let plastic = MaterialModel::Plastic(std::sync::Arc::new(j2));
let neo = MaterialModel::NeoHookean { shear: 300.0, bulk: 3000.0 };
let x20 = hex20();
let field = |n: usize| Temperature {
alpha: [1e-3, 2e-3, 1.5e-3],
reference: 20.0,
nodal: (0..n).map(|i| 20.0 + 40.0 * (i as f64 * 0.37).sin()).collect(),
};
for f in [Formulation::Full, Formulation::Hybrid { pressures: 4 }] {
let solid = Solid::new(ElementKind::Hex20, &x20, f).unwrap();
let points = solid.rule().points().len();
let t = field(20);
for (m, kin, len) in [(&neo, Kinematics::Finite, 0), (&plastic, Kinematics::Finite, HISTORY), (&plastic, Kinematics::Small, HISTORY)] {
let u = displacement(20, 0.03);
let committed = vec![0.0; points * len];
let k = solid.nonlinear(m, &u, kin, Some(&t), &committed, &mut committed.clone(), true).unwrap().tangent.unwrap();
let scale = k.data.iter().fold(0.0f64, |a, v| a.max(v.abs()));
let h = if kin == Kinematics::Small { 1e-9 } else { 1e-7 };
for j in 0..60 {
let shifted = |s: f64| {
let mut v = u.clone();
v[j / 3][j % 3] += s;
solid.nonlinear(m, &v, kin, Some(&t), &committed, &mut committed.clone(), false).unwrap().force
};
let (p, q) = (shifted(h), shifted(-h));
for i in 0..60 {
let numeric = (p[i] - q[i]) / (2.0 * h);
assert!((numeric - k.get(i, j)).abs() < 1e-6 * scale, "{f:?} {} {kin:?} {i} {j}: {numeric} vs {}", m.name(), k.get(i, j));
}
}
}
// Free expansion by a uniform 150 degrees: no force, no stress.
let uniform = Temperature { alpha: [1e-3, 2e-3, 1.5e-3], reference: 20.0, nodal: vec![170.0; 20] };
let stretch = uniform.alpha.map(|a| 1.0 + 150.0 * a);
let u: Vec<[f64; 3]> = x20.iter().map(|x| std::array::from_fn(|k| (stretch[k] - 1.0) * x[k])).collect();
let r = solid.nonlinear(&neo, &u, Kinematics::Finite, Some(&uniform), &[], &mut [], false).unwrap();
assert!(r.force.iter().all(|v| v.abs() < 1e-10 * 3000.0), "{f:?}: {:?}", r.force);
let s = solid.nonlinear_stresses(&neo, &u, Kinematics::Finite, Some(&uniform), &[]).unwrap();
assert!(s.iter().all(|p| p.stress.iter().all(|v| v.abs() < 1e-10 * 3000.0)), "{f:?}");
assert!((s[0].strain[1] - stretch[1].ln()).abs() < 1e-12, "total logarithmic strain: {:?}", s[0].strain);
}
}
#[test]
fn rigid_rotation_strains_and_stresses_nothing() {
let x20 = hex20();
@@ -501,7 +901,7 @@ mod tests {
let neo = MaterialModel::NeoHookean { shear: 300.0, bulk: 3000.0 };
for f in [Formulation::Full, Formulation::Hybrid { pressures: 4 }] {
let solid = Solid::new(ElementKind::Hex20, &x20, f).unwrap();
let s = solid.finite_strain_stresses(&neo, &u, &[]).unwrap();
let s = solid.nonlinear_stresses(&neo, &u, Kinematics::Finite, None, &[]).unwrap();
assert!(s.iter().all(|p| p.stress.iter().chain(&p.strain).all(|v| v.abs() < 1e-12 * 3000.0)), "{f:?}: {:?}", s[0]);
let r = solid.finite_strain(&neo, &u, &[], &mut [], false).unwrap();
assert!(r.force.iter().all(|v| v.abs() < 1e-11 * 3000.0));
+8 -3
View File
@@ -14,12 +14,13 @@
//! the solid kernels evaluate;
//! - [`material`]: isotropic and orthotropic elasticity matrices, von Mises stress;
//! - [`constitutive`]: the material point of finite-strain analysis (Kirchhoff stress and
//! spatial tangent from the deformation gradient, with history): St Venant–Kirchhoff and
//! neo-Hookean;
//! spatial tangent from the deformation gradient, with history): St Venant–Kirchhoff,
//! neo-Hookean and J2 plasticity;
//! - [`element`]: the element table, stiffness (full, reduced, incompatible modes), consistent
//! and lumped mass, body forces, integration-point stresses and their extrapolation to nodes;
//! - [`finite`]: total Lagrangian solid and 2D elements (internal forces, the consistent tangent
//! with its geometric part, mixed u–p at finite strain, Cauchy stress and logarithmic strain);
//! with its geometric part, mixed u–p at finite strain, Cauchy stress and logarithmic strain),
//! and the same with small strain for inelastic materials;
//! - [`plane`]: 2D elements (plane stress, plane strain, axisymmetric) on tri3, tri6, quad4 and
//! quad8, with two displacement components per node;
//! - [`shell`]: flat MITC4 quadrilateral and MITC3 triangle shells with a drilling rotation, six
@@ -33,6 +34,9 @@
//! shape vectors and element frequency bounds for explicit dynamics;
//! - [`rotation`]: incrementally objective stress updates (Hughes–Winget Jaumann, Flanagan–Taylor
//! Green–Naghdi);
//! - [`elastoplastic`]: J2 plasticity at a material point of implicit analysis, at finite strain
//! (Simo's exponential map in the principal logarithmic strains) and at small strain, with the
//! consistent tangents, on the radial return of [`plasticity`];
//! - [`plasticity`]: J2 radial return with isotropic, kinematic and Johnson–Cook hardening,
//! Johnson–Cook damage;
//! - [`truss`]: two- and three-node trusses (straight or curved), their stiffness, mass, body
@@ -52,6 +56,7 @@ pub mod beam;
pub mod constitutive;
pub mod dense;
pub mod dof;
pub mod elastoplastic;
pub mod element;
pub mod face;
pub mod finite;
+92 -21
View File
@@ -30,6 +30,11 @@
//! [`MIN_FRACTURE_STRAIN`] counts as that (some published sets give negative values at high
//! triaxiality, where the point fails at once). The point has failed at D ≥ 1.
//!
//! **One return map for both solvers.** [`J2::radial_return`] is the return above; explicit
//! dynamics calls it through [`J2::update`] on its hypoelastic stress, the implicit material
//! point ([`crate::elastoplastic`]) on its trial elastic strain, small or logarithmic, and adds
//! the consistent tangent [`J2::tangent`].
//!
//! The strain increment the update takes is the **mechanical** one: a caller with initial or
//! thermal strains subtracts their increment first.
//!
@@ -98,6 +103,21 @@ impl PointState {
}
}
/// What a radial return did: what the consistent tangent needs.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Return {
/// The equivalent plastic strain increment Δε_p.
pub plastic_strain: f64,
/// The trial von Mises stress of ξ* = s* − α, q* = √(3/2)‖ξ*‖.
pub trial: f64,
/// The flow direction ξ*/‖ξ*‖ (tensor components).
pub normal: [f64; 6],
/// The kinematic hardening modulus H_k.
pub kinematic: f64,
/// The slope of the flow stress against the plastic strain at the end of the increment.
pub slope: f64,
}
/// What one update did.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Increment {
@@ -209,6 +229,11 @@ impl J2 {
Ok(J2 { shear, bulk, flow, damage, heating })
}
/// Whether the flow stress depends on the temperature (Johnson–Cook softening).
pub fn is_temperature_dependent(&self) -> bool {
matches!(self.flow, Some(Flow::JohnsonCook { .. }))
}
/// The temperature a point starts at.
pub fn initial_temperature(&self) -> f64 {
match self.flow {
@@ -232,27 +257,11 @@ impl J2 {
}
let pressure = mean + self.bulk * vol;
let mut out = Increment::default();
if let Some(flow) = &self.flow {
let xi: [f64; 6] = std::array::from_fn(|k| trial[k] - state.back[k]);
let norm = norm2(&xi).sqrt();
let q = (1.5f64).sqrt() * norm;
let (yield_now, _) = flow.stress(state.plastic_strain, 0.0, state.temperature, dt);
if q > yield_now && norm > 0.0 {
let hk = flow.kinematic();
let de = self.plastic_increment(flow, state, q, yield_now, hk, dt);
let n: [f64; 6] = xi.map(|x| x / norm);
let radial = g2 * (1.5f64).sqrt() * de;
let shift = (2.0f64 / 3.0).sqrt() * hk * de;
for k in 0..6 {
trial[k] -= radial * n[k];
state.back[k] += shift * n[k];
}
state.plastic_strain += de;
let q_new = q - (3.0 * self.shear + hk) * de;
out = Increment { plastic_strain: de, plastic_work: q_new * de };
if self.heating > 0.0 && density > 0.0 {
state.temperature += self.heating * out.plastic_work / density;
}
if let Some(r) = self.radial_return(state, &mut trial, dt) {
let q_new = r.trial - (3.0 * self.shear + r.kinematic) * r.plastic_strain;
out = Increment { plastic_strain: r.plastic_strain, plastic_work: q_new * r.plastic_strain };
if self.heating > 0.0 && density > 0.0 {
state.temperature += self.heating * out.plastic_work / density;
}
}
for k in 0..3 {
@@ -273,6 +282,68 @@ impl J2 {
out
}
/// The radial return of a trial deviator `trial` (tensor components) from `state`: when the
/// trial von Mises stress exceeds the yield stress, `trial` is moved back to the yield
/// surface, the back stress and the equivalent plastic strain of `state` are advanced, and
/// what the consistent tangent needs is returned; `None` when the point stays elastic.
pub fn radial_return(&self, state: &mut PointState, trial: &mut [f64; 6], dt: f64) -> Option<Return> {
let flow = self.flow.as_ref()?;
let xi: [f64; 6] = std::array::from_fn(|k| trial[k] - state.back[k]);
let norm = norm2(&xi).sqrt();
let q = (1.5f64).sqrt() * norm;
let (yield_now, _) = flow.stress(state.plastic_strain, 0.0, state.temperature, dt);
if !(q > yield_now && norm > 0.0) {
return None;
}
let hk = flow.kinematic();
let de = self.plastic_increment(flow, state, q, yield_now, hk, dt);
let n: [f64; 6] = xi.map(|x| x / norm);
let radial = 2.0 * self.shear * (1.5f64).sqrt() * de;
let shift = (2.0f64 / 3.0).sqrt() * hk * de;
for k in 0..6 {
trial[k] -= radial * n[k];
state.back[k] += shift * n[k];
}
let rate = if dt > 0.0 { de / dt } else { 0.0 };
let (_, slope) = flow.stress(state.plastic_strain + de, rate, state.temperature, dt);
state.plastic_strain += de;
Some(Return { plastic_strain: de, trial: q, normal: n, kinematic: hk, slope })
}
/// The consistent (algorithmic) tangent of the return map, dσ/dε of the stress after the
/// update against the total strain of the increment (engineering-shear columns): the
/// elasticity K I ⊗ I + 2G ℙ while elastic, and after a return
/// K I ⊗ I + 2G θ ℙ − 2G θ̄ n ⊗ n with θ = 1 − 3G Δε_p / q*, θ̄ = 1/(1 + H/3G) − (1 − θ) and
/// H the slope of the flow stress plus the kinematic modulus at the end of the increment
/// (Simo & Hughes 1998, Box 3.2, eq. 3.3.10, in the equivalent plastic strain). `bulk`
/// leaves out the volumetric part K I ⊗ I when false (the isochoric part a mixed element
/// takes). `None` when 3G + H is not positive: the point softens faster than it unloads
/// elastically and has no tangent.
pub fn tangent(&self, r: Option<&Return>, bulk: bool) -> Option<[[f64; 6]; 6]> {
let g = self.shear;
let (theta, theta_bar, n) = match r {
None => (1.0, 0.0, [0.0; 6]),
Some(r) => {
let stiff = 3.0 * g + r.slope + r.kinematic;
if !(stiff > 0.0 && stiff.is_finite() && r.trial > 0.0) {
return None;
}
let theta = 1.0 - 3.0 * g * r.plastic_strain / r.trial;
(theta, 3.0 * g / stiff - (1.0 - theta), r.normal)
}
};
let k = if bulk { self.bulk } else { 0.0 };
let identity = crate::constitutive::identity4();
let mut d = [[0.0; 6]; 6];
for a in 0..6 {
for b in 0..6 {
let (ia, ib) = (if a < 3 { 1.0 } else { 0.0 }, if b < 3 { 1.0 } else { 0.0 });
d[a][b] = k * ia * ib + 2.0 * g * theta * (identity[a][b] - ia * ib / 3.0) - 2.0 * g * theta_bar * n[a] * n[b];
}
}
Some(d)
}
/// The plastic strain increment of a trial state that yields.
fn plastic_increment(&self, flow: &Flow, state: &PointState, q: f64, yield_now: f64, hk: f64, dt: f64) -> f64 {
let g3 = 3.0 * self.shear;
+1
View File
@@ -18,6 +18,7 @@ const MESHERS: &[(&str, &str, &str)] = &[
("hex", "mesh.hex.generate", "all-hex mesh of a body"),
("hex_sweep", "mesh.hex.sweep", "swept all-hex mesh"),
("hex_mapped", "mesh.hex.mapped", "mapped all-hex mesh"),
("hex_blocks", "mesh.hex.blocks", "one conforming all-hex mesh of a design split into six-sided blocks {bodies, size, order}"),
("block", "mesh.block", "structured hex block"),
("planar", "mesh.planar", "2D elements of a polygon or a planar face"),
];
+117 -5
View File
@@ -6,7 +6,16 @@
//! The hex meshers reason on this topology the way B-rep sweepers do (Blacker, *The Cooper
//! tool*, Proc. 5th International Meshing Roundtable, 1996): which faces are sources, targets and
//! linking faces, and which edges bound them.
//!
//! **Virtual topology.** [`Brep::composite`] joins faces that meet smoothly along all of their
//! common boundary (the two halves of a cylinder the kernel splits at a seam, a plane running
//! tangentially into a fillet, coplanar pieces) into one composite face, so their seam is no edge
//! of the mesher's topology: the composite surfaces of virtual topology (A. Sheffer, M. Bercovier,
//! T. Blacker, J. Clements, *Virtual topology operators for meshing*, IJCGA 10(3):309–331, 2000).
//! A composite face keeps an analytic surface when every member lies on the first member's;
//! otherwise nodes go onto its triangles.
use std::borrow::Cow;
use std::collections::BTreeMap;
use super::geom::{Surface, add, dist, scale, unit};
@@ -59,6 +68,9 @@ impl Edge {
pub(crate) struct Brep<'a> {
pub surface: &'a BoundarySurface,
/// The face tag of each triangle: the surface's own tags, or for composite faces the smallest
/// tag of the composite.
tags: Cow<'a, [u32]>,
pub faces: Vec<Face>,
pub edges: Vec<Edge>,
/// Whether each surface vertex is a B-rep vertex.
@@ -72,9 +84,29 @@ pub(crate) struct Brep<'a> {
/// Relative geometric tolerance (of the bounding-box diagonal).
pub(crate) const RELATIVE_EPS: f64 = 1e-7;
/// Largest angle (radians) between the surface normals of two faces along their common boundary
/// for them to join into a composite face: 1°.
const SMOOTH_ANGLE: f64 = 0.017_453_292_519_943_295;
/// Distance from a member's vertices to the first member's analytic surface (relative to the
/// bounding-box diagonal) within which a composite face keeps that surface.
const RELATIVE_ON_SURFACE: f64 = 1e-6;
impl<'a> Brep<'a> {
/// Recover the topology. `surfaces[tag]` is the analytic surface of B-rep face `tag`, if any.
pub(crate) fn new(surface: &'a BoundarySurface, surfaces: &[Option<Surface>]) -> Result<Self> {
Self::build(surface, surfaces, Cow::Borrowed(surface.tri_face()), false)
}
/// [`Brep::new`] with smoothly meeting faces joined into composite faces (virtual topology).
pub(crate) fn composite(surface: &'a BoundarySurface, surfaces: &[Option<Surface>]) -> Result<Self> {
let tags = composite_tags(surface, surfaces);
Self::build(surface, surfaces, Cow::Owned(tags), true)
}
fn build(surface: &'a BoundarySurface, surfaces: &[Option<Surface>], tags: Cow<'a, [u32]>, composite: bool) -> Result<Self> {
if tags.len() != surface.triangles().len() {
return Err(Error::Failed("a face tag per triangle is needed".into()));
}
let pts = surface.points();
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
@@ -91,7 +123,7 @@ impl<'a> Brep<'a> {
let eps = RELATIVE_EPS * diagonal;
// Faces in tag order.
let mut by_tag: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
for (t, &f) in surface.tri_face().iter().enumerate() {
for (t, &f) in tags.iter().enumerate() {
by_tag.entry(f).or_default().push(t as u32);
}
let index: BTreeMap<u32, usize> = by_tag.keys().enumerate().map(|(i, &f)| (f, i)).collect();
@@ -109,12 +141,13 @@ impl<'a> Brep<'a> {
}
verts.sort_unstable();
verts.dedup();
let analytic = surfaces.get(id as usize).copied().flatten().and_then(|s| s.checked());
let analytic =
if composite { composite_surface(surface, surfaces, tris, RELATIVE_ON_SURFACE * diagonal) } else { own_surface(surfaces, id) };
let normal = unit(nsum).unwrap_or([0.0; 3]);
let plane = planarity(pts, &verts, normal, analytic, eps);
faces.push(Face { id, triangles: tris.clone(), vertices: verts, area, surface: analytic, plane, loops: Vec::new() });
}
let face_of = |t: u32| surface.tri_face().get(t as usize).and_then(|f| index.get(f)).copied();
let face_of = |t: u32| tags.get(t as usize).and_then(|f| index.get(f)).copied();
// Oriented boundary segments: `from → to` with face `left` traversing it.
struct Seg {
from: u32,
@@ -128,6 +161,10 @@ impl<'a> Brep<'a> {
let (Some(left), Some(right)) = (face_of(fe.triangles[0]), face_of(fe.triangles[1])) else {
return Err(Error::Failed("feature edge without faces".into()));
};
// The seam inside a composite face is no edge.
if left == right {
continue;
}
// Orient each segment as the lower-indexed face traverses it, so that the segments of
// one edge chain head to tail.
let [lo, hi] = fe.vertices;
@@ -236,7 +273,7 @@ impl<'a> Brep<'a> {
face.loops = loops;
}
}
Ok(Brep { surface, faces, edges, is_vertex, edge_triangles, diagonal })
Ok(Brep { surface, tags, faces, edges, is_vertex, edge_triangles, diagonal })
}
pub(crate) fn point(&self, v: u32) -> P {
@@ -276,9 +313,14 @@ impl<'a> Brep<'a> {
/// The faces that use the triangle's tag (index into `faces`).
pub(crate) fn face_of_triangle(&self, t: u32) -> Option<usize> {
let tag = *self.surface.tri_face().get(t as usize)?;
let tag = *self.tags.get(t as usize)?;
self.faces.binary_search_by_key(&tag, |f| f.id).ok()
}
/// The face tag of triangle `t` in this topology (a composite face's tag for its members).
pub(crate) fn tag(&self, t: u32) -> Option<u32> {
self.tags.get(t as usize).copied()
}
}
/// A point and outward normal of a planar face: the analytic plane, or the plane fitted through
@@ -300,3 +342,73 @@ fn planarity(pts: &[P], verts: &[u32], normal: P, analytic: Option<Surface>, eps
// tolerance, not to ours.
(flat || matches!(analytic, Some(Surface::Plane { .. }))).then_some((point, n))
}
/// The analytic surface of face `tag` from the geometry, checked.
fn own_surface(surfaces: &[Option<Surface>], tag: u32) -> Option<Surface> {
surfaces.get(tag as usize).copied().flatten().and_then(|s| s.checked())
}
/// The analytic surface of a composite face (its triangles `tris`): the first member's, when the
/// vertices of every member lie on it within `tol`.
fn composite_surface(surface: &BoundarySurface, surfaces: &[Option<Surface>], tris: &[u32], tol: f64) -> Option<Surface> {
let tri_face = surface.tri_face();
let first = tris.iter().filter_map(|&t| tri_face.get(t as usize)).min()?;
let s = own_surface(surfaces, *first)?;
let pts = surface.points();
let on = |v: u32| pts.get(v as usize).is_some_and(|&p| s.project(p).is_some_and(|q| dist(p, q) <= tol));
tris.iter().all(|&t| surface.triangles().get(t as usize).is_some_and(|tri| tri.iter().all(|&v| on(v)))).then_some(s)
}
/// Face tags with smoothly meeting faces joined: two faces join when, at every boundary segment
/// between them, their analytic surfaces' normals are within [`SMOOTH_ANGLE`] of parallel and the
/// two triangles there face the same side. Each composite face takes its smallest member tag.
fn composite_tags(surface: &BoundarySurface, surfaces: &[Option<Surface>]) -> Vec<u32> {
let tri_face = surface.tri_face();
let pts = surface.points();
let point = |v: u32| pts.get(v as usize).copied().unwrap_or([0.0; 3]);
let tri_normal = |t: u32| {
surface.triangles().get(t as usize).and_then(|tri| {
let [a, b, c] = tri.map(point);
unit(cross(sub(b, a), sub(c, a)))
})
};
let cos_min = SMOOTH_ANGLE.cos();
// 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) {
let (Some(&a), Some(&b)) = (tri_face.get(fe.triangles[0] as usize), tri_face.get(fe.triangles[1] as usize)) else { continue };
if a == b {
continue;
}
let m = scale(add(point(fe.vertices[0]), point(fe.vertices[1])), 0.5);
let normals = (own_surface(surfaces, a).and_then(|s| s.normal(m)), own_surface(surfaces, b).and_then(|s| s.normal(m)));
let same_side = match (tri_normal(fe.triangles[0]), tri_normal(fe.triangles[1])) {
(Some(x), Some(y)) => dot(x, y) > 0.0,
_ => false,
};
let smooth = same_side && matches!(normals, (Some(x), Some(y)) if dot(x, y).abs() >= cos_min);
let entry = pairs.entry((a.min(b), a.max(b))).or_insert(true);
*entry = *entry && smooth;
}
// Union–find over tags, the root always the smaller tag.
let mut parent: BTreeMap<u32, u32> = BTreeMap::new();
fn root(parent: &BTreeMap<u32, u32>, mut x: u32) -> u32 {
// Each step goes to a smaller tag, so this ends.
while let Some(&p) = parent.get(&x) {
if p == x {
break;
}
x = p;
}
x
}
for (&(a, b), &smooth) in &pairs {
if smooth {
let (ra, rb) = (root(&parent, a), root(&parent, b));
if ra != rb {
parent.insert(ra.max(rb), ra.min(rb));
}
}
}
tri_face.iter().map(|&f| root(&parent, f)).collect()
}
+796
View File
@@ -0,0 +1,796 @@
//! Conforming structured hex meshes of a part partitioned into cells (multi-block meshing).
//!
//! An analyst meshes a part no single sweep fits by splitting it into cells that each are a
//! logical cube (six four-sided faces, possibly composite), meshing every cell as a mapped block
//! and joining the blocks so that they conform: the multi-block grids of J. F. Thompson, B. K.
//! Soni, N. P. Weatherill (eds.), *Handbook of Grid Generation*, CRC Press 1999, ch. 3 and 13.
//! [`hex_mesh_cells`] does it in five steps:
//!
//! 1. **Topology.** Each cell's B-rep is read with smoothly meeting faces joined
//! ([`super::brep::Brep::composite`]: the halves of a hole, a plane running into a fillet) and
//! recognized as a logical cube ([`super::mapped::recognize`]). Corners of different cells at
//! the same point (within the merge tolerance, the one geometric decision) are one vertex;
//! cell edges between the same two vertices along the same curve are one edge; cell faces
//! bounded by the same four edges are one face, shared by at most two cells (an interface).
//! Everything after this works on these identities, never on coordinates.
//! 2. **Interval assignment** over the edges of all cells together ([`super::interval`]): the
//! four edges along each direction of every cell are equal, so a count is the same on both
//! sides of every interface. Edges that run away from a curved face (radial lines off a hole,
//! the diagonal off a fillet) are then graded: the spacing at that end matches the spacing
//! along the curved face there and grows by at most [`GROWTH`] per element towards the target
//! size; their counts are reassigned with the grading's goals.
//! 3. **Edges** are divided once, on the geometry, and their nodes belong to the edge.
//! 4. **Faces** get one transfinite grid each (Gordon and Hall), from their four edges and
//! projected onto the face; an interface's grid is made once and both cells use its nodes.
//! 5. **Cells** fill their interior by trivariate transfinite interpolation (Gordon and Thiel)
//! of their six face grids, blended by the edges' arc-length distribution.
//!
//! Nodes are merged by identity (a vertex, an edge's k-th node, a face grid's node), so the
//! cells conform exactly: no node is duplicated on an interface and none hangs. The result goes
//! through the same finishing as a single body ([`super::finish`]): the certified validity gate,
//! the tiling and fidelity checks, a closed 2-manifold boundary, and no boundary face lying on
//! another cell (a free internal face, where cells touch without sharing a face).
use std::collections::BTreeMap;
use super::brep::Brep;
use super::geom::{TriangleTree, add, dist, scale};
use super::mapped::{self, Cube, face_ring, other};
use super::{Cells, Ctx, HexMesh, HexMethod, HexOptions, RELATIVE_TOLERANCE, Raw, Surface, finish, project_to_face, project_to_faces};
use crate::quad::{lattice_index, transfinite_grid, transfinite_volume};
use crate::size::{SizeField, divide_polyline, ideal_divisions};
use crate::surface::BoundarySurface;
use crate::vec3::{P, cross, dot, norm, sub};
use crate::{Error, Result};
/// One cell: its name (for messages), its boundary surface (face-tagged) and the analytic
/// surfaces of its faces by tag.
#[derive(Clone, Copy)]
pub struct Cell<'a> {
pub name: &'a str,
pub surface: &'a BoundarySurface,
pub faces: &'a [Option<Surface>],
}
/// A conforming mesh of several cells.
#[derive(Debug, Clone, PartialEq)]
pub struct CellsMesh {
/// The mesh; boundary faces are tagged with the B-rep faces of their element's cell.
pub mesh: HexMesh,
/// The cell of each element (index into the cells given).
pub element_cell: Vec<u32>,
/// Each cell's block: divisions along its three logical directions.
pub blocks: Vec<[usize; 3]>,
/// Faces shared by two cells.
pub interfaces: usize,
}
/// The most cells meshed together.
pub const MAX_CELLS: usize = 4096;
/// Spacing growth from one element to the next along a graded edge (1.2: each at most 20 %
/// longer than the one before).
pub const GROWTH: f64 = 1.2;
/// Segments a graded edge's polyline is cut into for the integration of its grading (the size
/// helpers take the size constant along a sample).
const GRADED_SEGMENTS: usize = 64;
/// An edge shared by cells: its end vertices (`a < b`), the cell that divides it and how.
struct Edge {
a: usize,
b: usize,
/// The owning cell, its edge and whether that edge runs from `a` to `b`.
owner: (usize, usize, bool),
length: f64,
}
/// A face shared by one or two cells.
struct Face {
/// The owner's ring of vertices (the order of [`face_ring`]).
ring: [usize; 4],
/// The cells, with the direction and side of the face in each; the first owns the grid.
uses: Vec<(usize, usize, usize)>,
}
/// Mesh `cells` (bodies that together make the part, meeting face to face) into one conforming
/// all-hex mesh, every cell a mapped block. `opts.method` must be [`HexMethod::Auto`] or
/// [`HexMethod::Mapped`]; a cell that is not a logical cube, or cells that meet without sharing
/// whole faces, are [`Error::Unsupported`] with the reason.
pub fn hex_mesh_cells(cells: &[Cell], field: &dyn SizeField, opts: &HexOptions) -> Result<CellsMesh> {
if !matches!(opts.method, HexMethod::Auto | HexMethod::Mapped) {
return Err(Error::Invalid(format!("conforming cells are meshed as mapped blocks; method `{}` does not apply", opts.method.name())));
}
if !(opts.chordal_tolerance >= 0.0 && opts.chordal_tolerance.is_finite()) {
return Err(Error::Invalid(format!("the chordal tolerance must be a non-negative number, got {}", opts.chordal_tolerance)));
}
if cells.is_empty() || cells.len() > MAX_CELLS {
return Err(Error::Invalid(format!("{} cells; 1 to {MAX_CELLS} are meshed together", cells.len())));
}
if let Some(c) = cells.iter().find(|c| c.surface.shells().len() != 1) {
return Err(Error::Unsupported(format!("cell `{}` has {} shells; a cell is one solid without voids", c.name, c.surface.shells().len())));
}
let surfaces: Vec<&BoundarySurface> = cells.iter().map(|c| c.surface).collect();
super::check_estimate(&surfaces, field, opts)?;
let breps: Vec<Brep> = cells.iter().map(|c| Brep::composite(c.surface, c.faces)).collect::<Result<_>>()?;
let trees: Vec<TriangleTree> = cells.iter().map(|c| TriangleTree::new(c.surface)).collect::<Result<_>>()?;
let ctxs: Vec<Ctx> =
breps.iter().zip(&trees).map(|(brep, tree)| Ctx { brep, tree, field, opts, tol: RELATIVE_TOLERANCE * brep.diagonal }).collect();
let names: Vec<&str> = cells.iter().map(|c| c.name).collect();
let cubes: Vec<Cube> = breps
.iter()
.zip(&names)
.map(|(b, name)| mapped::recognize(b).map_err(|e| Error::Unsupported(format!("cell `{name}`: {}", super::plain(&e)))))
.collect::<Result<_>>()?;
let topo = Topology::new(&breps, &cubes, &names, opts.chordal_tolerance)?;
let divisions = topo.intervals(&ctxs, &cubes, field)?;
let mut builder =
Builder { topo: &topo, ctxs: &ctxs, cubes: &cubes, divisions: &divisions, field, opts, nodes: Vec::new(), edge_nodes: Vec::new() };
let (raw, element_cell, blocks) = builder.build()?;
let shared = topo.interfaces();
let mesh = finish(&Cells { ctxs: &ctxs, of: &element_cell, names: &names }, raw, HexMethod::Mapped)?;
Ok(CellsMesh { mesh, element_cell, blocks, interfaces: shared })
}
/// Vertices, edges and faces shared by the cells.
struct Topology {
/// Shared vertices: where they are.
vertices: Vec<P>,
edges: Vec<Edge>,
faces: Vec<Face>,
/// Per cell: the vertex at each corner, `corner[i][j][k]`.
corners: Vec<[[[usize; 2]; 2]; 2]>,
/// Per cell, per direction: the four edges (in the order of [`Cube::groups`]).
cell_edges: Vec<[[usize; 4]; 3]>,
/// Per cell: the face on each side of each direction.
cell_faces: Vec<[[usize; 2]; 3]>,
}
/// Points at most this far apart (relative to the diagonal of all cells) are one vertex.
const RELATIVE_MERGE: f64 = 1e-6;
impl Topology {
fn new(breps: &[Brep], cubes: &[Cube], names: &[&str], chordal: f64) -> Result<Topology> {
let name = |c: usize| names.get(c).copied().unwrap_or("?");
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for b in breps {
for p in b.surface.points() {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
}
let merge = RELATIVE_MERGE * dist(lo, hi);
if !(merge > 0.0 && merge.is_finite()) {
return Err(Error::Invalid("the cells have no extent".into()));
}
// Vertices: corners within `merge` of each other, found through a grid of that spacing.
let mut vertices: Vec<P> = Vec::new();
let mut grid: BTreeMap<[i64; 3], Vec<usize>> = BTreeMap::new();
let cellkey = |p: P| p.map(|x| (x / merge).floor() as i64);
let mut corners = Vec::with_capacity(cubes.len());
for (c, (brep, cube)) in breps.iter().zip(cubes).enumerate() {
let mut ids = [[[0usize; 2]; 2]; 2];
let mut seen: Vec<usize> = Vec::with_capacity(8);
for (i, plane) in ids.iter_mut().enumerate() {
for (j, row) in plane.iter_mut().enumerate() {
for (k, slot) in row.iter_mut().enumerate() {
let p = brep.point(cube.at(i, j, k));
let key = cellkey(p);
let mut found: Option<usize> = None;
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
let near = [key[0] + dx, key[1] + dy, key[2] + dz];
for &v in grid.get(&near).map(Vec::as_slice).unwrap_or(&[]) {
if vertices.get(v).is_some_and(|&x| dist(x, p) <= merge) {
if found.is_some_and(|f| f != v) {
return Err(Error::Unsupported(format!(
"cell `{}`: a corner at {p:?} is within the merge distance of two vertices",
name(c)
)));
}
found = Some(v);
}
}
}
}
}
let v = match found {
Some(v) => v,
None => {
vertices.push(p);
grid.entry(key).or_default().push(vertices.len() - 1);
vertices.len() - 1
}
};
if seen.contains(&v) {
return Err(Error::Unsupported(format!("cell `{}`: two corners at the same point {p:?}", name(c))));
}
seen.push(v);
*slot = v;
}
}
}
corners.push(ids);
}
// Edges: the same two vertices and the same curve (midpoints within the merge distance
// and the tessellation's chordal deviation).
let edge_tol = merge + 4.0 * chordal;
let mut edges: Vec<Edge> = Vec::new();
let mut by_ends: BTreeMap<(usize, usize), Vec<(usize, P)>> = BTreeMap::new();
let mut cell_edges = Vec::with_capacity(cubes.len());
for (c, (brep, cube)) in breps.iter().zip(cubes).enumerate() {
let ids = corners.get(c).copied().ok_or_else(|| Error::Failed("cell corners".into()))?;
let mut out = [[0usize; 4]; 3];
for (d, row) in out.iter_mut().enumerate() {
for (m, slot) in row.iter_mut().enumerate() {
let (u, v) = edge_ends(&ids, d, m);
let (e, fwd) = cube.groups.get(d).and_then(|g| g.get(m)).copied().ok_or_else(|| Error::Failed("cube groups".into()))?;
let pts = brep.edge_points(e, fwd);
let (mid, length) = midpoint(&pts);
let (a, b) = (u.min(v), u.max(v));
let list = by_ends.entry((a, b)).or_default();
let id = match list.iter().find(|(_, q)| dist(*q, mid) <= edge_tol) {
Some(&(id, _)) => id,
None => {
edges.push(Edge { a, b, owner: (c, e, fwd == (u == a)), length });
list.push((edges.len() - 1, mid));
edges.len() - 1
}
};
*slot = id;
}
}
cell_edges.push(out);
}
// Faces: the same four edges, on the same surface, with the cells on opposite sides.
let mut faces: Vec<Face> = Vec::new();
let mut by_edges: BTreeMap<[usize; 4], usize> = BTreeMap::new();
let mut cell_faces = Vec::with_capacity(cubes.len());
for (c, (brep, cube)) in breps.iter().zip(cubes).enumerate() {
let ids = corners.get(c).copied().ok_or_else(|| Error::Failed("cell corners".into()))?;
let mut out = [[0usize; 2]; 3];
for (d, row) in out.iter_mut().enumerate() {
for (side, slot) in row.iter_mut().enumerate() {
let ring = face_ring(d, side).map(|[i, j, k]| ids[i][j][k]);
let mut key = [0usize; 4];
for (m, k) in key.iter_mut().enumerate() {
let (u, v) = (ring[m], ring[(m + 1) % 4]);
let groups = cell_edges.get(c).ok_or_else(|| Error::Failed("cell edges".into()))?;
*k = edge_between(&edges, groups, u, v).ok_or_else(|| Error::Failed("a face side is no cell edge".into()))?;
}
key.sort_unstable();
let face = cube.faces.get(d).and_then(|r| r.get(side)).copied().ok_or_else(|| Error::Failed("cube faces".into()))?;
let id = match by_edges.get(&key) {
Some(&id) => {
let f = faces.get_mut(id).ok_or_else(|| Error::Failed("face index".into()))?;
if f.uses.len() >= 2 {
return Err(Error::Unsupported(format!("cell `{}`: a face is shared by more than two cells", name(c))));
}
if let Some(&(c0, d0, s0)) = f.uses.first() {
if c0 == c {
return Err(Error::Unsupported(format!("cell `{}`: two faces of the cell have the same four edges", name(c))));
}
let f0 = cubes.get(c0).and_then(|q| q.faces.get(d0)).and_then(|r| r.get(s0)).copied().unwrap_or(0);
let b0 = breps.get(c0).ok_or_else(|| Error::Failed("cell index".into()))?;
let (centre0, area0) = face_centre(b0, f0);
let (centre1, area1) = face_centre(brep, face);
if dist(centre0, centre1) > edge_tol + 1e-3 * norm(area0).sqrt() || dot(area0, area1) >= 0.0 {
return Err(Error::Unsupported(format!(
"cells `{}` and `{}` share the four edges of a face but not the face (different surfaces, or on the same side)",
name(c0),
name(c)
)));
}
}
f.uses.push((c, d, side));
id
}
None => {
faces.push(Face { ring, uses: vec![(c, d, side)] });
by_edges.insert(key, faces.len() - 1);
faces.len() - 1
}
};
*slot = id;
}
}
cell_faces.push(out);
}
Ok(Topology { vertices, edges, faces, corners, cell_edges, cell_faces })
}
/// Faces shared by two cells.
fn interfaces(&self) -> usize {
self.faces.iter().filter(|f| f.uses.len() == 2).count()
}
/// The edge between vertices `u` and `v` among cell `c`'s edges, and whether it runs from
/// `u` to `v`.
fn edge(&self, c: usize, u: usize, v: usize) -> Option<(usize, bool)> {
let e = edge_between(&self.edges, self.cell_edges.get(c)?, u, v)?;
Some((e, self.edges.get(e)?.a == u))
}
/// How every edge is divided, all cells together; graded edges get the counts their
/// grading asks for.
fn intervals(&self, ctxs: &[Ctx], cubes: &[Cube], field: &dyn SizeField) -> Result<Vec<Division>> {
let polylines: Vec<Vec<P>> = self.edges.iter().map(|e| self.polyline(ctxs, e)).collect::<Result<_>>()?;
let goals: Vec<f64> = polylines.iter().map(|p| ideal_divisions(field, p)).collect::<Result<_>>()?;
let first = self.solve(&goals)?;
// Ends of edges that leave a curved face: the spacing along that face there.
let ends = self.graded_ends(ctxs, cubes, &first);
let mut graded = goals;
let mut divisions = Vec::with_capacity(polylines.len());
for ((poly, ends), goal) in polylines.into_iter().zip(ends).zip(graded.iter_mut()) {
let poly = if ends.iter().any(Option::is_some) {
let dense = densify(&poly);
*goal = goal.max(ideal_divisions(&Grading { field, ends }, &dense)?);
dense
} else {
poly
};
divisions.push(Division { count: 1, ends, polyline: poly });
}
for (d, n) in divisions.iter_mut().zip(self.solve(&graded)?) {
d.count = n;
}
Ok(divisions)
}
fn solve(&self, goals: &[f64]) -> Result<Vec<usize>> {
let mut ia = super::interval::Intervals::default();
for &g in goals {
ia.var(g, 1);
}
for groups in &self.cell_edges {
for g in groups {
for &e in g.iter().skip(1) {
ia.equal.push((vec![g[0]], vec![e]));
}
}
}
ia.solve()
}
/// For every edge, at each end (`a`, `b`): the point and the spacing to start from when the
/// edge leaves a curved face there (ends on it without lying on it) — the smallest spacing,
/// by `counts`, of that face's edges at the end — or `None`.
fn graded_ends(&self, ctxs: &[Ctx], cubes: &[Cube], counts: &[usize]) -> Vec<GradedEnds> {
// The edges of every curved face, by the vertices they meet.
let mut curved: Vec<[usize; 4]> = Vec::new();
let mut curved_at: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for f in &self.faces {
let Some(&(c, d, side)) = f.uses.first() else { continue };
let planar = cubes
.get(c)
.and_then(|q| q.faces.get(d))
.and_then(|r| r.get(side))
.and_then(|&bf| ctxs.get(c).and_then(|x| x.brep.faces.get(bf)))
.is_none_or(|bf| bf.plane.is_some());
if planar {
continue;
}
let mut es = [usize::MAX; 4];
for (m, slot) in es.iter_mut().enumerate() {
if let Some((e, _)) = self.edge(c, f.ring[m], f.ring[(m + 1) % 4]) {
*slot = e;
}
}
for &v in &f.ring {
curved_at.entry(v).or_default().push(curved.len());
}
curved.push(es);
}
let spacing = |e: usize| -> f64 { self.edges.get(e).map_or(f64::INFINITY, |x| x.length / counts.get(e).copied().unwrap_or(1).max(1) as f64) };
self.edges
.iter()
.enumerate()
.map(|(e, edge)| {
[edge.a, edge.b].map(|v| {
let mut best: Option<f64> = None;
for face in curved_at.get(&v).map(Vec::as_slice).unwrap_or(&[]).iter().filter_map(|&k| curved.get(k)) {
if face.contains(&e) {
continue;
}
for &o in face {
if self.edges.get(o).is_some_and(|x| x.a == v || x.b == v) {
let s = spacing(o);
best = Some(best.map_or(s, |b| b.min(s)));
}
}
}
best.and_then(|s| self.vertices.get(v).map(|&x| (x, s)))
})
})
.collect()
}
/// The owner's polyline of an edge, from `a` to `b`.
fn polyline(&self, ctxs: &[Ctx], e: &Edge) -> Result<Vec<P>> {
let (c, be, fwd) = e.owner;
let ctx = ctxs.get(c).ok_or_else(|| Error::Failed("edge owner".into()))?;
let mut pts = ctx.brep.edge_points(be, fwd);
// The ends are the shared vertices.
if let (Some(first), Some(a)) = (pts.first_mut(), self.vertices.get(e.a)) {
*first = *a;
}
if let (Some(last), Some(b)) = (pts.last_mut(), self.vertices.get(e.b)) {
*last = *b;
}
if pts.len() < 2 {
return Err(Error::Failed("an edge without points".into()));
}
Ok(pts)
}
}
/// The edge among a cell's edges (`groups`) between vertices `u` and `v`.
fn edge_between(edges: &[Edge], groups: &[[usize; 4]; 3], u: usize, v: usize) -> Option<usize> {
let key = (u.min(v), u.max(v));
groups.iter().flatten().copied().find(|&e| edges.get(e).is_some_and(|x| (x.a, x.b) == key))
}
/// The corners (low, high) of edge `m` of direction `d` of a cell whose corners are `ids`, in
/// the order of [`Cube::groups`].
fn edge_ends(ids: &[[[usize; 2]; 2]; 2], d: usize, m: usize) -> (usize, usize) {
let (s, t) = ((m / 2) & 1, m & 1);
match d {
0 => (ids[0][s][t], ids[1][s][t]),
1 => (ids[s][0][t], ids[s][1][t]),
_ => (ids[s][t][0], ids[s][t][1]),
}
}
/// The point halfway along a polyline (by arc length) and its length.
fn midpoint(pts: &[P]) -> (P, f64) {
let length: f64 = pts.windows(2).map(|w| dist(w[0], w[1])).sum();
let mut walked = 0.0;
for w in pts.windows(2) {
let l = dist(w[0], w[1]);
if walked + l >= 0.5 * length && l > 0.0 {
let t = (0.5 * length - walked) / l;
return (add(w[0], scale(sub(w[1], w[0]), t)), length);
}
walked += l;
}
(pts.first().copied().unwrap_or([0.0; 3]), length)
}
/// A B-rep face's area-weighted centre and its area vector (outward).
fn face_centre(brep: &Brep, f: usize) -> (P, P) {
let (mut c, mut n, mut total) = ([0.0; 3], [0.0; 3], 0.0);
for &t in brep.faces.get(f).map(|x| x.triangles.as_slice()).unwrap_or(&[]) {
let Some(tri) = brep.surface.triangles().get(t as usize) else { continue };
let [a, b, d] = tri.map(|v| brep.point(v));
let area = cross(sub(b, a), sub(d, a));
let w = 0.5 * norm(area);
c = add(c, scale(add(add(a, b), d), w / 3.0));
n = add(n, scale(area, 0.5));
total += w;
}
(if total > 0.0 { scale(c, 1.0 / total) } else { c }, n)
}
/// A polyline cut into at least [`GRADED_SEGMENTS`] pieces (for the integration of a grading).
fn densify(pts: &[P]) -> Vec<P> {
let length: f64 = pts.windows(2).map(|w| dist(w[0], w[1])).sum();
let step = length / GRADED_SEGMENTS as f64;
let mut out = Vec::with_capacity(pts.len() + GRADED_SEGMENTS);
for w in pts.windows(2) {
let l = dist(w[0], w[1]);
let k = if step > 0.0 { ((l / step).ceil() as usize).clamp(1, GRADED_SEGMENTS) } else { 1 };
for s in 0..k {
out.push(add(w[0], scale(sub(w[1], w[0]), s as f64 / k as f64)));
}
}
if let Some(&last) = pts.last() {
out.push(last);
}
out
}
/// Each end of an edge (`a`, `b`): where grading starts from and the spacing there.
type GradedEnds = [Option<(P, f64)>; 2];
/// How an edge is divided: its count, its graded ends and the polyline (from `a` to `b`; cut
/// finer when graded) it is divided along.
struct Division {
count: usize,
ends: GradedEnds,
polyline: Vec<P>,
}
/// The size along a graded edge: the field's, capped near each graded end by the spacing there
/// growing by [`GROWTH`] per element (linearly with the distance from that end).
struct Grading<'a> {
field: &'a dyn SizeField,
ends: GradedEnds,
}
impl SizeField for Grading<'_> {
fn size(&self, p: [f64; 3]) -> f64 {
let mut h = self.field.size(p);
for (q, s) in self.ends.iter().flatten() {
h = h.min(s + (GROWTH - 1.0) * dist(p, *q));
}
h
}
}
/// The nodes of a cell's lattice, or of a face's grid, by identity.
struct Builder<'t, 'c, 'a> {
topo: &'t Topology,
ctxs: &'c [Ctx<'a>],
cubes: &'c [Cube],
divisions: &'c [Division],
field: &'c dyn SizeField,
opts: &'c HexOptions,
nodes: Vec<P>,
/// Per edge: its node ids from `a` to `b`.
edge_nodes: Vec<Vec<u32>>,
}
impl Builder<'_, '_, '_> {
fn count(&self, e: usize) -> usize {
self.divisions.get(e).map_or(1, |d| d.count)
}
fn push(&mut self, p: P) -> Result<u32> {
let id = u32::try_from(self.nodes.len()).map_err(|_| Error::Limit("too many nodes".into()))?;
self.nodes.push(p);
Ok(id)
}
fn build(&mut self) -> Result<(Raw, Vec<u32>, Vec<[usize; 3]>)> {
let (topo, field, max_elements) = (self.topo, self.field, self.opts.max_elements);
// Block sizes and the element budget before any node is made.
let mut blocks = Vec::with_capacity(topo.cell_edges.len());
let mut total = 0usize;
for groups in &topo.cell_edges {
let n = groups.map(|g| self.count(g[0]));
let nodes = (n[0] + 1).checked_mul(n[1] + 1).and_then(|x| x.checked_mul(n[2] + 1)).filter(|&x| x <= mapped::MAX_NODES);
if nodes.is_none() {
return Err(Error::Limit(format!("a {} × {} × {} block exceeds {} nodes", n[0], n[1], n[2], mapped::MAX_NODES)));
}
total = total.saturating_add(n[0] * n[1] * n[2]);
blocks.push(n);
}
if total > max_elements {
return Err(Error::Limit(format!("the mesh would have {total} hexahedra; the limit is {max_elements}")));
}
// Vertices.
for &v in &topo.vertices {
self.push(v)?;
}
// Edges: divided once, on the geometry.
for (edge, division) in topo.edges.iter().zip(self.divisions) {
let n = division.count;
let pts = if division.ends.iter().any(Option::is_some) {
divide_polyline(&Grading { field, ends: division.ends }, &division.polyline, n)?
} else {
divide_polyline(field, &division.polyline, n)?
};
let (c, be, _) = edge.owner;
let ctx = self.ctxs.get(c).ok_or_else(|| Error::Failed("edge owner".into()))?;
let faces = ctx.brep.edges.get(be).map(|x| x.faces.to_vec()).unwrap_or_default();
let mut ids = Vec::with_capacity(n + 1);
ids.push(edge.a as u32);
for q in pts.iter().take(n).skip(1) {
let p = project_to_faces(ctx, &faces, *q);
ids.push(self.push(p)?);
}
ids.push(edge.b as u32);
self.edge_nodes.push(ids);
}
// Faces: one grid each, from the owner's side.
let mut face_grids: Vec<FaceGrid> = Vec::with_capacity(topo.faces.len());
for face in &topo.faces {
face_grids.push(self.face_grid(face)?);
}
// Cells.
let mut hexes: Vec<[u32; 8]> = Vec::with_capacity(total);
let mut element_cell: Vec<u32> = Vec::with_capacity(total);
for (c, &n) in blocks.iter().enumerate() {
let ids = self.cell_lattice(c, n, &face_grids)?;
let [ni, nj, nk] = n;
let id = |i: usize, j: usize, k: usize| ids.get(lattice_index(n, [i, j, k])).copied().unwrap_or(0);
for k in 0..nk {
for j in 0..nj {
for i in 0..ni {
hexes.push([
id(i, j, k),
id(i + 1, j, k),
id(i + 1, j + 1, k),
id(i, j + 1, k),
id(i, j, k + 1),
id(i + 1, j, k + 1),
id(i + 1, j + 1, k + 1),
id(i, j + 1, k + 1),
]);
element_cell.push(c as u32);
}
}
}
}
let shared = topo.interfaces();
let description = format!(
"{} cell{} as conforming mapped blocks, {} shared face{}",
blocks.len(),
if blocks.len() == 1 { "" } else { "s" },
shared,
if shared == 1 { "" } else { "s" }
);
let nodes = std::mem::take(&mut self.nodes);
Ok((Raw { nodes, hexes, description }, element_cell, blocks))
}
/// The nodes of edge `e` from vertex `u`.
fn edge_from(&self, e: usize, u: usize) -> Result<Vec<u32>> {
let edge = self.topo.edges.get(e).ok_or_else(|| Error::Failed("edge index".into()))?;
let mut ids = self.edge_nodes.get(e).cloned().ok_or_else(|| Error::Failed("edge nodes".into()))?;
if edge.b == u {
ids.reverse();
} else if edge.a != u {
return Err(Error::Failed("an edge does not start at the vertex asked".into()));
}
Ok(ids)
}
/// A face's transfinite grid in its owner's frame.
fn face_grid(&mut self, face: &Face) -> Result<FaceGrid> {
let &(c, d, side) = face.uses.first().ok_or_else(|| Error::Failed("a face without a cell".into()))?;
let r = face.ring;
let side_nodes = |b: &Self, u: usize, v: usize| -> Result<Vec<u32>> {
let (e, _) = b.topo.edge(c, u, v).ok_or_else(|| Error::Failed("a face side is no edge".into()))?;
b.edge_from(e, u)
};
let bottom = side_nodes(self, r[0], r[1])?;
let right = side_nodes(self, r[1], r[2])?;
let top = side_nodes(self, r[3], r[2])?;
let left = side_nodes(self, r[0], r[3])?;
let (na, nb) = (bottom.len() - 1, left.len() - 1);
if top.len() != na + 1 || right.len() != nb + 1 {
return Err(Error::Failed("opposite sides of a face have different counts".into()));
}
let at = |b: &Self, ids: &[u32]| -> Vec<P> { ids.iter().map(|&i| b.nodes.get(i as usize).copied().unwrap_or([0.0; 3])).collect() };
let grid = transfinite_grid(&at(self, &bottom), &at(self, &right), &at(self, &top), &at(self, &left))?;
let ctx = self.ctxs.get(c).ok_or_else(|| Error::Failed("face owner".into()))?;
let brep_face = self.cubes.get(c).and_then(|q| q.faces.get(d)).and_then(|row| row.get(side)).copied();
let mut ids = vec![0u32; (na + 1) * (nb + 1)];
for b in 0..=nb {
for a in 0..=na {
let id = if b == 0 {
bottom.get(a).copied()
} else if b == nb {
top.get(a).copied()
} else if a == 0 {
left.get(b).copied()
} else if a == na {
right.get(b).copied()
} else {
let q = grid.get(b * (na + 1) + a).copied().ok_or_else(|| Error::Failed("face grid".into()))?;
let q = brep_face.and_then(|f| project_to_face(ctx, f, q)).unwrap_or(q);
Some(self.push(q)?)
};
if let Some(slot) = ids.get_mut(b * (na + 1) + a) {
*slot = id.ok_or_else(|| Error::Failed("face side node".into()))?;
}
}
}
Ok(FaceGrid { ring: r, na, nb, ids })
}
/// Cell `c`'s lattice of node ids: boundary from the shared face grids, interior new.
fn cell_lattice(&mut self, c: usize, n: [usize; 3], face_grids: &[FaceGrid]) -> Result<Vec<u32>> {
let topo = self.topo;
let corners = topo.corners.get(c).copied().ok_or_else(|| Error::Failed("cell corners".into()))?;
let faces = topo.cell_faces.get(c).copied().ok_or_else(|| Error::Failed("cell faces".into()))?;
let total = (n[0] + 1) * (n[1] + 1) * (n[2] + 1);
let mut ids: Vec<Option<u32>> = vec![None; total];
for d in 0..3 {
let (da, db) = other(d);
for side in 0..2 {
let gf = faces.get(d).and_then(|r| r.get(side)).copied().ok_or_else(|| Error::Failed("cell face".into()))?;
let grid = face_grids.get(gf).ok_or_else(|| Error::Failed("face grid".into()))?;
let ring = face_ring(d, side).map(|[i, j, k]| corners[i][j][k]);
let map = grid.map_from(ring, n[da], n[db])?;
for b in 0..=n[db] {
for a in 0..=n[da] {
let mut l = [0usize; 3];
l[d] = side * n[d];
l[da] = a;
l[db] = b;
if let Some(slot) = ids.get_mut(lattice_index(n, l)) {
*slot = Some(map(a, b)?);
}
}
}
}
}
// Interior: trivariate interpolation, blended by the edges' arc-length distribution.
let mut x: Vec<Option<P>> = ids.iter().map(|i| i.and_then(|i| self.nodes.get(i as usize).copied())).collect();
let groups = topo.cell_edges.get(c).copied().ok_or_else(|| Error::Failed("cell edges".into()))?;
let mut blend: [Vec<f64>; 3] = [Vec::new(), Vec::new(), Vec::new()];
for (d, g) in groups.iter().enumerate() {
let mut sum = vec![0.0; n[d] + 1];
for (m, &e) in g.iter().enumerate() {
let pts: Vec<P> = self
.edge_from(e, edge_ends(&corners, d, m).0)?
.iter()
.map(|&i| self.nodes.get(i as usize).copied().unwrap_or([0.0; 3]))
.collect();
let params = crate::quad::tfi::params(&pts, "edge")?;
if params.len() != sum.len() {
return Err(Error::Failed("an edge's count differs from its block's".into()));
}
for (acc, p) in sum.iter_mut().zip(params) {
*acc += 0.25 * p;
}
}
if let Some(first) = sum.first_mut() {
*first = 0.0;
}
if let Some(last) = sum.last_mut() {
*last = 1.0;
}
blend[d] = sum;
}
transfinite_volume(&mut x, n, [&blend[0], &blend[1], &blend[2]])?;
let mut out = Vec::with_capacity(total);
for (slot, p) in ids.iter().zip(&x) {
out.push(match (slot, p) {
(Some(id), _) => *id,
(None, Some(p)) => self.push(*p)?,
(None, None) => return Err(Error::Failed("a block node is missing".into())),
});
}
Ok(out)
}
}
/// A face's grid of node ids in its owner's frame: `(na + 1) × (nb + 1)`, node `(a, b)` at
/// `b·(na + 1) + a`, corners at the owner's ring.
struct FaceGrid {
ring: [usize; 4],
na: usize,
nb: usize,
ids: Vec<u32>,
}
impl FaceGrid {
/// The lookup of node `(a, b)` of a cell's view of this face, whose ring (the order of
/// [`face_ring`]) is `ring` and counts `na × nb`.
fn map_from(&self, ring: [usize; 4], na: usize, nb: usize) -> Result<impl Fn(usize, usize) -> Result<u32> + '_> {
// The owner's lattice position of each of the cell's corners.
let corner = |v: usize| -> Option<[i64; 2]> {
let k = self.ring.iter().position(|&x| x == v)?;
let (a, b) = [(0, 0), (self.na, 0), (self.na, self.nb), (0, self.nb)].get(k).copied()?;
Some([a as i64, b as i64])
};
let (Some(o), Some(pa), Some(pb)) = (corner(ring[0]), corner(ring[1]), corner(ring[3])) else {
return Err(Error::Failed("a cell's face does not have the shared face's corners".into()));
};
let step = |p: [i64; 2], n: usize| -> Option<[i64; 2]> {
let d = [p[0] - o[0], p[1] - o[1]];
let n = n as i64;
// One axis of the owner's lattice, `n` long.
(n > 0 && d.iter().filter(|&&x| x != 0).count() == 1 && d.iter().all(|&x| x == 0 || x.abs() == n)).then(|| d.map(|x| x / n))
};
let (Some(du), Some(dv)) = (step(pa, na), step(pb, nb)) else {
return Err(Error::Failed("a cell's view of a shared face does not match its grid".into()));
};
let (na_owner, ids) = (self.na, &self.ids);
Ok(move |a: usize, b: usize| -> Result<u32> {
let (a, b) = (a as i64, b as i64);
let u = o[0] + a * du[0] + b * dv[0];
let v = o[1] + a * du[1] + b * dv[1];
usize::try_from(v)
.ok()
.zip(usize::try_from(u).ok())
.and_then(|(v, u)| ids.get(v * (na_owner + 1) + u))
.copied()
.ok_or_else(|| Error::Failed("shared face grid index".into()))
})
}
}
+34 -1
View File
@@ -115,6 +115,29 @@ impl Surface {
};
q.iter().all(|x| x.is_finite()).then_some(q)
}
/// The unit normal of the surface at its point closest to `p`, up to sign, or `None` where
/// the closest point is not unique.
pub(crate) fn normal(&self, p: P) -> Option<P> {
match *self {
Surface::Plane { normal, .. } => unit(normal),
Surface::Cylinder { origin, axis, .. } => {
let w = sub(p, origin);
unit(sub(w, scale(axis, dot(w, axis))))
}
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())))
}
Surface::Sphere { center, .. } => unit(sub(p, center)),
Surface::Torus { center, axis, major, .. } => {
let w = sub(p, center);
let ring = add(center, scale(unit(sub(w, scale(axis, dot(w, axis))))?, major));
unit(sub(p, ring))
}
}
}
}
/// The closest point to `p` on triangle `abc`.
@@ -274,8 +297,18 @@ impl<'a> TriangleTree<'a> {
/// The closest triangle to `p` among those `accept` admits (by triangle index).
pub(crate) fn closest(&self, p: P, accept: &dyn Fn(u32) -> bool) -> Option<Hit> {
self.search(p, f64::INFINITY, accept)
}
/// [`TriangleTree::closest`] among the triangles within distance `r` of `p` (the search
/// skips everything farther).
pub(crate) fn closest_within(&self, p: P, r: f64, accept: &dyn Fn(u32) -> bool) -> Option<Hit> {
self.search(p, r * r, accept).filter(|h| h.dist <= r)
}
fn search(&self, p: P, limit2: f64, accept: &dyn Fn(u32) -> bool) -> Option<Hit> {
let mut best: Option<Hit> = None;
let mut best2 = f64::INFINITY;
let mut best2 = limit2;
let mut stack = vec![0usize];
let mut visits = 0usize;
while let Some(id) = stack.pop() {
+83 -36
View File
@@ -12,6 +12,7 @@
use std::collections::BTreeMap;
use super::brep::Brep;
use super::interval::Intervals;
use super::{Ctx, Raw, project_to_face, project_to_faces};
use crate::quad::{lattice_index, transfinite_faces, transfinite_volume};
@@ -20,10 +21,30 @@ use crate::vec3::{P, det, sub};
use crate::{Error, Result};
/// The most nodes of a mapped block.
const MAX_NODES: usize = 1 << 26;
pub(crate) const MAX_NODES: usize = 1 << 26;
pub(crate) fn mesh(ctx: &Ctx) -> Result<Raw> {
let brep = ctx.brep;
/// A logical cube found in a B-rep: its corners, its edges by logical direction and its faces.
pub(crate) struct Cube {
/// The surface vertex at each corner, `corner[i][j][k]`, right-handed.
pub corner: [[[u32; 2]; 2]; 2],
/// The four edges along each logical direction `d`, oriented from the low corner to the high
/// one, at the other two coordinates `(s, t) ∈ {0, 1}²` in the order (0, 0), (0, 1), (1, 0),
/// (1, 1).
pub groups: [[(usize, bool); 4]; 3],
/// The B-rep face (index into `faces`) on each side of each direction: `faces[d][side]`.
pub faces: [[usize; 2]; 3],
}
impl Cube {
/// The corner at logical coordinates `(i, j, k)` (each 0 or 1).
pub(crate) fn at(&self, i: usize, j: usize, k: usize) -> u32 {
self.corner[i & 1][j & 1][k & 1]
}
}
/// Recognize a logical cube: six faces with one loop of four edges each, three edges at each of
/// eight corners. Anything else is [`Error::Unsupported`] with the reason.
pub(crate) fn recognize(brep: &Brep) -> Result<Cube> {
if brep.faces.len() != 6 || brep.edges.len() != 12 {
return Err(Error::Unsupported(format!("not a logical cube: {} faces and {} edges (6 and 12 needed)", brep.faces.len(), brep.edges.len())));
}
@@ -61,7 +82,6 @@ pub(crate) fn mesh(ctx: &Ctx) -> Result<Raw> {
};
let c111 = common(c110, c101, a).filter(|&x| neighbours(c011).contains(&x));
let Some(c111) = c111 else { return Err(Error::Unsupported("not a logical cube: the corner graph is not a cube".into())) };
// corner[i][j][k]
let corner = [[[c000, c], [b, c011]], [[a, c101], [c110, c111]]];
let at = |i: usize, j: usize, k: usize| -> u32 { corner[i][j][k] };
let mut distinct: Vec<u32> = corner.iter().flatten().flatten().copied().collect();
@@ -74,20 +94,65 @@ pub(crate) fn mesh(ctx: &Ctx) -> Result<Raw> {
let edge_between = |u: u32, v: u32| -> Option<(usize, bool)> {
adj.get(&u)?.iter().find(|x| x.0 == v).map(|x| (x.1, brep.edges.get(x.1).is_some_and(|e| e.first() == u)))
};
// The four edges along each direction, from the low corner to the high one.
let mut groups: [Vec<(usize, bool)>; 3] = [Vec::new(), Vec::new(), Vec::new()];
let mut groups = [[(0usize, true); 4]; 3];
for (d, group) in groups.iter_mut().enumerate() {
for s in 0..2 {
for t in 0..2 {
let (lo, hi) = match d {
0 => (at(0, s, t), at(1, s, t)),
1 => (at(s, 0, t), at(s, 1, t)),
_ => (at(s, t, 0), at(s, t, 1)),
};
group.push(edge_between(lo, hi).ok_or_else(|| Error::Unsupported("not a logical cube: a corner pair has no edge".into()))?);
}
for (m, slot) in group.iter_mut().enumerate() {
let (s, t) = (m / 2, m % 2);
let (lo, hi) = match d {
0 => (at(0, s, t), at(1, s, t)),
1 => (at(s, 0, t), at(s, 1, t)),
_ => (at(s, t, 0), at(s, t, 1)),
};
*slot = edge_between(lo, hi).ok_or_else(|| Error::Unsupported("not a logical cube: a corner pair has no edge".into()))?;
}
}
// The face bounded by the four edges of each side.
let mut faces = [[0usize; 2]; 3];
for (d, row) in faces.iter_mut().enumerate() {
for (side, slot) in row.iter_mut().enumerate() {
let ring = face_ring(d, side).map(|[i, j, k]| at(i, j, k));
let mut bounding: Vec<usize> = (0..4).filter_map(|m| edge_between(ring[m], ring[(m + 1) % 4]).map(|x| x.0)).collect();
bounding.sort_unstable();
*slot = brep
.faces
.iter()
.position(|f| {
let mut es: Vec<usize> = f.loops.iter().flatten().map(|x| x.0).collect();
es.sort_unstable();
es == bounding
})
.ok_or_else(|| Error::Unsupported("not a logical cube: a side's four edges bound no face".into()))?;
}
}
Ok(Cube { corner, groups, faces })
}
/// The corners of side `side` of direction `d` in the order (0, 0), (1, 0), (1, 1), (0, 1) of its
/// two other directions (lower first).
pub(crate) fn face_ring(d: usize, side: usize) -> [[usize; 3]; 4] {
let (da, db) = other(d);
[(0, 0), (1, 0), (1, 1), (0, 1)].map(|(a, b)| {
let mut c = [0usize; 3];
c[d] = side;
c[da] = a;
c[db] = b;
c
})
}
/// The two directions other than `d`, lower first.
pub(crate) fn other(d: usize) -> (usize, usize) {
match d {
0 => (1, 2),
1 => (0, 2),
_ => (0, 1),
}
}
pub(crate) fn mesh(ctx: &Ctx) -> Result<Raw> {
let brep = ctx.brep;
let cube = recognize(brep)?;
let groups = cube.groups;
let mut ia = Intervals::default();
for e in 0..brep.edges.len() {
ia.var(ideal_divisions(ctx.field, &brep.edge_points(e, true))?, 1);
@@ -99,7 +164,7 @@ pub(crate) fn mesh(ctx: &Ctx) -> Result<Raw> {
}
}
let counts = ia.solve()?;
let count = |g: &Vec<(usize, bool)>| g.first().and_then(|x| counts.get(x.0)).copied().unwrap_or(1);
let count = |g: &[(usize, bool); 4]| g.first().and_then(|x| counts.get(x.0)).copied().unwrap_or(1);
let (ni, nj, nk) = (count(&groups[0]), count(&groups[1]), count(&groups[2]));
let total = (ni + 1).checked_mul(nj + 1).and_then(|x| x.checked_mul(nk + 1)).filter(|&x| x <= MAX_NODES);
let total = total.ok_or_else(|| Error::Limit(format!("a {ni} × {nj} × {nk} block exceeds {MAX_NODES} nodes")))?;
@@ -135,26 +200,8 @@ pub(crate) fn mesh(ctx: &Ctx) -> Result<Raw> {
// Faces: grids from their edges, projected onto the B-rep face their four edges bound.
let n = [ni, nj, nk];
transfinite_faces(&mut x, n, None, |d, side, grid| {
let (da, db) = match d {
0 => (1, 2),
1 => (0, 2),
_ => (0, 1),
};
let logical = |a: usize, b: usize| {
let mut c = [0usize; 3];
c[d] = side;
c[da] = a;
c[db] = b;
at(c[0], c[1], c[2])
};
let ring = [logical(0, 0), logical(1, 0), logical(1, 1), logical(0, 1)];
let mut bounding: Vec<usize> = (0..4).filter_map(|m| edge_between(ring[m], ring[(m + 1) % 4]).map(|x| x.0)).collect();
bounding.sort_unstable();
let face = brep.faces.iter().position(|f| {
let mut es: Vec<usize> = f.loops.iter().flatten().map(|x| x.0).collect();
es.sort_unstable();
es == bounding
});
let (da, db) = other(d);
let face = cube.faces.get(d).and_then(|r| r.get(side)).copied();
let (na, nb) = (n[da], n[db]);
for b in 1..nb {
for a in 1..na {
+210 -38
View File
@@ -19,6 +19,11 @@
//! A body none of these fits is [`Error::Unsupported`] with each method's reason: the input for
//! decomposition into sweepable and mappable regions.
//!
//! **Partitioned parts.** [`hex_mesh_cells`] ([`cells`]) meshes a part split into cells that
//! meet face to face (SolidCraft split bodies) as one conforming mesh: every cell a mapped block,
//! interval assignment over all cells together, each shared face meshed once and its nodes
//! merged by identity.
//!
//! **Common steps.** Interval assignment ([`interval`]) picks the division counts of shared curves
//! together. Boundary nodes on analytic faces are placed on the exact surface; quadratic hexahedra
//! (hex20) get their mid-edge nodes projected onto the faces (and onto both faces of a B-rep
@@ -31,7 +36,8 @@
mod arrangement;
mod brep;
mod geom;
mod cells;
pub(crate) mod geom;
mod interval;
mod layered;
mod mapped;
@@ -43,6 +49,7 @@ mod tests;
use std::collections::HashMap;
pub use cells::{Cell, CellsMesh, GROWTH, MAX_CELLS, hex_mesh_cells};
pub use geom::Surface;
use brep::Brep;
@@ -198,16 +205,7 @@ pub fn hex_mesh(surface: &BoundarySurface, faces: &[Option<Surface>], field: &dy
surface.shells().len()
)));
}
// A mesh far beyond the element limit is refused before any work: the solid's volume over
// the cube of the smallest size the field asks for at the boundary.
let mut h_min = f64::INFINITY;
for p in surface.points() {
h_min = h_min.min(crate::size::checked_size(field, *p)?);
}
let estimate = surface.volume().abs() / h_min.powi(3);
if !(estimate <= 8.0 * opts.max_elements as f64) {
return Err(Error::Limit(format!("about {estimate:.3e} hexahedra at this size; the limit is {}", opts.max_elements)));
}
check_estimate(&[surface], field, opts)?;
let brep = Brep::new(surface, faces)?;
let tree = TriangleTree::new(surface)?;
let ctx = Ctx { brep: &brep, tree: &tree, field, opts, tol: RELATIVE_TOLERANCE * brep.diagonal };
@@ -253,7 +251,7 @@ pub fn hex_mesh(surface: &BoundarySurface, faces: &[Option<Surface>], field: &dy
Err(e @ Error::Limit(_)) => return Err(e),
Err(e) => last = Some(e),
},
_ => match mapped::mesh(&ctx).and_then(|raw| finish(&ctx, raw, method)) {
_ => match mapped::mesh(&ctx).and_then(|raw| finish(&Cells::one(&ctx), raw, method)) {
Ok(mesh) => return Ok(mesh),
Err(e @ Error::Limit(_)) => return Err(e),
Err(e) => last = Some(e),
@@ -266,6 +264,23 @@ pub fn hex_mesh(surface: &BoundarySurface, faces: &[Option<Surface>], field: &dy
Err(Error::Unsupported(format!("no structured hex method fits this body ({})", reasons.join("; "))))
}
/// Refuse a mesh far beyond the element limit before any work: the solids' volume over the cube
/// of the smallest size the field asks for on their boundaries.
fn check_estimate(surfaces: &[&BoundarySurface], field: &dyn SizeField, opts: &HexOptions) -> Result<()> {
let (mut h_min, mut volume) = (f64::INFINITY, 0.0);
for s in surfaces {
for p in s.points() {
h_min = h_min.min(crate::size::checked_size(field, *p)?);
}
volume += s.volume().abs();
}
let estimate = volume / h_min.powi(3);
if !(estimate <= 8.0 * opts.max_elements as f64) {
return Err(Error::Limit(format!("about {estimate:.3e} hexahedra at this size; the limit is {}", opts.max_elements)));
}
Ok(())
}
/// The frames a layered method can try, fewest levels first, or the first reason none fits.
fn candidate_setups(ctx: &Ctx, method: HexMethod) -> std::result::Result<Vec<layered::Setup>, String> {
let frames = if method == HexMethod::Extrude { layered::translations(ctx.brep) } else { layered::rotations(ctx.brep) };
@@ -292,7 +307,7 @@ fn candidate_setups(ctx: &Ctx, method: HexMethod) -> std::result::Result<Vec<lay
fn with_refinement(ctx: &Ctx, method: HexMethod, run: impl Fn(u32) -> Result<Raw>) -> Result<HexMesh> {
let mut last = Error::Failed("no attempt".into());
for refine in 0..=MAX_REFINE {
match run(refine).and_then(|raw| finish(ctx, raw, method)) {
match run(refine).and_then(|raw| finish(&Cells::one(ctx), raw, method)) {
Ok(mesh) => return Ok(mesh),
Err(e @ Error::Failed(_)) => last = e,
Err(e) => return Err(e),
@@ -314,14 +329,130 @@ fn hex_face(k: usize) -> [usize; 4] {
ElementKind::Hex8.faces().get(k).and_then(|f| <[usize; 4]>::try_from(*f).ok()).unwrap_or([0, 1, 2, 3])
}
/// Tag, check, upgrade to hex20 when asked, gate and measure.
fn finish(ctx: &Ctx, raw: Raw, method: HexMethod) -> Result<HexMesh> {
/// The cells a mesh spans, for [`finish`]: one context per cell and the cell of each element.
pub(crate) struct Cells<'c, 'a> {
pub ctxs: &'c [Ctx<'a>],
/// The cell of each hexahedron (index into `ctxs`); empty: all in the first.
pub of: &'c [u32],
/// The cells' names, for messages.
pub names: &'c [&'c str],
}
impl<'c, 'a> Cells<'c, 'a> {
pub(crate) fn one(ctx: &'c Ctx<'a>) -> Self {
Cells { ctxs: std::slice::from_ref(ctx), of: &[], names: &[] }
}
fn name(&self, c: usize) -> String {
self.names.get(c).map_or_else(|| c.to_string(), |n| format!("`{n}`"))
}
fn cell(&self, e: usize) -> usize {
self.of.get(e).map_or(0, |&c| c as usize)
}
fn ctx(&self, e: usize) -> Result<&Ctx<'a>> {
self.ctxs.get(self.cell(e)).ok_or_else(|| Error::Failed(format!("element {e} has no cell")))
}
}
/// Buckets of a uniform grid over the cells' bounding boxes: which cells may be near a point.
struct CellGrid {
lo: P,
step: f64,
dims: [usize; 3],
buckets: Vec<Vec<usize>>,
}
/// Cosine of the largest angle between a boundary face's normal and the reversed normal of
/// another cell's triangle for the face to lie on that triangle (about 25°): a face on another
/// cell faces it, while faces that merely meet at an edge (a fillet beside a split) do not.
const FACING: f64 = 0.9;
/// Buckets per axis of a [`CellGrid`] at most.
const MAX_GRID_DIM: usize = 64;
impl CellGrid {
/// The cells' boxes grown by `gap`.
fn new(ctxs: &[Ctx], gap: f64) -> CellGrid {
let boxes: Vec<(P, P)> = ctxs
.iter()
.map(|c| {
c.brep.surface.points().iter().fold(([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]), |(mut lo, mut hi), p| {
for k in 0..3 {
lo[k] = lo[k].min(p[k] - gap);
hi[k] = hi[k].max(p[k] + gap);
}
(lo, hi)
})
})
.collect();
let (lo, hi) = boxes.iter().fold(([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]), |(mut lo, mut hi), (a, b)| {
for k in 0..3 {
lo[k] = lo[k].min(a[k]);
hi[k] = hi[k].max(b[k]);
}
(lo, hi)
});
let per_axis = ((ctxs.len() as f64).cbrt().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);
let dims = [dim(0), dim(1), dim(2)];
let mut grid = CellGrid { lo, step, dims, buckets: vec![Vec::new(); dims[0] * dims[1] * dims[2]] };
for (c, (a, b)) in boxes.iter().enumerate() {
let (i0, i1) = (grid.index(*a), grid.index(*b));
for i in i0[0]..=i1[0] {
for j in i0[1]..=i1[1] {
for k in i0[2]..=i1[2] {
if let Some(bucket) = grid.buckets.get_mut((k * dims[1] + j) * dims[0] + i) {
bucket.push(c);
}
}
}
}
}
grid
}
fn index(&self, p: P) -> [usize; 3] {
[0, 1, 2].map(|k| {
let x = ((p[k] - self.lo[k]) / self.step).floor();
if x.is_finite() && x > 0.0 { (x as usize).min(self.dims[k] - 1) } else { 0 }
})
}
fn near(&self, p: P) -> &[usize] {
let [i, j, k] = self.index(p);
self.buckets.get((k * self.dims[1] + j) * self.dims[0] + i).map(Vec::as_slice).unwrap_or(&[])
}
}
/// A boundary quadrilateral of a mesh being finished: the element face, its cell and the B-rep
/// face (index into the cell's faces) it lies on.
struct Skin {
element: u32,
face: u8,
cell: usize,
brep: usize,
}
/// Tag, check, upgrade to hex20 when asked, gate and measure. Besides the gate, the tiling and
/// the fidelity checks, no boundary face of a mesh of several cells may lie on another cell's
/// boundary (a free internal face: cells that do not conform there), and the mesh boundary must
/// be a closed 2-manifold of quadrilaterals (every edge on exactly two).
fn finish(cells: &Cells, raw: Raw, method: HexMethod) -> Result<HexMesh> {
let Raw { mut nodes, hexes, description } = raw;
let first = cells.ctxs.first().ok_or_else(|| Error::Failed("no cell to finish".into()))?;
let opts = first.opts;
if hexes.is_empty() {
return Err(Error::Failed("the method produced no elements".into()));
}
if hexes.len() > ctx.opts.max_elements {
return Err(Error::Limit(format!("the mesh has {} hexahedra; the limit is {}", hexes.len(), ctx.opts.max_elements)));
if hexes.len() > opts.max_elements {
return Err(Error::Limit(format!("the mesh has {} hexahedra; the limit is {}", hexes.len(), opts.max_elements)));
}
if !cells.of.is_empty() && cells.of.len() != hexes.len() {
return Err(Error::Failed("a cell is needed for every element".into()));
}
fieldcraft_model::check_coordinates(nodes.as_flattened())?;
let at = |nodes: &[P], i: u32| nodes.get(i as usize).copied().ok_or_else(|| Error::Failed(format!("node {i} out of range")));
@@ -337,8 +468,14 @@ fn finish(ctx: &Ctx, raw: Raw, method: HexMethod) -> Result<HexMesh> {
if let Some(n) = uses.values().copied().max().filter(|&n| n > 2) {
return Err(Error::Failed(format!("a hexahedron face is shared by {n} elements (the mesh is not a manifold)")));
}
// A boundary face this close to another cell's boundary lies on it.
let gap = 2.0 * opts.chordal_tolerance + cells.ctxs.iter().map(|c| c.tol).fold(0.0, f64::max);
let others = (cells.ctxs.len() > 1).then(|| CellGrid::new(cells.ctxs, gap));
let mut skin: Vec<Skin> = Vec::new();
let mut boundary = Vec::new();
let mut fidelity = 0.0;
// Boundary edges of the quadrilaterals: each must be on exactly two of them.
let mut rim: HashMap<(u32, u32), u32> = HashMap::new();
for (e, h) in hexes.iter().enumerate() {
for k in 0..6 {
let corners = hex_face(k).map(|i| h.get(i).copied().unwrap_or(0));
@@ -347,6 +484,11 @@ fn finish(ctx: &Ctx, raw: Raw, method: HexMethod) -> Result<HexMesh> {
if uses.get(&key) != Some(&1) {
continue;
}
for m in 0..4 {
let (a, b) = (corners[m], corners[(m + 1) % 4]);
*rim.entry((a.min(b), a.max(b))).or_insert(0) += 1;
}
let ctx = cells.ctx(e)?;
let [a, b, c, d] = [at(&nodes, corners[0])?, at(&nodes, corners[1])?, at(&nodes, corners[2])?, at(&nodes, corners[3])?];
let centre = scale(add(add(a, b), add(c, d)), 0.25);
let n = cross(sub(c, a), sub(d, b));
@@ -357,14 +499,35 @@ fn finish(ctx: &Ctx, raw: Raw, method: HexMethod) -> Result<HexMesh> {
.closest(centre, &|t| dot(ctx.tree.normal(t), n) > 0.0)
.ok_or_else(|| Error::Failed(format!("no boundary face faces the same way as element {e}'s face {}", k + 1)))?;
let f = ctx.brep.face_of_triangle(hit.triangle).ok_or_else(|| Error::Failed("triangle without a face".into()))?;
fidelity += area * (hit.dist + ctx.opts.chordal_tolerance);
boundary.push(BoundaryFace { element: e as u32, face: k as u8 + 1, brep_face: ctx.brep.face_tag(f) });
let cell = cells.cell(e);
if let Some(grid) = &others {
for &o in grid.near(centre) {
let Some(other) = cells.ctxs.get(o).filter(|_| o != cell) else { continue };
if other.tree.closest_within(centre, gap, &|t| dot(other.tree.normal(t), n) < -FACING).is_some() {
return Err(Error::Failed(format!(
"element {e}'s face {} lies on cell {}'s boundary without a neighbour there: cells {} and {} do not conform (split them so that they meet face to face)",
k + 1,
cells.name(o),
cells.name(cell),
cells.name(o)
)));
}
}
}
// The geometry's own face tag (a member of a composite face keeps its own).
let tag = ctx.brep.surface.tri_face().get(hit.triangle as usize).copied().unwrap_or_else(|| ctx.brep.face_tag(f));
fidelity += area * (hit.dist + opts.chordal_tolerance);
boundary.push(BoundaryFace { element: e as u32, face: k as u8 + 1, brep_face: tag });
skin.push(Skin { element: e as u32, face: k as u8 + 1, cell, brep: f });
}
}
let kind = if ctx.opts.quadratic { ElementKind::Hex20 } else { ElementKind::Hex8 };
if let Some((&(a, b), &n)) = rim.iter().filter(|(_, n)| **n != 2).min_by_key(|(k, _)| **k) {
return Err(Error::Failed(format!("the mesh boundary is not closed: the edge between nodes {a} and {b} is on {n} boundary faces")));
}
let kind = if opts.quadratic { ElementKind::Hex20 } else { ElementKind::Hex8 };
let mut connectivity: Vec<u32> = Vec::with_capacity(hexes.len() * kind.node_count());
if ctx.opts.quadratic {
let mids = mid_edge_nodes(ctx, &mut nodes, &hexes, &boundary)?;
if opts.quadratic {
let mids = mid_edge_nodes(cells, &mut nodes, &hexes, &skin)?;
for (h, m) in hexes.iter().zip(&mids) {
connectivity.extend_from_slice(h);
connectivity.extend_from_slice(m);
@@ -398,7 +561,7 @@ fn finish(ctx: &Ctx, raw: Raw, method: HexMethod) -> Result<HexMesh> {
let gate = validity_gate_checks(checks.iter().map(|(l, c)| (*l, c)), &GateOptions { expected_volume: Some(enclosed), volume_tolerance: 1e-9 })?
.into_result()
.map_err(|e| Error::Failed(format!("{} ({description})", plain(&e))))?;
let surface_volume = ctx.brep.surface.volume();
let surface_volume: f64 = cells.ctxs.iter().map(|c| c.brep.surface.volume()).sum();
let slack = 2.0 * fidelity + 1e-9 * surface_volume.abs();
if (gate.volume - surface_volume).abs() > slack {
return Err(Error::Failed(format!(
@@ -420,7 +583,7 @@ fn finish(ctx: &Ctx, raw: Raw, method: HexMethod) -> Result<HexMesh> {
/// `∫ x n_x dA` over a quad4 or quad8 face (outward normal): its contribution to the enclosed
/// volume. The 3 × 3 Gauss rule integrates it exactly (degree at most 5 per direction).
fn face_flux(face: &[P]) -> f64 {
pub(crate) fn face_flux(face: &[P]) -> f64 {
const G: [(f64, f64); 3] = [(-0.774_596_669_241_483_4, 5.0 / 9.0), (0.0, 8.0 / 9.0), (0.774_596_669_241_483_4, 5.0 / 9.0)];
let mut sum = 0.0;
for (r, wr) in G {
@@ -478,46 +641,52 @@ pub(crate) fn project_to_face(ctx: &Ctx, f: usize, p: P) -> Option<P> {
return Some(q);
}
let tag = face.id;
let tri_face = ctx.brep.surface.tri_face();
ctx.tree.closest(p, &|t| tri_face.get(t as usize) == Some(&tag)).map(|h| h.point)
ctx.tree.closest(p, &|t| ctx.brep.tag(t) == Some(tag)).map(|h| h.point)
}
/// The point on every listed face (their common curve or corner) nearest `p`, by alternating
/// projections.
pub(crate) fn project_to_faces(ctx: &Ctx, faces: &[usize], p: P) -> P {
let pairs: Vec<(&Ctx, usize)> = faces.iter().map(|&f| (ctx, f)).collect();
project_alternating(&pairs, ctx.tol, p)
}
/// The point on every listed face of the listed cells nearest `p`, by alternating projections
/// until a round moves it less than a thousandth of `tol`.
fn project_alternating(faces: &[(&Ctx, usize)], tol: f64, p: P) -> P {
let mut x = p;
for _ in 0..PROJECTION_ROUNDS {
let before = x;
for &f in faces {
for &(ctx, f) in faces {
if let Some(q) = project_to_face(ctx, f, x) {
x = q;
}
}
if faces.len() < 2 || dist(before, x) <= ctx.tol * 1e-3 {
if faces.len() < 2 || dist(before, x) <= tol * 1e-3 {
break;
}
}
x
}
/// Mid-edge nodes of every hexahedron (Hex20 order: positions 8..20), shared between elements;
/// boundary edges get theirs on the B-rep faces of the boundary quads around them.
fn mid_edge_nodes(ctx: &Ctx, nodes: &mut Vec<P>, hexes: &[[u32; 8]], boundary: &[BoundaryFace]) -> Result<Vec<[u32; 12]>> {
// B-rep faces at each boundary edge.
let mut edge_faces: HashMap<(u32, u32), Vec<usize>> = HashMap::new();
for b in boundary {
/// Mid-edge nodes of every hexahedron (Hex20 order: positions 8..20), shared between elements
/// (of any cell); boundary edges get theirs on the B-rep faces of the boundary quads around them.
fn mid_edge_nodes(cells: &Cells, nodes: &mut Vec<P>, hexes: &[[u32; 8]], skin: &[Skin]) -> Result<Vec<[u32; 12]>> {
// B-rep faces (cell, face) at each boundary edge.
let mut edge_faces: HashMap<(u32, u32), Vec<(usize, usize)>> = HashMap::new();
for b in skin {
let Some(h) = hexes.get(b.element as usize) else { continue };
let c = hex_face(b.face as usize - 1).map(|i| h.get(i).copied().unwrap_or(0));
let f = ctx.brep.faces.binary_search_by_key(&b.brep_face, |x| x.id).map_err(|_| Error::Failed("boundary tag".into()))?;
for k in 0..4 {
let (a, z) = (c[k], c[(k + 1) % 4]);
let list = edge_faces.entry((a.min(z), a.max(z))).or_default();
if !list.contains(&f) {
list.push(f);
if !list.contains(&(b.cell, b.brep)) {
list.push((b.cell, b.brep));
}
}
}
let edges = ElementKind::Hex20.edges();
let tol = cells.ctxs.iter().map(|c| c.tol).fold(f64::INFINITY, f64::min);
let mut mid: HashMap<(u32, u32), u32> = HashMap::new();
let mut out = Vec::with_capacity(hexes.len());
for h in hexes {
@@ -533,7 +702,10 @@ fn mid_edge_nodes(ctx: &Ctx, nodes: &mut Vec<P>, hexes: &[[u32; 8]], boundary: &
let (a, b) = (nodes.get(i as usize).copied().unwrap_or([0.0; 3]), nodes.get(j as usize).copied().unwrap_or([0.0; 3]));
let p = scale(add(a, b), 0.5);
let p = match edge_faces.get(&key) {
Some(fs) => project_to_faces(ctx, fs, p),
Some(fs) => {
let pairs: Vec<(&Ctx, usize)> = fs.iter().filter_map(|&(c, f)| cells.ctxs.get(c).map(|ctx| (ctx, f))).collect();
project_alternating(&pairs, tol, p)
}
None => p,
};
nodes.push(p);
+188
View File
@@ -292,3 +292,191 @@ fn unsweepable_bodies_say_why_and_hostile_input_is_an_error() {
let m = hex_mesh(&s, &garbage, &UniformSize(1.0), &HexOptions::default()).unwrap();
assert_eq!(m.element_count(), 24);
}
// ---- Conforming cells ----
/// A prism over a counter-clockwise polygon from z0 to z1 whose side from vertex k to k + 1 gets
/// tag `side_tags[k]` (bottom 0, top 1); quads of one tag make one face.
fn tagged_prism(poly: &[[f64; 2]], side_tags: &[u32], z0: f64, z1: f64) -> BoundarySurface {
let n = poly.len();
let mut pts: Vec<[f64; 3]> = poly.iter().map(|p| [p[0], p[1], z0]).collect();
pts.extend(poly.iter().map(|p| [p[0], p[1], z1]));
let mut tris = Vec::new();
let mut tags = Vec::new();
for t in ears(&pts, &(0..n as u32).rev().collect::<Vec<_>>()) {
tris.push(t);
tags.push(0);
}
for t in ears(&pts, &(n as u32..2 * n as u32).collect::<Vec<_>>()) {
tris.push(t);
tags.push(1);
}
for (k, &tag) in side_tags.iter().enumerate().take(n) {
let (a, b) = (k as u32, ((k + 1) % n) as u32);
let (c, d) = (b + n as u32, a + n as u32);
tris.push([a, b, c]);
tris.push([a, c, d]);
tags.extend([tag, tag]);
}
BoundarySurface::new(&pts, &tris, &tags, &SurfaceOptions::default()).unwrap()
}
fn cells_of<'a>(surfaces: &'a [BoundarySurface], faces: &'a [Vec<Option<Surface>>]) -> Vec<Cell<'a>> {
surfaces.iter().zip(faces).map(|(surface, faces)| Cell { name: "cell", surface, faces }).collect()
}
/// Boxes `[x0, x1] × [y0, y1] × [0, h]` as prisms (faces 0 bottom, 1 top, 2..6 the sides).
fn boxes(spans: &[([f64; 2], [f64; 2])], h: f64) -> Vec<BoundarySurface> {
spans.iter().map(|(x, y)| prism(&[[x[0], y[0]], [x[1], y[0]], [x[1], y[1]], [x[0], y[1]]], 0.0, h)).collect()
}
/// Every node of the mesh at a distinct position: the cells' nodes were merged.
fn distinct_positions(m: &HexMesh) -> bool {
let mut keys: Vec<[u64; 3]> = m.nodes.iter().map(|p| p.map(|x| (x + 0.0).to_bits())).collect();
keys.sort_unstable();
keys.windows(2).all(|w| w[0] != w[1])
}
#[test]
fn boxes_side_by_side_conform() {
// Two boxes sharing x = 2, then a 2 × 2 arrangement whose middle edge four cells share.
let pairs = boxes(&[([0.0, 2.0], [0.0, 1.0]), ([2.0, 3.0], [0.0, 1.0])], 1.0);
let none = vec![Vec::new(); 2];
let m = hex_mesh_cells(&cells_of(&pairs, &none), &UniformSize(0.5), &HexOptions::default()).unwrap();
assert_eq!(m.interfaces, 1);
assert_eq!(m.blocks.iter().map(|b| b[0] * b[1] * b[2]).sum::<usize>(), m.mesh.element_count());
assert_eq!(m.mesh.element_count(), 6 * 2 * 2);
// One structured grid: 7 × 3 × 3 nodes, none duplicated on the interface.
assert_eq!(m.mesh.nodes.len(), 7 * 3 * 3);
assert!(distinct_positions(&m.mesh));
assert!((m.mesh.report.volume - 3.0).abs() < 1e-12);
assert!(m.mesh.report.gate.passed());
// The boundary is the outside only: 2·(6·2 + 6·2 + 2·2) quads.
assert_eq!(m.mesh.boundary.len(), 56);
assert_eq!(m.element_cell.iter().filter(|&&c| c == 0).count(), 16);
let grid = boxes(&[([0.0, 1.0], [0.0, 1.0]), ([1.0, 2.0], [0.0, 1.0]), ([0.0, 1.0], [1.0, 2.0]), ([1.0, 2.0], [1.0, 2.0])], 1.0);
let none = vec![Vec::new(); 4];
let a = hex_mesh_cells(&cells_of(&grid, &none), &UniformSize(0.25), &HexOptions::default()).unwrap();
assert_eq!(a.interfaces, 4);
assert_eq!(a.mesh.nodes.len(), 9 * 9 * 5);
assert!(distinct_positions(&a.mesh));
// Deterministic, and hex20 shares its mid-edge nodes across the interfaces too.
let b = hex_mesh_cells(&cells_of(&grid, &none), &UniformSize(0.25), &HexOptions::default()).unwrap();
assert_eq!(a, b);
let q = hex_mesh_cells(&cells_of(&grid, &none), &UniformSize(0.25), &HexOptions { quadratic: true, ..HexOptions::default() }).unwrap();
assert_eq!(q.mesh.kind, ElementKind::Hex20);
// Corner nodes plus one node per lattice edge: 9·9·5 + (8·9·5)·2 + 9·9·4.
assert_eq!(q.mesh.nodes.len(), 405 + 720 + 324);
assert!(distinct_positions(&q.mesh));
}
#[test]
fn cells_that_do_not_meet_face_to_face_are_refused() {
// One box against two half-height boxes: its face at x = 1 meets two faces (a T-junction).
let t = vec![
prism(&[[0.0, 0.0], [1.0, 0.0], [1.0, 2.0], [0.0, 2.0]], 0.0, 1.0),
prism(&[[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]], 0.0, 1.0),
prism(&[[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 2.0]], 0.0, 1.0),
];
let none = vec![Vec::new(); 3];
match hex_mesh_cells(&cells_of(&t, &none), &UniformSize(0.5), &HexOptions::default()) {
Err(Error::Failed(why)) => assert!(why.contains("interface") || why.contains("conform"), "{why}"),
other => panic!("{other:?}"),
}
// A cell that is no logical cube says which.
let l = vec![prism(&[[0.0, 0.0], [6.0, 0.0], [6.0, 2.0], [2.0, 2.0], [2.0, 6.0], [0.0, 6.0]], 0.0, 2.0)];
match hex_mesh_cells(&cells_of(&l, &[Vec::new()]), &UniformSize(1.0), &HexOptions::default()) {
Err(Error::Unsupported(why)) => assert!(why.starts_with("cell `cell`: not a logical cube"), "{why}"),
other => panic!("{other:?}"),
}
}
#[test]
fn hostile_cell_input_is_an_error() {
let two = boxes(&[([0.0, 1.0], [0.0, 1.0]), ([1.0, 2.0], [0.0, 1.0])], 1.0);
let none = vec![Vec::new(); 2];
let cells = cells_of(&two, &none);
assert!(hex_mesh_cells(&[], &UniformSize(1.0), &HexOptions::default()).is_err());
for method in [HexMethod::Sweep, HexMethod::Extrude, HexMethod::Revolve, HexMethod::Swept] {
assert!(matches!(hex_mesh_cells(&cells, &UniformSize(1.0), &HexOptions { method, ..HexOptions::default() }), Err(Error::Invalid(_))));
}
let bad_sizes: [&dyn crate::size::SizeField; 3] = [&UniformSize(f64::NAN), &UniformSize(0.0), &UniformSize(-1.0)];
for f in bad_sizes {
assert!(hex_mesh_cells(&cells, f, &HexOptions::default()).is_err());
}
assert!(matches!(hex_mesh_cells(&cells, &UniformSize(1e-4), &HexOptions::default()), Err(Error::Limit(_))));
assert!(matches!(hex_mesh_cells(&cells, &UniformSize(0.1), &HexOptions { max_elements: 100, ..HexOptions::default() }), Err(Error::Limit(_))));
assert!(hex_mesh_cells(&cells, &UniformSize(1.0), &HexOptions { chordal_tolerance: -1.0, ..HexOptions::default() }).is_err());
// The same cell twice: two cells on the same side of every face.
let same = [two[0].clone(), two[0].clone()];
assert!(hex_mesh_cells(&cells_of(&same, &none), &UniformSize(0.5), &HexOptions::default()).is_err());
// Garbage analytic surfaces are not trusted.
let garbage = vec![vec![Some(Surface::Cylinder { origin: [f64::NAN; 3], axis: [0.0; 3], radius: -1.0 }); 6]; 2];
let m = hex_mesh_cells(&cells_of(&two, &garbage), &UniformSize(0.5), &HexOptions::default()).unwrap();
assert_eq!(m.mesh.element_count(), 16);
}
/// The four cells of an O-grid round a hole of radius 1 in the square [−2, 2]², height `h`: each
/// cell the hole's quarter between two diagonals and a side of the square. The hole's quarter is
/// one face (tag 5) or two halves of the cylinder (tags 5 and 6: virtual topology joins them).
fn o_grid(h: f64, arc_segments: usize, split_arcs: bool) -> (Vec<BoundarySurface>, Vec<Vec<Option<Surface>>>) {
use std::f64::consts::FRAC_PI_4;
let mut surfaces = Vec::new();
let mut faces = Vec::new();
for q in 0..4 {
let turn = q as f64 * 2.0 * FRAC_PI_4;
let rot = |p: [f64; 2]| [p[0] * turn.cos() - p[1] * turn.sin(), p[0] * turn.sin() + p[1] * turn.cos()];
let mut poly = vec![rot([FRAC_PI_4.cos(), -FRAC_PI_4.sin()]), rot([2.0, -2.0]), rot([2.0, 2.0])];
let mut tags = vec![2, 3, 4];
for k in 0..arc_segments {
let t = FRAC_PI_4 - 2.0 * FRAC_PI_4 * k as f64 / arc_segments as f64;
poly.push(rot([t.cos(), t.sin()]));
tags.push(if split_arcs && 2 * k >= arc_segments { 6 } else { 5 });
}
// Exact corners where the cells meet.
let snap = |p: [f64; 2]| p.map(|x| if (x.abs() - 2.0).abs() < 1e-12 { 2.0 * x.signum() } else { x });
let poly: Vec<[f64; 2]> = poly.into_iter().map(snap).collect();
surfaces.push(tagged_prism(&poly, &tags, 0.0, h));
let cyl = Some(Surface::Cylinder { origin: [0.0; 3], axis: [0.0, 0.0, 1.0], radius: 1.0 });
faces.push(vec![None, None, None, None, None, cyl, cyl]);
}
(surfaces, faces)
}
#[test]
fn an_o_grid_round_a_hole_conforms_with_graded_radial_lines() {
for split_arcs in [false, true] {
let (surfaces, faces) = o_grid(0.5, 24, split_arcs);
let opts = HexOptions { chordal_tolerance: 1.0 - (std::f64::consts::PI / 48.0).cos(), ..HexOptions::default() };
let m = hex_mesh_cells(&cells_of(&surfaces, &faces), &UniformSize(0.5), &opts).unwrap();
assert_eq!(m.interfaces, 4, "{split_arcs}");
assert!(m.mesh.report.gate.passed());
assert!(m.mesh.report.min_scaled_jacobian > 0.3, "{}", m.mesh.report.min_scaled_jacobian);
assert!(distinct_positions(&m.mesh));
// A ring of quads round the hole: every block is (around) × (radial) × (through).
let sorted = |b: [usize; 3]| {
let mut b = b;
b.sort_unstable();
b
};
assert!(m.blocks.iter().all(|&b| sorted(b) == sorted(m.blocks[0])), "{:?}", m.blocks);
let per_block = m.blocks[0].iter().product::<usize>();
assert_eq!(m.mesh.element_count(), 4 * per_block);
// Nodes on the hole lie on it.
let hole: Vec<u32> = if split_arcs { vec![5, 6] } else { vec![5] };
assert!(on_radius(&m.mesh, &hole, 1.0), "{split_arcs}");
let on_hole = m.mesh.boundary.iter().filter(|b| hole.contains(&b.brep_face)).count();
assert!(on_hole > 0);
// Radial grading: the first spacing off the hole is smaller than the last at the square.
let radii: std::collections::BTreeSet<u64> =
m.mesh.nodes.iter().filter(|p| p[2] == 0.0 && p[1].abs() < 1e-12 && p[0] > 0.0).map(|p| p[0].to_bits()).collect();
let r: Vec<f64> = radii.into_iter().map(f64::from_bits).collect();
assert!(r.len() >= 3, "{r:?}");
let first = r[1] - r[0];
let last = r[r.len() - 1] - r[r.len() - 2];
assert!(first < 0.75 * last, "{r:?}");
// Spacing grows by at most the growth rate plus the rounding of the count.
assert!(r.windows(3).all(|w| (w[2] - w[1]) <= 1.35 * (w[1] - w[0])), "{r:?}");
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod cdt;
pub mod delaunay;
pub mod hex;
pub mod join;
pub mod octree;
pub mod planar;
pub mod quad;
pub mod quality;
+636
View File
@@ -0,0 +1,636 @@
//! The boundary as the hex mesher sees it: a triangle tree for nearest-point, ray and box
//! queries, the exact inside test, and the feature graph (corners, curves, patches).
//!
//! # Feature graph
//! Every feature edge of the [`BoundarySurface`] (between two B-rep faces, or a crease inside a
//! face) whose dihedral angle reaches the sharp angle is kept; smooth face boundaries (a fillet
//! meeting its faces tangentially) are not features, the faces on either side share a patch.
//! Neither are blades and slits, edges whose normals turn by more than the blade angle (a 3°
//! wedge, a sphere touching a plane): no valid hexahedra can follow them, so the mesh rounds
//! them within the boundary tolerance. *Corners* are vertices where the number of feature edges is not two, or where a
//! curve turns by more than [`CORNER_TURN`]; *curves* are the chains of feature edges between
//! corners, or closed loops; *patches* are the connected sets of triangles between feature edges,
//! so every patch lies in one B-rep face and is bounded by curves. This is the partition the hex
//! mesh's boundary must reproduce (Gao, Shen, Panozzo 2019, §3.3).
use std::collections::BTreeMap;
pub(crate) use crate::hex::geom::closest_on_triangle;
use crate::predicates::{Axis, Point3, Sign, orient2d_projected, orient3d, snap_to_domain};
use crate::surface::BoundarySurface;
use crate::vec3::{P, cross, dot, norm, sub};
use crate::{Error, Result};
/// A degree-2 feature vertex whose curve turns by more than this (degrees) is a corner.
pub(crate) const CORNER_TURN: f64 = 30.0;
/// Triangles per tree leaf.
const LEAF: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Aabb {
pub lo: P,
pub hi: P,
}
impl Aabb {
pub const EMPTY: Aabb = Aabb { lo: [f64::INFINITY; 3], hi: [f64::NEG_INFINITY; 3] };
pub fn add(&mut self, p: P) {
for ((lo, hi), x) in self.lo.iter_mut().zip(&mut self.hi).zip(p) {
*lo = lo.min(x);
*hi = hi.max(x);
}
}
fn join(&mut self, o: &Aabb) {
self.add(o.lo);
self.add(o.hi);
}
pub fn overlaps(&self, o: &Aabb) -> bool {
(0..3).all(|k| self.lo[k] <= o.hi[k] && o.lo[k] <= self.hi[k])
}
/// Squared distance from a point to the box (0 inside).
fn dist2(&self, p: P) -> f64 {
(0..3).map(|k| (self.lo[k] - p[k]).max(0.0).max(p[k] - self.hi[k])).map(|d| d * d).sum()
}
/// Whether the segment `a + t (b − a)`, `t ∈ [0, 1]`, may meet the box (slab test, widened by
/// a relative margin so rounding never drops a hit).
fn meets_segment(&self, a: P, b: P) -> bool {
let (mut t0, mut t1) = (0.0f64, 1.0f64);
for k in 0..3 {
let pad = 1e-9 * (self.hi[k] - self.lo[k]).abs().max(1e-300) + 1e-12 * (a[k].abs() + b[k].abs());
let (lo, hi) = (self.lo[k] - pad, self.hi[k] + pad);
let d = b[k] - a[k];
if d == 0.0 {
if a[k] < lo || a[k] > hi {
return false;
}
continue;
}
let (mut u, mut v) = ((lo - a[k]) / d, (hi - a[k]) / d);
if u > v {
std::mem::swap(&mut u, &mut v);
}
t0 = t0.max(u);
t1 = t1.min(v);
if t0 > t1 {
return false;
}
}
true
}
}
#[derive(Debug, Clone, Copy)]
struct Node {
bounds: Aabb,
children: Option<usize>,
start: usize,
end: usize,
}
/// A median-split tree over triangle boxes.
#[derive(Debug, Clone)]
struct Tree {
nodes: Vec<Node>,
order: Vec<u32>,
}
impl Tree {
fn build(boxes: &[Aabb]) -> Tree {
let mut order: Vec<u32> = (0..boxes.len() as u32).collect();
let empty = Node { bounds: Aabb::EMPTY, children: None, start: 0, end: 0 };
let mut nodes = vec![empty];
let mut work = vec![(0usize, 0usize, boxes.len())];
while let Some((ni, start, end)) = work.pop() {
let Some(slice) = order.get_mut(start..end) else { continue };
let (mut bounds, mut centres) = (Aabb::EMPTY, Aabb::EMPTY);
for &t in slice.iter() {
if let Some(b) = boxes.get(t as usize) {
bounds.join(b);
centres.add([0, 1, 2].map(|k| b.lo[k] + b.hi[k]));
}
}
let mut node = Node { bounds, children: None, start, end };
if end - start > LEAF {
let ext = [0, 1, 2].map(|k| centres.hi[k] - centres.lo[k]);
let axis = if ext[0] >= ext[1] && ext[0] >= ext[2] {
0
} else if ext[1] >= ext[2] {
1
} else {
2
};
let key = |t: &u32| boxes.get(*t as usize).map_or(0.0, |b| b.lo[axis] + b.hi[axis]);
let mid = (end - start) / 2;
slice.select_nth_unstable_by(mid, |a, b| key(a).total_cmp(&key(b)).then(a.cmp(b)));
let l = nodes.len();
nodes.push(empty);
nodes.push(empty);
node.children = Some(l);
work.push((l, start, start + mid));
work.push((l + 1, start + mid, end));
}
if let Some(n) = nodes.get_mut(ni) {
*n = node;
}
}
Tree { nodes, order }
}
fn leaf(&self, n: &Node) -> &[u32] {
self.order.get(n.start..n.end).unwrap_or(&[])
}
}
pub(crate) fn lerp(a: P, b: P, t: f64) -> P {
[0, 1, 2].map(|k| a[k] + (b[k] - a[k]) * t)
}
/// The closest point of segment `ab` to `p` and its parameter.
pub(crate) fn closest_on_segment(p: P, a: P, b: P) -> (P, f64) {
let ab = sub(b, a);
let l2 = dot(ab, ab);
let t = if l2 > 0.0 { (dot(sub(p, a), ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
(lerp(a, b, t), t)
}
pub(crate) fn dist2(a: P, b: P) -> f64 {
let d = sub(a, b);
dot(d, d)
}
/// A chain of feature edges.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Curve {
/// Surface vertices in order; a closed loop repeats its first vertex at the end.
pub vertices: Vec<u32>,
pub closed: bool,
/// The patches on either side (sorted, deduplicated: one entry for a crease seam).
pub patches: Vec<u32>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Patch {
pub triangles: Vec<u32>,
/// The B-rep faces of its triangles (sorted, deduplicated; one for a well-formed boundary).
pub faces: Vec<u32>,
pub area: f64,
/// Twice the area over the length of the bounding curves: the width of a strip, the
/// diameter of a disc, `∞` for a closed surface without curves.
pub width: f64,
}
/// The boundary with its queries and feature graph.
pub(crate) struct Geometry<'a> {
pub surface: &'a BoundarySurface,
tree: Tree,
pub boxes: Vec<Aabb>,
pub normals: Vec<P>,
pub tri_patch: Vec<u32>,
pub patches: Vec<Patch>,
pub curves: Vec<Curve>,
/// Corner vertices, ascending.
pub corners: Vec<u32>,
pub bounds: Aabb,
/// Far enough to leave the boundary from any point of its box.
far: f64,
}
impl<'a> Geometry<'a> {
pub fn new(surface: &'a BoundarySurface, sharp_angle: f64, blade_angle: f64) -> Result<Geometry<'a>> {
let pts = surface.points();
let tris = surface.triangles();
let xyz = |v: u32| pts.get(v as usize).copied().unwrap_or([0.0; 3]);
let mut bounds = Aabb::EMPTY;
let mut boxes = Vec::with_capacity(tris.len());
let mut normals = Vec::with_capacity(tris.len());
for t in tris {
let mut b = Aabb::EMPTY;
for &v in t {
b.add(xyz(v));
}
bounds.join(&b);
boxes.push(b);
let n = cross(sub(xyz(t[1]), xyz(t[0])), sub(xyz(t[2]), xyz(t[0])));
let l = norm(n);
normals.push(if l > 0.0 { n.map(|c| c / l) } else { [0.0; 3] });
}
if tris.is_empty() || !(0..3).all(|k| bounds.hi[k] >= bounds.lo[k]) {
return Err(Error::Invalid("the boundary has no triangles".into()));
}
let tree = Tree::build(&boxes);
let diag = norm(sub(bounds.hi, bounds.lo));
let far = 4.0 * diag.max(1e-9) + (0..3).map(|k| bounds.lo[k].abs().max(bounds.hi[k].abs())).fold(0.0, f64::max);
// Feature edges and corners.
// A boundary between two B-rep faces is kept or dropped as a whole, by the median angle
// along it, so tessellation noise never breaks a curve into pieces; creases inside a
// face go edge by edge.
let face_of = |t: u32| surface.tri_face().get(t as usize).copied().unwrap_or(0);
let mut pair_angles: BTreeMap<(u32, u32), Vec<f64>> = BTreeMap::new();
for e in surface.feature_edges() {
let (a, b) = (face_of(e.triangles[0]), face_of(e.triangles[1]));
if a != b {
pair_angles.entry((a.min(b), a.max(b))).or_default().push(e.angle);
}
}
let pair_kept: BTreeMap<(u32, u32), bool> = pair_angles
.into_iter()
.map(|(k, mut v)| {
v.sort_by(f64::total_cmp);
let m = v.get(v.len() / 2).copied().unwrap_or(0.0);
(k, m >= sharp_angle && m <= blade_angle)
})
.collect();
let kept = |e: &&crate::surface::FeatureEdge| {
let (a, b) = (face_of(e.triangles[0]), face_of(e.triangles[1]));
if a != b { pair_kept.get(&(a.min(b), a.max(b))).copied().unwrap_or(false) } else { e.angle >= sharp_angle && e.angle <= blade_angle }
};
let mut edge_set: BTreeMap<(u32, u32), [u32; 2]> = BTreeMap::new();
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()];
let mut patches: Vec<Patch> = Vec::new();
for seed in 0..tris.len() {
if tri_patch[seed] != u32::MAX {
continue;
}
let id = patches.len() as u32;
let mut patch = Patch { triangles: Vec::new(), faces: Vec::new(), area: 0.0, width: f64::INFINITY };
tri_patch[seed] = id;
let mut stack = vec![seed as u32];
while let Some(t) = stack.pop() {
patch.triangles.push(t);
let tri = tris.get(t as usize).copied().unwrap_or([0; 3]);
let n = neighbors.get(t as usize).copied().unwrap_or([u32::MAX; 3]);
for k in 0..3 {
let (a, b) = (tri[k], tri[(k + 1) % 3]);
if edge_set.contains_key(&(a.min(b), a.max(b))) {
continue;
}
if let Some(slot) = tri_patch.get_mut(n[k] as usize)
&& *slot == u32::MAX
{
*slot = id;
stack.push(n[k]);
}
}
}
patch.triangles.sort_unstable();
for &t in &patch.triangles {
let tri = tris.get(t as usize).copied().unwrap_or([0; 3]);
patch.area += 0.5 * norm(cross(sub(xyz(tri[1]), xyz(tri[0])), sub(xyz(tri[2]), xyz(tri[0]))));
patch.faces.push(surface.tri_face().get(t as usize).copied().unwrap_or(0));
}
patch.faces.sort_unstable();
patch.faces.dedup();
let mut perimeter = 0.0;
for &t in &patch.triangles {
let tri = tris.get(t as usize).copied().unwrap_or([0; 3]);
for k in 0..3 {
let (a, b) = (tri[k], tri[(k + 1) % 3]);
if edge_set.contains_key(&(a.min(b), a.max(b))) {
perimeter += norm(sub(xyz(b), xyz(a)));
}
}
}
if perimeter > 0.0 {
patch.width = 2.0 * patch.area / perimeter;
}
patches.push(patch);
}
// Curves: chains from corners, then closed loops.
let mut used: BTreeMap<(u32, u32), bool> = edge_set.keys().map(|k| (*k, false)).collect();
let is_corner = |v: u32| corners.binary_search(&v).is_ok();
let mut curves: Vec<Curve> = Vec::new();
let trace = |start: u32, next: u32, used: &mut BTreeMap<(u32, u32), bool>, curves: &mut Vec<Curve>| {
let mut verts = vec![start];
let (mut prev, mut cur) = (start, next);
loop {
let key = (prev.min(cur), prev.max(cur));
if let Some(u) = used.get_mut(&key) {
*u = true;
}
verts.push(cur);
if cur == start || is_corner(cur) || verts.len() > edge_set.len() + 2 {
break;
}
let nxt = adj.get(&cur).and_then(|n| n.iter().copied().find(|&w| w != prev && used.get(&(cur.min(w), cur.max(w))) == Some(&false)));
match nxt {
Some(w) => {
prev = cur;
cur = w;
}
None => break,
}
}
let closed = verts.first() == verts.last() && verts.len() > 2;
let mut ps: Vec<u32> = Vec::new();
for w in verts.windows(2) {
if let Some(t) = edge_set.get(&(w[0].min(w[1]), w[0].max(w[1]))) {
ps.extend(t.iter().filter_map(|&t| tri_patch.get(t as usize).copied()));
}
}
ps.sort_unstable();
ps.dedup();
curves.push(Curve { vertices: verts, closed, patches: ps });
};
for &c in &corners {
let ns = adj.get(&c).cloned().unwrap_or_default();
for n in ns {
if used.get(&(c.min(n), c.max(n))) == Some(&false) {
trace(c, n, &mut used, &mut curves);
}
}
}
let keys: Vec<(u32, u32)> = used.keys().copied().collect();
for k in keys {
if used.get(&k) == Some(&false) {
trace(k.0, k.1, &mut used, &mut curves);
}
}
Ok(Geometry { surface, tree, boxes, normals, tri_patch, patches, curves, corners, bounds, far })
}
pub fn xyz(&self, v: u32) -> P {
self.surface.points().get(v as usize).copied().unwrap_or([0.0; 3])
}
/// The centroid of triangle `t`.
pub fn triangle_centre(&self, t: u32) -> P {
let [a, b, c] = self.triangle(t);
[0, 1, 2].map(|k| (a[k] + b[k] + c[k]) / 3.0)
}
pub fn triangle(&self, t: u32) -> [P; 3] {
let tri = self.surface.triangles().get(t as usize).copied().unwrap_or([0; 3]);
tri.map(|v| self.xyz(v))
}
/// The closest boundary point to `p` among triangles that pass `keep`: (triangle, point,
/// squared distance).
pub fn nearest(&self, p: P, keep: impl Fn(u32) -> bool) -> Option<(u32, P, f64)> {
let mut best: Option<(u32, P, f64)> = None;
let mut stack = vec![0usize];
while let Some(n) = stack.pop() {
let Some(node) = self.tree.nodes.get(n) else { continue };
let bd = node.bounds.dist2(p);
if best.is_some_and(|b| bd >= b.2) {
continue;
}
match node.children {
Some(l) => {
let (dl, dr) = (
self.tree.nodes.get(l).map_or(f64::INFINITY, |x| x.bounds.dist2(p)),
self.tree.nodes.get(l + 1).map_or(f64::INFINITY, |x| x.bounds.dist2(p)),
);
if dl <= dr {
stack.extend([l + 1, l]);
} else {
stack.extend([l, l + 1]);
}
}
None => {
for &t in self.tree.leaf(node) {
if !keep(t) {
continue;
}
let [a, b, c] = self.triangle(t);
let q = closest_on_triangle(p, a, b, c);
let d = dist2(p, q);
if best.is_none_or(|x| d < x.2 || (d == x.2 && t < x.0)) {
best = Some((t, q, d));
}
}
}
}
}
best
}
/// The triangles whose boxes overlap `b`, ascending.
pub fn overlapping(&self, b: &Aabb) -> Vec<u32> {
let mut out = Vec::new();
let mut stack = vec![0usize];
while let Some(n) = stack.pop() {
let Some(node) = self.tree.nodes.get(n) else { continue };
if !node.bounds.overlaps(b) {
continue;
}
match node.children {
Some(l) => stack.extend([l, l + 1]),
None => out.extend(self.tree.leaf(node).iter().copied().filter(|&t| self.boxes.get(t as usize).is_some_and(|x| x.overlaps(b)))),
}
}
out.sort_unstable();
out
}
/// The first triangle hit by the ray from `p` along `d` (unit) within `max`, with its
/// distance (floating point; used to measure, never to decide).
pub fn ray(&self, p: P, d: P, max: f64, skip: u32) -> Option<(u32, f64)> {
let q = [0, 1, 2].map(|k| p[k] + d[k] * max);
let mut best: Option<(u32, f64)> = None;
let mut stack = vec![0usize];
while let Some(n) = stack.pop() {
let Some(node) = self.tree.nodes.get(n) else { continue };
if !node.bounds.meets_segment(p, q) {
continue;
}
match node.children {
Some(l) => stack.extend([l, l + 1]),
None => {
for &t in self.tree.leaf(node) {
if t == skip {
continue;
}
let [a, b, c] = self.triangle(t);
if let Some(s) = ray_triangle(p, d, a, b, c)
&& s > 0.0
&& s <= max
&& best.is_none_or(|x| s < x.1)
{
best = Some((t, s));
}
}
}
}
}
best
}
/// Whether `p` is strictly inside the solid, decided exactly: the parity of the boundary
/// crossings of a segment from `p` to a far point, tried along fixed directions until one
/// meets no edge or vertex and does not start on the boundary. `None` when every direction is
/// degenerate (p lies on the boundary) or `p` is not admissible.
pub fn inside(&self, p: P) -> Option<bool> {
const DIRS: [P; 7] = [
[0.5773, 0.6123, 0.5401],
[-0.6301, 0.4372, 0.6417],
[0.3989, -0.7068, 0.5839],
[0.7211, 0.4109, -0.5577],
[-0.4402, -0.6005, -0.6675],
[0.1351, 0.9013, -0.4116],
[-0.8803, 0.1237, 0.4583],
];
let pp = Point3::new(snap_to_domain(p[0]), snap_to_domain(p[1]), snap_to_domain(p[2])).ok()?;
'dirs: for d in DIRS {
let q = [0, 1, 2].map(|k| snap_to_domain(p[k] + d[k] * self.far));
let Ok(qq) = Point3::new(q[0], q[1], q[2]) else { continue };
let mut parity = false;
let mut stack = vec![0usize];
while let Some(n) = stack.pop() {
let Some(node) = self.tree.nodes.get(n) else { continue };
if !node.bounds.meets_segment(p, q) {
continue;
}
match node.children {
Some(l) => stack.extend([l, l + 1]),
None => {
for &t in self.tree.leaf(node) {
match self.crossing(&pp, &qq, t) {
Some(true) => parity = !parity,
Some(false) => {}
None => continue 'dirs,
}
}
}
}
}
return Some(parity);
}
None
}
/// Whether segment `pq` crosses triangle `t` properly (`Some(true)`), misses it
/// (`Some(false)`), or touches it degenerately (`None`): exact.
fn crossing(&self, p: &Point3, q: &Point3, t: u32) -> Option<bool> {
let tri = self.surface.triangles().get(t as usize)?;
let a = self.surface.point3(tri[0])?;
let b = self.surface.point3(tri[1])?;
let c = self.surface.point3(tri[2])?;
let (sp, sq) = (orient3d(&a, &b, &c, p), orient3d(&a, &b, &c, q));
if sq.is_zero() {
// The far end is outside the boundary's box, so never on a triangle; a segment in
// the plane is ambiguous.
return None;
}
if sp.is_zero() {
// p in the triangle's plane: the segment leaves the plane at p, so it touches the
// triangle only if p lies in it.
let axis = [Axis::Z, Axis::X, Axis::Y].into_iter().find(|&ax| !orient2d_projected(&a, &b, &c, ax).is_zero())?;
let o = orient2d_projected(&a, &b, &c, axis);
let inside = [(&a, &b), (&b, &c), (&c, &a)].into_iter().all(|(u, v)| orient2d_projected(u, v, p, axis) != -o);
return if inside { None } else { Some(false) };
}
if sp == sq {
return Some(false);
}
let s = [orient3d(p, q, &a, &b), orient3d(p, q, &b, &c), orient3d(p, q, &c, &a)];
if s.iter().any(|x| x.is_zero()) {
let nz: Vec<Sign> = s.iter().copied().filter(|x| !x.is_zero()).collect();
return if nz.windows(2).all(|w| w[0] == w[1]) { None } else { Some(false) };
}
Some(s[0] == s[1] && s[1] == s[2])
}
/// The closest point of curve `c` to `p`: (point, segment index, parameter on it).
pub fn nearest_on_curve(&self, c: u32, p: P) -> Option<(P, usize, f64)> {
let curve = self.curves.get(c as usize)?;
let mut best: Option<(P, usize, f64, f64)> = None;
for (i, w) in curve.vertices.windows(2).enumerate() {
let (q, t) = closest_on_segment(p, self.xyz(w[0]), self.xyz(w[1]));
let d = dist2(p, q);
if best.is_none_or(|b| d < b.3) {
best = Some((q, i, t, d));
}
}
best.map(|(q, i, t, _)| (q, i, t))
}
}
/// Möller–Trumbore ray–triangle distance (floating point).
fn ray_triangle(o: P, d: P, a: P, b: P, c: P) -> Option<f64> {
let (e1, e2) = (sub(b, a), sub(c, a));
let h = cross(d, e2);
let det = dot(e1, h);
if det.abs() < 1e-300 {
return None;
}
let inv = 1.0 / det;
let s = sub(o, a);
let u = inv * dot(s, h);
if !(-1e-12..=1.0 + 1e-12).contains(&u) {
return None;
}
let q = cross(s, e1);
let v = inv * dot(d, q);
if v < -1e-12 || u + v > 1.0 + 1e-12 {
return None;
}
Some(inv * dot(e2, q))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::surface::SurfaceOptions;
/// A closed axis-aligned box surface, one B-rep face per side.
pub(crate) fn box_surface(lo: P, hi: P) -> BoundarySurface {
let c = |i: usize| [if i & 1 == 0 { lo[0] } else { hi[0] }, if i & 2 == 0 { lo[1] } else { hi[1] }, if i & 4 == 0 { lo[2] } else { hi[2] }];
let pts: Vec<P> = (0..8).map(c).collect();
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
let mut tris = Vec::new();
let mut face = Vec::new();
for (f, q) in quads.iter().enumerate() {
tris.push([q[0], q[1], q[2]]);
tris.push([q[0], q[2], q[3]]);
face.extend([f as u32; 2]);
}
BoundarySurface::new(&pts, &tris, &face, &SurfaceOptions::default()).unwrap()
}
#[test]
fn box_feature_graph_inside_and_nearest() {
let s = box_surface([0.0; 3], [2.0, 3.0, 4.0]);
let g = Geometry::new(&s, 30.0, 160.0).unwrap();
assert_eq!(g.corners.len(), 8);
assert_eq!(g.curves.len(), 12);
assert_eq!(g.patches.len(), 6);
assert!(g.curves.iter().all(|c| c.vertices.len() == 2 && c.patches.len() == 2 && !c.closed));
assert_eq!(g.inside([1.0, 1.5, 2.0]), Some(true));
assert_eq!(g.inside([3.0, 1.5, 2.0]), Some(false));
assert_eq!(g.inside([1.0, 1.5, 0.0]), None, "on the boundary");
assert_eq!(g.inside([0.0, 0.0, 0.0]), None, "a corner");
let (_, q, d) = g.nearest([1.0, 1.0, -1.0], |_| true).unwrap();
assert!((d - 1.0).abs() < 1e-12 && q[2] == 0.0);
let top = g.tri_patch[2];
let (_, q, _) = g.nearest([1.0, 1.0, -1.0], |t| g.tri_patch[t as usize] == top).unwrap();
assert_eq!(q[2], 4.0, "restricted to the top patch");
let (t, s) = g.ray([1.0, 1.5, 2.0], [0.0, 0.0, 1.0], 10.0, u32::MAX).unwrap();
assert!((s - 2.0).abs() < 1e-12 && g.tri_patch[t as usize] == top);
}
}
+376
View File
@@ -0,0 +1,376 @@
//! The adaptive grid: a box of root cubes, each refined as a 27-tree (a leaf splits into
//! 3 × 3 × 3), strongly balanced and closed under the template patterns, then cut into a
//! conforming hexahedral mesh by [`super::templates`].
//!
//! # Balance and closure
//! *Strong balance*: leaves that share a vertex differ by at most one level, so a leaf's finer
//! neighbours are exactly one level finer and a leaf next to a coarser one never has a finer one
//! on that side. A vertex of a leaf at level `L` is *marked* when a finer leaf touches it, that
//! is, when one of the eight level-`L` cells around the vertex is split. *Closure*: a leaf whose
//! marks fit no template (two diagonal corners of a face, three of a face, …) is split too.
//! Balancing and closing repeat until neither splits anything; both only split, so this ends,
//! and every round runs over the leaves in sorted order, so the result is the same on every run.
//!
//! # Coordinates
//! Everything is integer. A cell at level `L` has index `i` (per axis, in units of its own edge)
//! and spans `6 · 3^(D − L)` lattice units, `D` the deepest level allowed; template vertices are
//! in sixths of a leaf, so every vertex is a lattice point and vertices shared by neighbours are
//! found by exact lookup.
use std::collections::HashMap;
use super::templates::{self, Pattern, Sym};
use crate::{Error, Result};
/// A cell: its level and index (in units of its edge at that level).
pub(crate) type Key = (u8, [i64; 3]);
/// The deepest level a tree may have (`3^12` lattice steps per root cell edge, × 6).
pub(crate) const MAX_LEVEL: u8 = 12;
fn pow3(n: u8) -> i64 {
3i64.pow(u32::from(n))
}
/// What covers a cell-sized region at some level.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Cover {
/// A leaf (at that level or coarser).
Leaf(Key),
/// The region is split at that level (finer leaves inside).
Split,
/// Outside the root box.
Outside,
}
/// The tree. `nodes` maps every cell that exists to whether it is a leaf.
#[derive(Debug, Clone)]
pub(crate) struct Tree {
/// Root cubes per axis.
pub roots: [i64; 3],
/// The deepest level allowed.
pub depth: u8,
nodes: HashMap<Key, bool>,
leaves: usize,
max_leaves: usize,
}
/// The sort order of [`Tree::sorted_leaves`]: level, then z, y, x.
fn order(k: &Key) -> (u8, i64, i64, i64) {
(k.0, k.1[2], k.1[1], k.1[0])
}
/// The 26 neighbour offsets, in a fixed order.
fn offsets() -> impl Iterator<Item = [i64; 3]> {
(0..27).map(|k| [k % 3 - 1, (k / 3) % 3 - 1, k / 9 - 1]).filter(|d| *d != [0, 0, 0])
}
impl Tree {
/// One leaf per root cube.
pub fn new(roots: [i64; 3], depth: u8, max_leaves: usize) -> Result<Tree> {
if roots.iter().any(|&n| n < 1) || depth > MAX_LEVEL {
return Err(Error::Invalid(format!("grid of {roots:?} root cells to depth {depth}")));
}
let count = roots.iter().try_fold(1usize, |a, &n| usize::try_from(n).ok().and_then(|n| a.checked_mul(n)));
let count = count.filter(|&c| c <= max_leaves).ok_or_else(|| Error::Limit(format!("{roots:?} root cells exceed {max_leaves} cells")))?;
let mut nodes = HashMap::with_capacity(count);
for z in 0..roots[2] {
for y in 0..roots[1] {
for x in 0..roots[0] {
nodes.insert((0, [x, y, z]), true);
}
}
}
Ok(Tree { roots, depth, nodes, leaves: count, max_leaves })
}
pub fn leaf_count(&self) -> usize {
self.leaves
}
/// Whether `key` is a leaf.
pub fn is_leaf(&self, key: &Key) -> bool {
self.nodes.get(key).copied().unwrap_or(false)
}
fn inside(&self, (level, i): Key) -> bool {
let n = pow3(level);
(0..3).all(|k| i[k] >= 0 && i[k] < self.roots[k] * n)
}
/// What covers the level-`L` cell `key`.
fn cover(&self, key: Key) -> Cover {
if !self.inside(key) {
return Cover::Outside;
}
let (level, i) = key;
for l in (0..=level).rev() {
let s = pow3(level - l);
let k = (l, [i[0].div_euclid(s), i[1].div_euclid(s), i[2].div_euclid(s)]);
match self.nodes.get(&k) {
Some(true) => return Cover::Leaf(k),
Some(false) => return Cover::Split,
None => {}
}
}
Cover::Outside
}
/// Split a leaf into its 27 children.
pub fn split(&mut self, key: Key) -> Result<()> {
let level = key.0;
if !self.is_leaf(&key) {
return Ok(());
}
if level >= self.depth {
return Err(Error::Limit(format!("refinement needs more than {} levels", self.depth)));
}
if self.leaves + 26 > self.max_leaves {
return Err(Error::Limit(format!("the grid needs more than {} cells", self.max_leaves)));
}
self.nodes.insert(key, false);
for child in Self::children(key) {
self.nodes.insert(child, true);
}
self.leaves += 26;
Ok(())
}
/// The leaves in sorted order (level, then z, y, x).
pub fn sorted_leaves(&self) -> Vec<Key> {
let mut v: Vec<Key> = self.nodes.iter().filter(|(_, leaf)| **leaf).map(|(k, _)| *k).collect();
v.sort_by_key(order);
v
}
/// The children of a split cell.
pub fn children(key: Key) -> impl Iterator<Item = Key> {
let (level, i) = key;
(0..27).map(move |k| (level + 1, [3 * i[0] + k % 3, 3 * i[1] + (k / 3) % 3, 3 * i[2] + k / 9]))
}
/// Split until leaves sharing a vertex differ by at most one level, checking only the leaves
/// `work` (in sorted order) and the children of the cells it splits: a split only makes
/// neighbours finer, so only new leaves can be too fine for a neighbour, and the result is
/// the unique coarsest balanced refinement whatever the order. Every split cell goes to `log`.
fn balance_from(&mut self, mut work: Vec<Key>, log: &mut Vec<Key>) -> Result<()> {
work.reverse();
while let Some(key) = work.pop() {
if !self.is_leaf(&key) || key.0 < 2 {
continue;
}
let (level, i) = key;
for d in offsets() {
if let Cover::Leaf(k) = self.cover((level, [i[0] + d[0], i[1] + d[1], i[2] + d[2]]))
&& k.0 + 1 < level
{
self.split(k)?;
log.push(k);
let mut kids: Vec<Key> = Self::children(k).collect();
kids.reverse();
work.extend(kids);
work.push(key);
break;
}
}
}
Ok(())
}
/// The leaves whose marks a split of `key` may change: its children and the leaves beside it
/// on its level.
fn touched_by(&self, key: Key, out: &mut Vec<Key>) {
out.extend(Self::children(key));
let (level, i) = key;
out.extend(offsets().map(|d| (level, [i[0] + d[0], i[1] + d[1], i[2] + d[2]])).filter(|k| self.is_leaf(k)));
}
/// The marked corners of a leaf (bit `c` for corner `c`: bit 0 x, bit 1 y, bit 2 z).
pub fn marks(&self, (level, i): Key) -> u8 {
let mut mask = 0u8;
for c in 0..8usize {
let o = [(c & 1) as i64, ((c >> 1) & 1) as i64, ((c >> 2) & 1) as i64];
let split = (0..8usize).any(|e| {
let n = [i[0] + o[0] - 1 + (e & 1) as i64, i[1] + o[1] - 1 + ((e >> 1) & 1) as i64, i[2] + o[2] - 1 + ((e >> 2) & 1) as i64];
self.nodes.get(&(level, n)) == Some(&false)
});
if split {
mask |= 1 << c;
}
}
mask
}
/// Balance, then split every leaf whose marks fit no template (and every leaf marked at all
/// eight corners, whose template is the split), until nothing changes. `max_rounds` bounds the
/// rounds. Each round re-checks only the leaves the previous splits can have changed, so a
/// round costs what it splits, not the size of the tree.
pub fn close(&mut self, max_rounds: usize, skip: &dyn Fn(Key) -> bool) -> Result<()> {
let mut fresh = self.sorted_leaves();
let mut touched = fresh.clone();
for _ in 0..max_rounds {
let mut log = Vec::new();
self.balance_from(std::mem::take(&mut fresh), &mut log)?;
for &k in &log {
self.touched_by(k, &mut touched);
}
touched.retain(|k| self.is_leaf(k));
touched.sort_by_key(order);
touched.dedup();
let bad: Vec<Key> = touched
.drain(..)
.filter(|&k| !skip(k))
.filter(|&k| !matches!(templates::classify(self.marks(k)), Some(Pattern::None | Pattern::Corner | Pattern::Edge | Pattern::Face)))
.collect();
if bad.is_empty() {
return Ok(());
}
for &k in &bad {
self.split(k)?;
}
for &k in &bad {
fresh.extend(Self::children(k));
self.touched_by(k, &mut touched);
}
fresh.sort_by_key(order);
}
Err(Error::Limit(format!("grid closure did not settle in {max_rounds} rounds")))
}
/// Lattice units per edge of a level-`L` cell.
pub fn span(&self, level: u8) -> i64 {
6 * pow3(self.depth - level)
}
/// Cut every leaf by its template into a conforming mesh on the integer lattice. Fails if a
/// leaf's marks fit no template (call [`Tree::close`] first).
pub fn mesh(&self, skip: &dyn Fn(Key) -> bool) -> Result<LatticeMesh> {
let syms: Vec<Sym> = templates::symmetries();
let canon: Vec<(Pattern, Vec<templates::Hex>)> =
[Pattern::None, Pattern::Corner, Pattern::Edge, Pattern::Face].into_iter().map(|p| (p, templates::template(p))).collect();
let mut out = LatticeMesh::default();
let mut index: HashMap<[i64; 3], u32> = HashMap::new();
for key in self.sorted_leaves() {
if skip(key) {
continue;
}
let mask = self.marks(key);
let pattern = templates::classify(mask).filter(|p| *p != Pattern::Full);
let (pattern, sym) = pattern
.and_then(|p| templates::orientation(&syms, p, mask).map(|s| (p, s)))
.ok_or_else(|| Error::Failed(format!("grid cell {key:?} has marks {mask:#010b} that fit no template")))?;
let hexes = canon.iter().find(|(p, _)| *p == pattern).map(|(_, h)| h.as_slice()).unwrap_or(&[]);
let (level, i) = key;
let unit = pow3(self.depth - level);
for h in hexes {
let mut g = h.map(|p| {
let q = sym.apply(p);
[0, 1, 2].map(|k| (6 * i[k] + q[k]) * unit)
});
if templates::corner0_orientation(&g) < 0 {
g = templates::reversed(g);
}
let mut ids = [0u32; 8];
for (id, p) in ids.iter_mut().zip(g) {
*id = match index.get(&p) {
Some(&v) => v,
None => {
let v = u32::try_from(out.points.len()).map_err(|_| Error::Limit("too many grid points".into()))?;
index.insert(p, v);
out.points.push(p);
v
}
};
}
out.hexes.push(ids);
out.level.push(level);
}
}
Ok(out)
}
}
/// A conforming hexahedral mesh on the integer lattice.
#[derive(Debug, Clone, Default)]
pub(crate) struct LatticeMesh {
pub points: Vec<[i64; 3]>,
pub hexes: Vec<[u32; 8]>,
/// The tree level of the leaf each hexahedron came from.
pub level: Vec<u8>,
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::quality::{Validity, element_validity};
use fieldcraft_model::ElementKind;
/// A tiny deterministic generator for test trees.
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
self.0 >> 33
}
}
fn check_conforming(tree: &Tree) {
let m = tree.mesh(&|_| false).unwrap();
let mut faces: HashMap<[u32; 4], usize> = HashMap::new();
let mut volume = 0.0;
for h in &m.hexes {
let xyz: Vec<[f64; 3]> = h.iter().map(|&v| m.points[v as usize].map(|c| c as f64)).collect();
let c = element_validity(ElementKind::Hex8, &xyz).unwrap();
assert_eq!(c.validity, Validity::Valid);
volume += c.volume;
for f in ElementKind::Hex8.faces() {
let mut q = [h[f[0]], h[f[1]], h[f[2]], h[f[3]]];
q.sort();
*faces.entry(q).or_default() += 1;
}
}
let side = tree.span(0) as f64;
let expected = side.powi(3) * (tree.roots[0] * tree.roots[1] * tree.roots[2]) as f64;
assert!((volume - expected).abs() < 1e-6 * expected, "volume {volume} vs {expected}");
let hi = tree.roots.map(|n| n * tree.span(0));
for (q, n) in faces {
let on_box = (0..3).any(|k| q.iter().all(|&v| m.points[v as usize][k] == 0) || q.iter().all(|&v| m.points[v as usize][k] == hi[k]));
assert_eq!(n, if on_box { 1 } else { 2 }, "face {q:?}");
}
}
#[test]
fn random_trees_close_into_conforming_valid_meshes() {
let mut rng = Lcg(7);
for case in 0..12 {
let roots = [1 + (rng.next() % 2) as i64, 1 + (rng.next() % 2) as i64, 1];
let mut tree = Tree::new(roots, 3, 1_000_000).unwrap();
for _ in 0..(1 + rng.next() % 6) {
let leaves = tree.sorted_leaves();
let k = leaves[(rng.next() as usize) % leaves.len()];
if k.0 < 3 {
tree.split(k).unwrap();
}
}
tree.close(100, &|_| false).unwrap();
for k in tree.sorted_leaves() {
assert!(templates::classify(tree.marks(k)).is_some(), "case {case}");
}
check_conforming(&tree);
}
}
#[test]
fn caps_are_errors() {
assert!(Tree::new([0, 1, 1], 2, 10).is_err());
assert!(Tree::new([10, 10, 10], 2, 999).is_err());
let mut t = Tree::new([1, 1, 1], 1, 100).unwrap();
t.split((0, [0, 0, 0])).unwrap();
assert!(t.split((1, [0, 0, 0])).is_err(), "deeper than the depth");
let mut t = Tree::new([1, 1, 1], 3, 30).unwrap();
t.split((0, [0, 0, 0])).unwrap();
assert!(t.split((1, [0, 0, 0])).is_err(), "more cells than allowed");
}
}
+282
View File
@@ -0,0 +1,282 @@
//! The topological map from the boundary's feature graph (corners, curves, patches) onto the
//! core's boundary quads, after X. Gao, H. Shen, D. Panozzo, *Feature preserving octree-based
//! hexahedral meshing*, CGF 38(5), 2019, §3.3 (paper only; the authors' code has no licence and
//! is not read). Gao et al. trace the curves first and read the patches off them; here the
//! patches come first and the curves are their borders, which keeps every region on the side of
//! the part it faces:
//!
//! 1. **Label.** Each quad takes the patch its outward normal meets first (within two quad
//! sizes), or else the patch nearest its centre.
//! 2. **Clean.** A quad whose label differs from all its edge neighbours takes their majority
//! label; then every patch keeps only its largest connected group of quads, the other
//! groups joining the label they border most. Both repeat a bounded number of times.
//! 3. **Check.** The map must be a bijection: every patch has a region, and two regions border
//! each other exactly where the part has a curve between their patches.
//! 4. **Features.** Region borders are the curves (each border edge takes the curve between its
//! two patches nearest to it); a vertex where three or more regions meet is a corner and takes
//! the nearest corner of the part around those patches. Corners that are not region junctions
//! (where one curve turns sharply) go to the nearest vertex on their curve's border.
//!
//! Any failure names where the grid is too coarse for the features; the caller refines there and
//! tries again (§3.3, "Bijective map").
use std::collections::{BTreeMap, BTreeSet};
use super::geom::{Geometry, closest_on_segment, dist2};
use super::smooth::Constraint;
use super::topo::quad_edges;
use crate::vec3::{P, cross, norm, sub};
/// Rounds of label cleaning.
const CLEAN_ROUNDS: usize = 16;
/// Where and why the map failed.
#[derive(Debug, Clone)]
pub(crate) struct MapFailure {
pub at: Vec<P>,
pub why: String,
}
/// The map: a constraint per quad vertex and a patch per quad.
#[derive(Debug, Clone)]
pub(crate) struct FeatureMap {
pub role: BTreeMap<u32, Constraint>,
pub quad_patch: Vec<u32>,
}
/// Map the feature graph onto `quads` (the core's boundary, outward) over `xyz`.
pub(crate) fn map_features(g: &Geometry, quads: &[[u32; 4]], xyz: &dyn Fn(u32) -> P) -> Result<FeatureMap, MapFailure> {
let n = quads.len();
let centre = |q: usize| {
let mut c = [0.0; 3];
for &v in &quads[q] {
let p = xyz(v);
for k in 0..3 {
c[k] += 0.25 * p[k];
}
}
c
};
let edges = quad_edges(quads);
let mut nbr: Vec<Vec<u32>> = vec![Vec::new(); n];
for qs in edges.values() {
if let [a, b] = qs.as_slice() {
nbr[*a as usize].push(*b);
nbr[*b as usize].push(*a);
}
}
// 1. Label: the patch the quad faces (a ray along its outward normal), else the nearest.
let mut label: Vec<u32> = (0..n)
.map(|q| {
let c = centre(q);
let p = quads[q].map(xyz);
let nrm = cross(sub(p[2], p[0]), sub(p[3], p[1]));
let l = norm(nrm);
let reach = 2.0 * (norm(sub(p[2], p[0])) + norm(sub(p[3], p[1])));
let hit = if l > 0.0 { g.ray(c, nrm.map(|x| x / l), reach, u32::MAX).map(|(t, _)| t) } else { None };
hit.or_else(|| g.nearest(c, |_| true).map(|(t, _, _)| t)).and_then(|t| g.tri_patch.get(t as usize).copied()).unwrap_or(0)
})
.collect();
// A patch no quad faces (a narrow face the core's boundary passes by) takes the quad
// nearest to it, which the cleaning keeps.
let mut seeded: BTreeSet<usize> = BTreeSet::new();
let have: BTreeSet<u32> = label.iter().copied().collect();
for p in 0..g.patches.len() as u32 {
if have.contains(&p) {
continue;
}
let best = (0..n)
.filter(|q| !seeded.contains(q))
.filter_map(|q| g.nearest(centre(q), |t| g.tri_patch.get(t as usize) == Some(&p)).map(|x| (q, x.2)))
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
if let Some((q, _)) = best {
label[q] = p;
seeded.insert(q);
}
}
// 2. Clean.
for _ in 0..CLEAN_ROUNDS {
let mut changed = false;
for q in 0..n {
let mut count: BTreeMap<u32, usize> = BTreeMap::new();
for &m in &nbr[q] {
*count.entry(label[m as usize]).or_default() += 1;
}
let same = count.get(&label[q]).copied().unwrap_or(0);
if same == 0
&& !nbr[q].is_empty()
&& !seeded.contains(&q)
&& let Some((&best, _)) = count.iter().filter(|(l, _)| **l != label[q]).max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
{
label[q] = best;
changed = true;
}
}
let mut comp = vec![usize::MAX; n];
let mut comps: Vec<(u32, Vec<usize>)> = Vec::new();
for s in 0..n {
if comp[s] != usize::MAX {
continue;
}
let id = comps.len();
let mut members = vec![s];
comp[s] = id;
let mut i = 0;
while i < members.len() {
let q = members[i];
i += 1;
for &m in &nbr[q] {
let m = m as usize;
if comp[m] == usize::MAX && label[m] == label[s] {
comp[m] = id;
members.push(m);
}
}
}
comps.push((label[s], members));
}
let mut largest: BTreeMap<u32, usize> = BTreeMap::new();
for (i, (l, m)) in comps.iter().enumerate() {
let e = largest.entry(*l).or_insert(i);
if comps[*e].1.len() < m.len() {
*e = i;
}
}
for (i, (l, members)) in comps.iter().enumerate() {
if largest.get(l) == Some(&i) {
continue;
}
let mut count: BTreeMap<u32, usize> = BTreeMap::new();
for &q in members {
for &m in &nbr[q] {
if comp[m as usize] != i {
*count.entry(label[m as usize]).or_default() += 1;
}
}
}
if let Some((&best, _)) = count.iter().max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0))) {
for &q in members {
label[q] = best;
}
changed = true;
}
}
if !changed {
break;
}
}
// 3. Check: every patch has a region; borders match curves.
let present: BTreeSet<u32> = label.iter().copied().collect();
for (p, patch) in g.patches.iter().enumerate() {
if !present.contains(&(p as u32)) {
let at: Vec<P> = patch.triangles.iter().take(64).map(|&t| g.triangle_centre(t)).collect();
return Err(MapFailure { at, why: format!("patch {p} (B-rep faces {:?}) has no region", patch.faces) });
}
}
let mut curves_between: BTreeMap<(u32, u32), Vec<u32>> = BTreeMap::new();
for (i, c) in g.curves.iter().enumerate() {
match c.patches.as_slice() {
[a, b] => curves_between.entry((*a, *b)).or_default().push(i as u32),
[a] => curves_between.entry((*a, *a)).or_default().push(i as u32),
_ => {}
}
}
let mut role: BTreeMap<u32, Constraint> = BTreeMap::new();
let mut border_pairs: BTreeSet<(u32, u32)> = BTreeSet::new();
let mut vertex_labels: BTreeMap<u32, BTreeSet<u32>> = BTreeMap::new();
for (q, quad) in quads.iter().enumerate() {
for &v in quad {
vertex_labels.entry(v).or_default().insert(label[q]);
}
}
for ((a, b), qs) in &edges {
let (q0, q1) = match qs.as_slice() {
[x, y] => (*x as usize, *y as usize),
_ => continue,
};
let (la, lb) = (label[q0].min(label[q1]), label[q0].max(label[q1]));
if la == lb {
continue;
}
let Some(cs) = curves_between.get(&(la, lb)) else {
return Err(MapFailure { at: vec![xyz(*a), xyz(*b)], why: format!("regions of patches {la} and {lb} meet where the part has no curve") });
};
border_pairs.insert((la, lb));
let mid = [0, 1, 2].map(|k| 0.5 * (xyz(*a)[k] + xyz(*b)[k]));
let c = nearest_curve(g, cs, mid);
for v in [*a, *b] {
role.entry(v).or_insert(Constraint::Curve(c));
}
}
for (pair, cs) in &curves_between {
if pair.0 != pair.1 && !border_pairs.contains(pair) {
let at: Vec<P> = cs.iter().flat_map(|&c| g.curves[c as usize].vertices.iter().map(|&v| g.xyz(v)).collect::<Vec<_>>()).collect();
return Err(MapFailure { at, why: format!("the regions of patches {} and {} do not meet along their curve", pair.0, pair.1) });
}
}
// 4. Corners.
let mut taken: BTreeSet<u32> = BTreeSet::new();
let junctions: Vec<u32> = vertex_labels.iter().filter(|(_, l)| l.len() >= 3).map(|(v, _)| *v).collect();
for &v in &junctions {
let p = xyz(v);
let labels = &vertex_labels[&v];
let best = g
.corners
.iter()
.copied()
.filter(|c| !taken.contains(c) && corner_patches(g, *c).is_superset(labels))
.min_by(|&a, &b| dist2(g.xyz(a), p).total_cmp(&dist2(g.xyz(b), p)).then(a.cmp(&b)));
match best {
Some(c) => {
taken.insert(c);
role.insert(v, Constraint::Corner(c));
}
None => return Err(MapFailure { at: vec![p], why: format!("regions {labels:?} meet where the part has no corner") }),
}
}
for &c in &g.corners {
if taken.contains(&c) {
continue;
}
let p = g.xyz(c);
let cp = corner_patches(g, c);
let best = role
.iter()
.filter(|(_, r)| matches!(r, Constraint::Curve(_)))
.map(|(v, _)| *v)
.filter(|v| vertex_labels.get(v).is_some_and(|l| l.is_subset(&cp)))
.min_by(|&a, &b| dist2(xyz(a), p).total_cmp(&dist2(xyz(b), p)).then(a.cmp(&b)));
match best {
Some(v) => {
role.insert(v, Constraint::Corner(c));
}
None => return Err(MapFailure { at: vec![p], why: format!("the feature corner at {p:?} has no vertex") }),
}
}
for (q, quad) in quads.iter().enumerate() {
for &v in quad {
role.entry(v).or_insert(Constraint::Patch(label[q]));
}
}
Ok(FeatureMap { role, quad_patch: label })
}
/// The patches around a corner of the part (on either side of the curves that end there).
fn corner_patches(g: &Geometry, c: u32) -> BTreeSet<u32> {
g.curves.iter().filter(|cv| cv.vertices.first() == Some(&c) || cv.vertices.last() == Some(&c)).flat_map(|cv| cv.patches.iter().copied()).collect()
}
/// The curve of `cs` nearest to `p`.
fn nearest_curve(g: &Geometry, cs: &[u32], p: P) -> u32 {
cs.iter().copied().min_by(|&a, &b| curve_distance(g, a, p).total_cmp(&curve_distance(g, b, p)).then(a.cmp(&b))).unwrap_or(0)
}
fn curve_distance(g: &Geometry, c: u32, p: P) -> f64 {
g.curves.get(c as usize).map_or(f64::INFINITY, |cv| {
cv.vertices.windows(2).map(|w| dist2(p, closest_on_segment(p, g.xyz(w[0]), g.xyz(w[1])).0)).fold(f64::INFINITY, f64::min)
})
}
+801
View File
@@ -0,0 +1,801 @@
//! Unstructured all-hexahedral meshing of any closed boundary: an adaptive grid cut into
//! conforming hexahedra, a core of grid elements inside the part, two boundary layers that carry
//! the part's faces, sharp curves and corners, and an optimisation that fits them to the
//! geometry while keeping every element valid.
//!
//! # Pipeline
//! 1. **Frame.** The grid is aligned with the part: the normals of its two largest planar
//! 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.
//! 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.
//! 4. **Core.** Elements whose eight corners are inside the part (exact ray parity on the
//! boundary triangles) and at least [`OctreeOptions::core_offset`] of their cell size from it.
//! Elements are dropped until the core's boundary is a 2-manifold and every boundary vertex can
//! move outward (its quads' normals lie in one half-space: the buffer rule of H. Tong,
//! E. Halilaj, Y. J. Zhang, *HybridOctree_Hex*, arXiv 2401.05984, 2024); its components and
//! genus must match the part's shells.
//! 5. **Feature map** ([`map`]): the part's patches, sharp curves and corners mapped onto the
//! core's boundary quads (X. Gao, H. Shen, D. Panozzo, *Feature preserving octree-based
//! hexahedral meshing*, CGF 38(5), 2019, §3.3), patches first.
//! 6. **Padding and pillowing** ([`topo`]): one layer of hexahedra on the core's boundary (so no
//! element keeps two faces on the boundary), then each patch's layer is pillowed, which inserts
//! a ring of quads along every sharp curve on both sides: a curve then runs between quads that
//! each have one edge on it, so straight and curved features are captured without degenerate
//! quads (Gao et al. 2019, §3.4, Fig. 9).
//! 7. **Fit** ([`smooth`]): the foldover-free energy of V. Garanzha et al., *Foldover-free maps
//! in 50 lines of code*, ACM TOG 40(4), 2021, plus a fitting term that draws every boundary
//! vertex onto its corner, curve or patch with a growing weight (Gao et al. 2019, §3.5); the
//! barrier keeps every element valid while the boundary moves. Each boundary vertex then goes
//! exactly onto its feature where its elements stay valid; where they would not (a blade, a
//! near-tangent contact) the mesh stays off by [`OctreeReport::boundary_distance`].
//! 8. **Gate.** The worst elements are improved by compass search, then every element is proven
//! valid by the certified Jacobian check (Bernstein bounds, [`crate::quality`]; corner
//! Jacobians alone do not prove a trilinear hexahedron valid, J. Marschner et al., CGF 39(5),
//! 2020); elements that are not are repaired locally first. A failure in 4–8 names where the
//! grid is too coarse; the leaves there are split once more and the mesh is rebuilt, at most
//! [`OctreeOptions::attempts`] times, and a rebuild may not grow the grid more than
//! [`RETRY_GROWTH`] times (Gao et al. 2019, §3.3).
//!
//! # Why 3-refinement templates
//! The dual schemes of M. Livesu, L. Pitzalis, G. Cherchi, *Optimal dual schemes for adaptive
//! grid based hexmeshing* (ACM TOG 41(2), 2022) give a simpler singular structure, but their
//! installation depends on a paired grid whose transition vertices the reference pipeline
//! (L. Pitzalis et al., *Generalized adaptive refinement for grid-based hexahedral meshing*, ACM
//! TOG 40(6), 2021) selects with an integer linear program, and on heuristics that are retried
//! with other parameters when they fail. The node-marking templates need only the balance and
//! closure rules and are proven conforming by an exhaustive test. Their limit is the one
//! Schneiders states: they close convex refined regions only, so a leaf next to a concave corner
//! 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.
//!
//! # Determinism and bounds
//! The same boundary and options give the same mesh: grid cells are processed in sorted order,
//! maps are only looked up, the optimiser sweeps vertices in index order. Grid cells, elements,
//! refinement depth, closure rounds, optimiser sweeps and attempts are capped.
mod geom;
mod grid;
mod map;
mod smooth;
mod templates;
mod topo;
#[cfg(test)]
mod tests;
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use fieldcraft_model::ElementKind;
pub use crate::hex::BoundaryFace;
use crate::hex::face_flux;
use crate::quality::{GateOptions, Metric, Validity, element_quality, validity_gate_checks};
use crate::size::{SizeField, checked_size};
use crate::surface::BoundarySurface;
use crate::vec3::{P, cross, dot, norm, sub};
use crate::{Error, Result};
use geom::{Aabb, Geometry};
use grid::Tree;
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.
pub max_depth: u8,
/// The most grid cells and the most output elements.
pub max_elements: usize,
/// A cell is split where the normals of one B-rep face inside it turn by more than this
/// (degrees).
pub curvature_angle: f64,
/// Levels below the root that curvature may refine (see the module docs).
pub curvature_levels: u8,
/// A cell is split where the part is less than this many cells thick.
pub thickness_cells: f64,
/// Core elements keep this fraction of their cell size from the boundary.
pub core_offset: f64,
/// Optimiser sweeps over all vertices.
pub max_sweeps: usize,
/// Builds, counting the rebuilds after refining where a stage failed (1 = no retry).
pub attempts: usize,
/// Boundary edges whose faces meet at more than this angle (degrees between normals) are
/// sharp features the mesh captures with its own edges; smoother face boundaries are not.
pub sharp_angle: f64,
/// Edges whose normals turn by more than this (degrees: thin blades, near-tangent contacts)
/// are not captured; the mesh rounds them.
pub blade_angle: f64,
}
impl Default for OctreeOptions {
fn default() -> Self {
OctreeOptions {
max_depth: 2,
max_elements: 5_000_000,
curvature_angle: 25.0,
curvature_levels: 1,
thickness_cells: 3.0,
core_offset: 0.3,
max_sweeps: 300,
attempts: 4,
sharp_angle: 30.0,
blade_angle: 160.0,
}
}
}
/// What [`octree_hex_mesh`] did.
#[derive(Debug, Clone, PartialEq)]
pub struct OctreeReport {
/// Builds run (1 + rebuilds after a failure).
pub attempts: usize,
/// Grid leaves of the last build.
pub grid_cells: usize,
/// Core elements (from the grid) and boundary-layer elements.
pub core_elements: usize,
pub layer_elements: usize,
/// The grid's axes.
pub frame: [[f64; 3]; 3],
/// Sharp corners, curves and patches of the boundary.
pub corners: usize,
pub curves: usize,
pub patches: usize,
/// Optimiser sweeps of the last build.
pub sweeps: usize,
/// Smallest and mean corner scaled Jacobian.
pub min_scaled_jacobian: f64,
pub mean_scaled_jacobian: f64,
/// Exact volume of the elements and the boundary's enclosed volume.
pub volume: f64,
pub surface_volume: f64,
/// The largest distance of a boundary vertex from its corner, curve or patch: zero when
/// every boundary vertex lies on the geometry; where the geometry is sharper than valid
/// elements can follow (blades, slits), the mesh stays this far off.
pub boundary_distance: f64,
/// Why earlier builds were refined.
pub retries: Vec<String>,
}
/// An all-hex8 mesh.
#[derive(Debug, Clone, PartialEq)]
pub struct OctreeHexMesh {
pub nodes: Vec<[f64; 3]>,
/// Model node order; every element is proven valid.
pub hexes: Vec<[u32; 8]>,
pub boundary: Vec<BoundaryFace>,
pub report: OctreeReport,
}
/// A rebuild after a failure may have at most this many times the grid cells of the build
/// before…
pub const RETRY_GROWTH: usize = 4;
/// … or this many cells, whichever is more.
const RETRY_MIN_CELLS: usize = 250_000;
/// The fitting weights of the successive optimisation stages.
const FIT_STAGES: &[f64] = &[1e-3, 1e-2, 1e-1, 1.0, 10.0, 100.0];
/// A boundary vertex goes exactly onto its feature only if its elements keep at least this
/// corner scaled Jacobian.
const MIN_SNAP_QUALITY: f64 = 0.05;
/// Elements below this corner scaled Jacobian are improved by compass search.
const IMPROVE_BELOW: f64 = 0.3;
/// 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).
const CORE_ROUNDS: usize = 1000;
/// A failure that refining at `at` may cure.
struct Retry {
at: Vec<P>,
why: String,
}
enum Attempt {
Done(Box<OctreeHexMesh>),
Retry(Retry),
}
/// 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,
/// 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 mut hints: Vec<P> = Vec::new();
let mut retries: Vec<String> = Vec::new();
for attempt in 0..options.attempts {
let cap = if attempt == 0 {
options.max_elements
} else {
options.max_elements.min(RETRY_GROWTH.saturating_mul(ctx.last_leaves.get()).max(RETRY_MIN_CELLS))
};
let built = match ctx.build(&hints, cap) {
Err(Error::Limit(why)) if attempt > 0 => {
retries.push(format!("refining for another build needs more than {cap} grid cells ({why})"));
break;
}
other => other?,
};
match built {
Attempt::Done(mut m) => {
m.report.attempts = attempt + 1;
m.report.retries = retries;
return Ok(*m);
}
Attempt::Retry(r) => {
retries.push(r.why);
hints.extend(r.at);
}
}
}
Err(Error::Failed(format!("octree hex meshing failed after {} builds: {}", retries.len(), retries.join("; "))))
}
fn check_options(o: &OctreeOptions) -> Result<()> {
let ok = o.max_depth <= 8
&& o.curvature_levels <= o.max_depth
&& o.max_elements >= 1
&& o.curvature_angle.is_finite()
&& o.curvature_angle > 0.0
&& o.thickness_cells.is_finite()
&& o.thickness_cells >= 0.0
&& o.core_offset.is_finite()
&& (0.0..1.0).contains(&o.core_offset)
&& (1..=16).contains(&o.attempts)
&& o.sharp_angle.is_finite()
&& o.sharp_angle > 0.0
&& o.blade_angle.is_finite()
&& o.blade_angle > o.sharp_angle
&& o.blade_angle <= 180.0
&& o.max_sweeps <= 100_000;
if ok { Ok(()) } else { Err(Error::Invalid(format!("octree options out of range: {o:?}"))) }
}
fn from_frame(f: &[[f64; 3]; 3], u: P) -> P {
[0, 1, 2].map(|k| u[0] * f[0][k] + u[1] * f[1][k] + u[2] * f[2][k])
}
fn to_frame(f: &[[f64; 3]; 3], p: P) -> P {
[dot(p, f[0]), dot(p, f[1]), dot(p, f[2])]
}
/// 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 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() {
let Some(&t0) = p.triangles.first() else { continue };
let n0 = normal(t0);
if p.triangles.iter().all(|&t| norm(cross(normal(t), n0)) < tol) {
planar.push((p.area, i, n0));
}
}
planar.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
let Some(&(_, _, a0)) = planar.first() else { return identity };
let Some(&(_, _, a1)) = planar.iter().find(|x| dot(x.2, a0).abs() < tol) else { return identity };
let a1 = {
let v = sub(a1, a0.map(|x| x * dot(a1, a0)));
let l = norm(v);
v.map(|x| x / l)
};
let candidate = [a0, a1, cross(a0, a1)];
let score = |f: &[[f64; 3]; 3]| -> f64 {
(0..g.surface.triangles().len() as u32)
.filter(|&t| f.iter().any(|a| norm(cross(normal(t), *a)) < tol))
.map(|t| {
let [a, b, c] = g.triangle(t);
0.5 * norm(cross(sub(b, a), sub(c, a)))
})
.sum()
};
if score(&candidate) > score(&identity) * (1.0 + 1e-9) { candidate } else { identity }
}
/// The distance through the part from each boundary triangle: a ray from its centre along the
/// inward normal to the next triangle (`∞` when none).
fn triangle_thickness(g: &Geometry) -> Vec<f64> {
let far = 2.0 * norm(sub(g.bounds.hi, g.bounds.lo)) + 1.0;
(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)
})
.collect()
}
fn centroid(points: &[P], hx: &[u32; 8]) -> P {
[0, 1, 2].map(|k| hx.iter().map(|&v| points.get(v as usize).map_or(0.0, |p| p[k])).sum::<f64>() / 8.0)
}
fn edge_mean(points: &[P], hx: &[u32; 8]) -> f64 {
let x = hx.map(|v| points.get(v as usize).copied().unwrap_or([0.0; 3]));
let edges = [(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 m = edges.iter().map(|&(a, b)| norm(sub(x[a], x[b]))).sum::<f64>() / 12.0;
if m > 0.0 { m } else { 1.0 }
}
/// The part being meshed and the state carried from one build to the next.
struct Context<'a> {
g: &'a Geometry<'a>,
field: &'a dyn SizeField,
options: &'a OctreeOptions,
frame: [[f64; 3]; 3],
origin: P,
h: f64,
roots: [i64; 3],
thickness: Vec<f64>,
outside_memo: RefCell<HashMap<grid::Key, bool>>,
/// 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>,
}
/// The mesh while its boundary layers are built: points, elements, their target sizes and how
/// each point may move.
struct Work {
points: Vec<P>,
hexes: Vec<[u32; 8]>,
size: Vec<f64>,
role: Vec<Constraint>,
core_count: usize,
}
impl Context<'_> {
fn edge(&self, level: u8) -> f64 {
self.h / 3f64.powi(i32::from(level))
}
/// The world box (inflated by `pad` cell edges) and the centre of a cell.
fn cell_box(&self, (level, i): grid::Key, pad: f64) -> (Aabb, P) {
let e = self.edge(level);
let mut b = Aabb::EMPTY;
for c in 0..8 {
b.add(from_frame(&self.frame, [0, 1, 2].map(|k| self.origin[k] + (i[k] as f64 + ((c >> k) & 1) as f64) * e)));
}
let centre = from_frame(&self.frame, [0, 1, 2].map(|k| self.origin[k] + (i[k] as f64 + 0.5) * e));
let p = pad * e;
(Aabb { lo: b.lo.map(|x| x - p), hi: b.hi.map(|x| x + p) }, centre)
}
fn needs_split(&self, key: grid::Key) -> Result<bool> {
let e = self.edge(key.0);
let (bx, centre) = self.cell_box(key, 0.05);
if e > checked_size(self.field, centre)? * (1.0 + 1e-9) {
return Ok(true);
}
let reach = 0.5 * 3f64.sqrt() * e * 1.05;
let cos = self.options.curvature_angle.to_radians().cos();
let mut first: BTreeMap<u32, P> = BTreeMap::new();
for t in self.g.overlapping(&bx) {
let [a, b, c] = self.g.triangle(t);
if geom::dist2(centre, geom::closest_on_triangle(centre, a, b, c)) > reach * reach {
continue;
}
let n = self.g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
let face = self.g.surface.tri_face().get(t as usize).copied().unwrap_or(0);
let f = *first.entry(face).or_insert(n);
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));
}
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) {
return Ok(true);
}
}
Ok(false)
}
/// Whether a cell lies wholly outside the part (its centre outside and farther from the
/// boundary than its half diagonal): no core element can come from it, so its transition
/// pattern does not matter and it is not meshed. Memoised.
fn outside(&self, key: grid::Key) -> bool {
if let Some(&v) = self.outside_memo.borrow().get(&key) {
return v;
}
let (_, centre) = self.cell_box(key, 0.0);
let half = 0.5 * 3f64.sqrt() * self.edge(key.0);
let far = self.g.nearest(centre, |_| true).is_some_and(|x| x.2 > half * half * (1.0 + 1e-9));
let v = far && self.g.inside(centre) == Some(false);
self.outside_memo.borrow_mut().insert(key, v);
v
}
/// The level-`level` cells within one cell of a hint point.
fn hint_cells(&self, hints: &[P], level: u8) -> HashSet<grid::Key> {
let e = self.edge(level);
let mut out = HashSet::new();
for &p in hints {
let u = to_frame(&self.frame, p);
let i = [0, 1, 2].map(|k| ((u[k] - self.origin[k]) / e).floor());
if !i.iter().all(|x| x.is_finite() && x.abs() < 1e15) {
continue;
}
for d in 0..27i64 {
out.insert((level, [i[0] as i64 + d % 3 - 1, i[1] as i64 + (d / 3) % 3 - 1, i[2] as i64 + d / 9 - 1]));
}
}
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)?;
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)?;
}
}
}
if !hints.is_empty() {
let near: Vec<HashSet<grid::Key>> = (0..self.options.max_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)?;
}
}
tree.close(CLOSURE_ROUNDS, &|k| self.outside(k))?;
Ok(tree)
}
fn build(&self, hints: &[P], max_leaves: usize) -> Result<Attempt> {
let g = self.g;
let tree = self.tree(hints, max_leaves)?;
self.last_leaves.set(tree.leaf_count());
let lattice = tree.mesh(&|k| self.outside(k))?;
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();
// Core.
let dist: Vec<f64> = points.iter().map(|&p| g.nearest(p, |_| true).map_or(0.0, |x| x.2.sqrt())).collect();
let keep = self.core(&lattice, &points, &dist);
let core: Vec<usize> = (0..lattice.hexes.len()).filter(|&i| keep[i]).collect();
if core.is_empty() {
let at = (0..g.surface.triangles().len() as u32).map(|t| g.triangle_centre(t)).collect();
return Ok(Attempt::Retry(Retry { at, why: "no grid element lies inside the part".into() }));
}
let mut w = Work {
hexes: core.iter().map(|&i| lattice.hexes[i]).collect(),
size: core.iter().map(|&i| self.edge(lattice.level[i])).collect(),
role: Vec::new(),
points,
core_count: core.len(),
};
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));
}
// Feature map, padding and pillows.
let fmap = {
let pts = &w.points;
match map::map_features(g, &q, &|v| pts.get(v as usize).copied().unwrap_or([0.0; 3])) {
Ok(m) => m,
Err(f) => return Ok(Attempt::Retry(Retry { at: f.at, why: f.why })),
}
};
self.layers(&mut w, &qb, &fmap, &dist)?;
if w.hexes.len() > self.options.max_elements {
return Err(Error::Limit(format!("the mesh needs {} elements; the limit is {}", w.hexes.len(), self.options.max_elements)));
}
// Fit, then improve and repair.
let mut opt = Optimizer::new(g, std::mem::take(&mut w.points), &w.hexes, &w.size, w.role.clone());
let mut ran = 0;
let sweeps = (self.options.max_sweeps / FIT_STAGES.len()).max(1);
for &weight in FIT_STAGES {
opt.fit = weight;
ran = opt.run(sweeps, 1e-4);
}
let (_, boundary_distance) = opt.snap(MIN_SNAP_QUALITY);
let inverted: Vec<P> = w
.hexes
.iter()
.filter(|hx| smooth::min_scaled_jacobian(&hx.map(|v| opt.points[v as usize])) <= 0.0)
.map(|hx| centroid(&opt.points, hx))
.collect();
if !inverted.is_empty() {
let why = format!("{} elements stay inverted after untangling", inverted.len());
return Ok(Attempt::Retry(Retry { at: inverted, why }));
}
opt.improve_worst(IMPROVE_BELOW, 4);
let uncertified = opt.repair_certified(6);
if !uncertified.is_empty() {
let at: Vec<P> = uncertified.iter().filter_map(|&e| w.hexes.get(e as usize)).map(|hx| centroid(&opt.points, hx)).collect();
let why = format!("{} elements fail the certified validity check", at.len());
return Ok(Attempt::Retry(Retry { at, why }));
}
w.points = opt.points;
self.finish(&w, &tree, ran, boundary_distance)
}
/// The core: elements wholly inside the part, cleaned to a 2-manifold boundary whose
/// vertices can all move outward.
fn core(&self, lattice: &grid::LatticeMesh, points: &[P], dist: &[f64]) -> Vec<bool> {
let inside: Vec<bool> = points.iter().map(|&p| self.g.inside(p) == Some(true)).collect();
let mut keep: Vec<bool> = lattice
.hexes
.iter()
.zip(&lattice.level)
.map(|(hx, &l)| {
let floor = self.options.core_offset * self.edge(l);
hx.iter().all(|&v| inside.get(v as usize) == Some(&true) && dist.get(v as usize).is_some_and(|&d| d >= floor))
})
.collect();
for _ in 0..CORE_ROUNDS {
let mut drop = topo::nonmanifold(&lattice.hexes, &keep);
if drop.is_empty() {
drop = topo::hidden_vertices(&lattice.hexes, &keep, &|v| points.get(v as usize).copied().unwrap_or([0.0; 3]), 0.1);
}
if drop.is_empty() {
break;
}
for d in drop {
if let Some(k) = keep.get_mut(d as usize) {
*k = false;
}
}
}
keep
}
/// The boundary layers: padding on the core's boundary quads `qb`, whose outer copies take the
/// map's roles and start halfway to the boundary along the core's normals, then a pillow
/// under each patch, whose copies start a quarter of the way into their elements. Every
/// element starts valid or nearly so; the fit untangles the rest.
fn layers(&self, w: &mut Work, qb: &[(topo::FaceRef, [u32; 4])], fmap: &map::FeatureMap, dist: &[f64]) -> Result<()> {
let q: Vec<[u32; 4]> = qb.iter().map(|x| x.1).collect();
let core_count = w.core_count;
let copy = topo::pad(&mut w.points, &mut w.hexes, &q)?;
w.role = vec![Constraint::Free; w.points.len()];
let mut normal: BTreeMap<u32, P> = BTreeMap::new();
for f in &q {
let p = f.map(|v| w.points[v as usize]);
let n = cross(sub(p[2], p[0]), sub(p[3], p[1]));
let l = norm(n);
if l > 0.0 {
for &v in f {
let e = normal.entry(v).or_insert([0.0; 3]);
for k in 0..3 {
e[k] += n[k] / l;
}
}
}
}
for (&v, &c) in &copy {
if let Some(r) = fmap.role.get(&v) {
w.role[c as usize] = *r;
}
let n = normal.get(&v).copied().unwrap_or([0.0; 3]);
let l = norm(n);
let d = 0.5 * dist.get(v as usize).copied().unwrap_or(0.0);
if l > 0.0 {
w.points[c as usize] = [0, 1, 2].map(|k| w.points[v as usize][k] + d * n[k] / l);
}
}
for ((owner, _), _) in qb {
w.size.push(0.5 * w.size.get(*owner as usize).copied().unwrap_or(self.h));
}
let patches: BTreeSet<u32> = fmap.quad_patch.iter().copied().collect();
for p in patches {
let inside: Vec<bool> =
(0..w.hexes.len()).map(|i| i >= core_count && i < core_count + q.len() && fmap.quad_patch.get(i - core_count) == Some(&p)).collect();
let before = w.hexes.len();
let centroids: Vec<P> = w.hexes.iter().map(|hx| centroid(&w.points, hx)).collect();
let copies = topo::pillow(&mut w.points, &mut w.hexes, &inside)?;
w.role.resize(w.points.len(), Constraint::Free);
for pc in &copies {
let orig = w.points[pc.original as usize];
let n = pc.elements.len().max(1) as f64;
let mut c = [0.0; 3];
for &e in &pc.elements {
let ce = centroids.get(e as usize).copied().unwrap_or(orig);
for k in 0..3 {
c[k] += ce[k] / n;
}
}
w.points[pc.copy as usize] = [0, 1, 2].map(|k| orig[k] + 0.25 * (c[k] - orig[k]));
w.role[pc.copy as usize] = match w.role.get(pc.original as usize).copied().unwrap_or(Constraint::Free) {
Constraint::Free => Constraint::Free,
_ => Constraint::Patch(p),
};
}
for hx in &w.hexes[before..] {
w.size.push(edge_mean(&w.points, hx));
}
}
Ok(())
}
/// The gate and the output: every element proven valid, nodes renumbered, boundary faces with
/// their B-rep faces.
fn finish(&self, w: &Work, tree: &Tree, sweeps: usize, boundary_distance: f64) -> Result<Attempt> {
let g = self.g;
// One certified pass: every element's quality (the same metric as mesh.quality and
// mesh.hex.generate report) and its Jacobian check for the gate.
let mut bad: Vec<P> = Vec::new();
let mut checks = Vec::with_capacity(w.hexes.len());
let (mut sj_min, mut sj_sum) = (f64::INFINITY, 0.0);
for (e, hx) in w.hexes.iter().enumerate() {
let q = element_quality(ElementKind::Hex8, &hx.map(|v| w.points[v as usize]))?;
let check = q.jacobian.ok_or_else(|| Error::Failed("an element was not certified".into()))?;
if check.validity != Validity::Valid {
bad.push(centroid(&w.points, hx));
}
let sj = q.get(Metric::ScaledJacobian).unwrap_or(-1.0);
sj_min = sj_min.min(sj);
sj_sum += sj;
checks.push((e as u32 + 1, check));
}
if !bad.is_empty() {
let why = format!("{} elements fail the certified validity check", bad.len());
return Ok(Attempt::Retry(Retry { at: bad, why }));
}
// The volume the mesh's own boundary encloses equals the elements' total exactly when they
// tile it without overlapping.
let faces = topo::boundary(&w.hexes);
let enclosed: f64 = faces.iter().map(|(_, quad)| face_flux(&quad.map(|v| w.points[v as usize]))).sum();
let gate =
validity_gate_checks(checks.iter().map(|(l, c)| (*l, c)), &GateOptions { expected_volume: Some(enclosed), volume_tolerance: 1e-9 })?
.into_result()?;
let volume = gate.volume;
let mut new_id = vec![u32::MAX; w.points.len()];
let mut nodes = Vec::new();
let mut hexes = Vec::with_capacity(w.hexes.len());
for hx in &w.hexes {
let mut o = [0u32; 8];
for (k, &v) in hx.iter().enumerate() {
if new_id[v as usize] == u32::MAX {
new_id[v as usize] = u32::try_from(nodes.len()).map_err(|_| Error::Limit("too many nodes".into()))?;
nodes.push(w.points[v as usize]);
}
o[k] = new_id[v as usize];
}
hexes.push(o);
}
let mut boundary = Vec::new();
for ((e, f), quad) in faces {
let patch = quad.iter().find_map(|&v| match w.role.get(v as usize) {
Some(Constraint::Patch(p)) => Some(*p),
_ => None,
});
let c = [0, 1, 2].map(|k| quad.iter().map(|&v| w.points[v as usize][k]).sum::<f64>() / 4.0);
let brep_face = match patch.and_then(|p| g.patches.get(p as usize)) {
Some(pt) if pt.faces.len() == 1 => pt.faces[0],
_ => {
let keep = |t: u32| patch.is_none_or(|p| g.tri_patch.get(t as usize) == Some(&p));
g.nearest(c, keep).and_then(|(t, _, _)| g.surface.tri_face().get(t as usize).copied()).unwrap_or(0)
}
};
boundary.push(BoundaryFace { element: e, face: f + 1, brep_face });
}
let report = OctreeReport {
attempts: 0,
grid_cells: tree.leaf_count(),
core_elements: w.core_count,
layer_elements: w.hexes.len() - w.core_count,
frame: self.frame,
corners: g.corners.len(),
curves: g.curves.len(),
patches: g.patches.len(),
sweeps,
min_scaled_jacobian: sj_min,
mean_scaled_jacobian: sj_sum / w.hexes.len().max(1) as f64,
volume,
surface_volume: g.surface.volume(),
boundary_distance,
retries: Vec::new(),
};
Ok(Attempt::Done(Box::new(OctreeHexMesh { nodes, hexes, boundary, report })))
}
}
/// The core boundary's components and genus against the part's shells; on a mismatch, a retry
/// that refines near the whole boundary.
fn check_topology(g: &Geometry, quads: &[[u32; 4]]) -> Option<Retry> {
let edges = topo::quad_edges(quads);
let mut comp: Vec<usize> = (0..quads.len()).collect();
for qs in edges.values() {
for w in qs.windows(2) {
let (a, b) = (topo::find(&mut comp, w[0] as usize), topo::find(&mut comp, w[1] as usize));
comp[a] = b;
}
}
// Per component: vertices, edges, faces.
let mut stats: BTreeMap<usize, (BTreeSet<u32>, i64, i64)> = BTreeMap::new();
for (i, q) in quads.iter().enumerate() {
let s = stats.entry(topo::find(&mut comp, i)).or_default();
s.2 += 1;
s.0.extend(q.iter().copied());
}
for qs in edges.values() {
if let Some(&q0) = qs.first()
&& let Some(s) = stats.get_mut(&topo::find(&mut comp, q0 as usize))
{
s.1 += 1;
}
}
let mut got: Vec<i64> = stats.values().map(|s| (2 - (s.0.len() as i64 - s.1 + s.2)) / 2).collect();
let mut want: Vec<i64> = g.surface.shells().iter().map(|s| i64::from(s.genus)).collect();
got.sort_unstable();
want.sort_unstable();
if got == want {
return None;
}
let at = (0..g.surface.triangles().len() as u32).map(|t| g.triangle_centre(t)).collect();
Some(Retry { at, why: format!("the core's boundary has components of genus {got:?}, the part {want:?}") })
}
+641
View File
@@ -0,0 +1,641 @@
//! Untangling, fitting and shape optimisation of the hexahedral mesh.
//!
//! # Energy
//! The foldover-free mapping energy of V. Garanzha, I. Kaporin, L. Kudryavtseva, F. Protais,
//! N. Ray, D. Sokolov, *Foldover-free maps in 50 lines of code*, ACM TOG 40(4):102, 2021 (paper
//! only; the authors' code is AGPL and is not read). Each hexahedron is measured at nine points,
//! its eight corners (the trapezoidal rule the paper uses for quads) and its centre, where a
//! twisted element folds first: `J` is the sample's three edge directions against the ideal cube
//! of the element's target edge `s`, `D = det J`, and the sample costs
//!
//! `f_ε + λ g_ε`, `f_ε = |J|³_F / χ(D, ε)`, `g_ε = (D² + 1) / χ(D, ε)`,
//! `χ(D, ε) = (D + √(ε² + D²)) / 2`,
//!
//! weighted by `s³`. `f` is the shape (conformal) term, `g` the volume term. The paper's shape
//! term is `|J|²/D^{2/3}`; its 3/2 power `|J|³/D`, the classical polyconvex distortion in three
//! dimensions (J. M. Ball, 1977), is minimal at the same ideal shapes and needs only square
//! roots, so the optimiser gives the same bits on every platform (no `pow` of the system maths
//! library). `χ` keeps both finite for inverted samples, and `ε` follows the paper's rule (7),
//! `ε = √(10⁻¹² + 0.04 min(0, min D)²)`, so it vanishes once every sample is positive and the
//! energy becomes a barrier against inversion.
//!
//! A vertex on a corner, curve or patch adds `fit · 8 s̄ · dist²` (`s̄` the mean target edge of its
//! elements) to its distance from that feature: with `fit` raised stage by stage the boundary is
//! drawn onto the geometry while the barrier keeps every element valid (Gao, Shen, Panozzo 2019,
//! §3.5). With `fit = 0` constrained vertices are held on their features instead (each step is
//! restricted to the tangent and projected back).
//!
//! # Minimisation
//! Nonlinear Gauss–Seidel: each vertex in turn takes a Newton step on its own energy with the
//! paper's positive approximation of the Hessian (the second derivatives of `χ` and `det` are
//! dropped, §2.3, App. A), accepted only if the energy decreased (halving line search). The paper
//! minimises the same energy globally with L-BFGS; the vertex sweep is the local form of the same
//! descent. Then a compass search raises the worst corner scaled Jacobians, and another repairs
//! elements whose trilinear map the certified check does not prove positive. Sweeps, line-search
//! steps, compass steps and rounds are capped.
use fieldcraft_model::ElementKind;
use crate::quality::{Validity, element_validity};
use crate::vec3::{P, cross, dot, norm, sub};
use super::geom::dist2;
use super::geom::Geometry;
/// How a vertex may move.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Constraint {
Free,
/// On a corner of the part (the boundary surface's vertex).
Corner(u32),
/// On a feature curve.
Curve(u32),
/// On a patch of the boundary.
Patch(u32),
}
/// Model-order corner `c` of a hex8: its three neighbours along x, y, z and the signs that make
/// the reference corner frame right-handed.
const CORNER_FRAMES: [([usize; 3], [f64; 3]); 8] = [
([1, 3, 4], [1.0, 1.0, 1.0]),
([0, 2, 5], [-1.0, 1.0, 1.0]),
([3, 1, 6], [-1.0, -1.0, 1.0]),
([2, 0, 7], [1.0, -1.0, 1.0]),
([5, 7, 0], [1.0, 1.0, -1.0]),
([4, 6, 1], [-1.0, 1.0, -1.0]),
([7, 5, 2], [-1.0, -1.0, -1.0]),
([6, 4, 3], [1.0, -1.0, -1.0]),
];
/// Weight of the volume term.
const LAMBDA: f64 = 0.1;
fn chi(d: f64, eps: f64) -> f64 {
0.5 * (d + (eps * eps + d * d).sqrt())
}
fn chi_d(d: f64, eps: f64) -> f64 {
0.5 * (1.0 + d / (eps * eps + d * d).sqrt())
}
/// The corner Jacobian (columns) of corner `c` of `x` against an ideal cube of edge `s`.
fn corner_jacobian(x: &[P; 8], c: usize, s: f64) -> [P; 3] {
let (n, sg) = CORNER_FRAMES[c];
[0, 1, 2].map(|a| sub(x[n[a]], x[c]).map(|v| v * sg[a] / s))
}
/// The sample points of the energy: the eight corners and the centre. Column `a` of sample `s`'s
/// Jacobian is `Σ_k W[s][a][k] x_k` (before dividing by the target edge). The centre keeps
/// twisted elements (positive corners, folded inside) out of reach, as the nine-point measure of
/// ParaView and of Zhang et al.'s meshers does.
const SAMPLES: usize = 9;
fn sample_weights(s: usize) -> [[f64; 8]; 3] {
let mut w = [[0.0; 8]; 3];
if s < 8 {
let (n, sg) = CORNER_FRAMES[s];
for a in 0..3 {
w[a][n[a]] += sg[a];
w[a][s] -= sg[a];
}
} else {
w = [
[-0.25, 0.25, 0.25, -0.25, -0.25, 0.25, 0.25, -0.25],
[-0.25, -0.25, 0.25, 0.25, -0.25, -0.25, 0.25, 0.25],
[-0.25, -0.25, -0.25, -0.25, 0.25, 0.25, 0.25, 0.25],
];
}
w
}
/// The Jacobian (columns) of sample `smp` of `x` against an ideal cube of edge `s`.
fn sample_jacobian(x: &[P; 8], smp: usize, s: f64) -> [P; 3] {
let w = sample_weights(smp);
[0, 1, 2].map(|a| {
let mut c = [0.0; 3];
for (k, p) in x.iter().enumerate() {
for r in 0..3 {
c[r] += w[a][k] * p[r];
}
}
c.map(|v| v / s)
})
}
fn det3(j: &[P; 3]) -> f64 {
dot(j[0], cross(j[1], j[2]))
}
/// The cofactor columns: `∂ det / ∂ column a`.
fn cofactors(j: &[P; 3]) -> [P; 3] {
[cross(j[1], j[2]), cross(j[2], j[0]), cross(j[0], j[1])]
}
/// The energy of one corner.
fn corner_energy(j: &[P; 3], eps: f64) -> f64 {
let d = det3(j);
let c = chi(d, eps);
let fro: f64 = j.iter().map(|v| dot(*v, *v)).sum();
fro * fro.sqrt() / c + LAMBDA * (d * d + 1.0) / c
}
/// The scaled Jacobian of each corner, smallest (`min over corners of det / product of edge
/// lengths`), the usual hex quality measure.
pub(crate) fn min_scaled_jacobian(x: &[P; 8]) -> f64 {
(0..8)
.map(|c| {
let j = corner_jacobian(x, c, 1.0);
let l = norm(j[0]) * norm(j[1]) * norm(j[2]);
if l > 0.0 { det3(&j) / l } else { -1.0 }
})
.fold(f64::INFINITY, f64::min)
}
/// The optimiser's state.
pub(crate) struct Optimizer<'a> {
pub geom: &'a Geometry<'a>,
pub points: Vec<P>,
pub hexes: &'a [[u32; 8]],
/// Target edge per element.
pub size: &'a [f64],
pub constraint: Vec<Constraint>,
/// Elements around each vertex with the vertex's local position.
around: Vec<Vec<(u32, u8)>>,
pub eps: f64,
/// Weight of the fitting term; 0 holds boundary vertices on their features exactly.
pub fit: f64,
}
impl<'a> Optimizer<'a> {
pub fn new(geom: &'a Geometry<'a>, points: Vec<P>, hexes: &'a [[u32; 8]], size: &'a [f64], constraint: Vec<Constraint>) -> Optimizer<'a> {
let mut around: Vec<Vec<(u32, u8)>> = vec![Vec::new(); points.len()];
for (e, h) in hexes.iter().enumerate() {
for (k, &v) in h.iter().enumerate() {
if let Some(a) = around.get_mut(v as usize) {
a.push((e as u32, k as u8));
}
}
}
Optimizer { geom, points, hexes, size, constraint, around, eps: 1.0, fit: 0.0 }
}
fn element(&self, e: u32) -> ([P; 8], f64) {
let h = self.hexes.get(e as usize).copied().unwrap_or([0; 8]);
(h.map(|v| self.points.get(v as usize).copied().unwrap_or([0.0; 3])), self.size.get(e as usize).copied().unwrap_or(1.0))
}
/// The smallest normalised corner determinant over the mesh, and the total energy.
pub fn measure(&self) -> (f64, f64) {
let (mut m, mut total) = (f64::INFINITY, 0.0);
for e in 0..self.hexes.len() as u32 {
let (x, s) = self.element(e);
for c in 0..SAMPLES {
let j = sample_jacobian(&x, c, s);
m = m.min(det3(&j));
total += s * s * s * corner_energy(&j, self.eps);
}
}
(m, total)
}
/// Where vertex `v`'s feature is closest to `p` (`None` for a free vertex).
pub fn target(&self, v: u32, p: P) -> Option<P> {
match self.constraint.get(v as usize).copied().unwrap_or(Constraint::Free) {
Constraint::Free => None,
Constraint::Corner(c) => Some(self.geom.xyz(c)),
Constraint::Curve(c) => self.geom.nearest_on_curve(c, p).map(|x| x.0),
Constraint::Patch(pt) => self.geom.nearest(p, |t| self.geom.tri_patch.get(t as usize) == Some(&pt)).map(|x| x.1),
}
}
/// The fitting weight of vertex `v`: the fit factor times its elements' size (so a distance
/// of one element weighs like the shape of its corners), ten times more for a corner.
fn fit_weight(&self, v: u32) -> f64 {
let a = self.around.get(v as usize).map_or(&[][..], |a| a.as_slice());
if a.is_empty() || self.fit == 0.0 {
return 0.0;
}
let s = a.iter().map(|&(e, _)| self.size.get(e as usize).copied().unwrap_or(1.0)).sum::<f64>() / a.len() as f64;
let corner = if matches!(self.constraint.get(v as usize), Some(Constraint::Corner(_))) { 10.0 } else { 1.0 };
8.0 * self.fit * corner * s
}
/// The energy of the corners that involve vertex `v`, with its fitting term.
fn local_energy(&self, v: u32) -> f64 {
let mut total = 0.0;
if self.fit > 0.0 {
let p = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
if let Some(t) = self.target(v, p) {
total += self.fit_weight(v) * dist2(p, t);
}
}
for &(e, k) in self.around.get(v as usize).map_or(&[][..], |a| a.as_slice()) {
let (x, s) = self.element(e);
let (n, _) = CORNER_FRAMES[k as usize];
for c in [k as usize, n[0], n[1], n[2], 8] {
total += s * s * s * corner_energy(&sample_jacobian(&x, c, s), self.eps);
}
}
total
}
/// The gradient and approximate Hessian of the local energy at vertex `v`.
fn derivatives(&self, v: u32) -> (P, [[f64; 3]; 3]) {
let mut g = [0.0; 3];
let mut h = [[0.0; 3]; 3];
for &(e, k) in self.around.get(v as usize).map_or(&[][..], |a| a.as_slice()) {
let (x, s) = self.element(e);
let k = k as usize;
let w = s * s * s;
let (nk, _) = CORNER_FRAMES[k];
for c in [k, nk[0], nk[1], nk[2], 8] {
// ∂ column a / ∂ x_v = coef[a] I.
let wt = sample_weights(c);
let coef: [f64; 3] = [0, 1, 2].map(|a| wt[a][k] / s);
let j = sample_jacobian(&x, c, s);
let d = det3(&j);
let ch = chi(d, self.eps);
let chd = chi_d(d, self.eps);
let fro: f64 = j.iter().map(|q| dot(*q, *q)).sum();
let cof = cofactors(&j);
let root = fro.sqrt();
// dE/dJ = A J + B cof(J).
let a_coef = 3.0 * root / ch;
let f_d = -fro * root * chd / (ch * ch);
let g_d = 2.0 * d / ch - (d * d + 1.0) * chd / (ch * ch);
let b_coef = f_d + LAMBDA * g_d;
let mut dd = [0.0; 3];
let mut sum_c2 = 0.0;
for a in 0..3 {
if coef[a] == 0.0 {
continue;
}
sum_c2 += coef[a] * coef[a];
for r in 0..3 {
g[r] += w * coef[a] * (a_coef * j[a][r] + b_coef * cof[a][r]);
dd[r] += coef[a] * cof[a][r];
}
}
// Positive part of the second derivative along D, χ'' and det'' dropped.
let f_dd = 2.0 * fro * root * chd * chd / (ch * ch * ch);
let g_dd = 2.0 / ch - 4.0 * d * chd / (ch * ch) + 2.0 * (d * d + 1.0) * chd * chd / (ch * ch * ch);
let k_dd = (f_dd + LAMBDA * g_dd).max(0.0);
for r in 0..3 {
h[r][r] += w * a_coef * sum_c2;
for q in 0..3 {
h[r][q] += w * k_dd * dd[r] * dd[q];
}
}
}
}
if self.fit > 0.0 {
let p = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
if let Some(t) = self.target(v, p) {
let w = self.fit_weight(v);
for r in 0..3 {
g[r] += 2.0 * w * (p[r] - t[r]);
h[r][r] += 2.0 * w;
}
}
}
(g, h)
}
/// Project a point onto vertex `v`'s constraint.
fn project(&self, v: u32, p: P) -> P {
self.target(v, p).unwrap_or(p)
}
/// The direction a constrained vertex may move along: the step restricted to its tangent.
fn restrict(&self, v: u32, d: P) -> P {
let p = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
match self.constraint.get(v as usize).copied().unwrap_or(Constraint::Free) {
Constraint::Free => d,
Constraint::Corner(_) => [0.0; 3],
Constraint::Curve(c) => {
let Some((_, i, _)) = self.geom.nearest_on_curve(c, p) else { return [0.0; 3] };
let Some(cv) = self.geom.curves.get(c as usize) else { return [0.0; 3] };
let (a, b) = (cv.vertices.get(i).copied().unwrap_or(0), cv.vertices.get(i + 1).copied().unwrap_or(0));
let t = sub(self.geom.xyz(b), self.geom.xyz(a));
let l2 = dot(t, t);
if l2 > 0.0 { t.map(|x| x * dot(d, t) / l2) } else { [0.0; 3] }
}
Constraint::Patch(pt) => {
let Some((t, _, _)) = self.geom.nearest(p, |t| self.geom.tri_patch.get(t as usize) == Some(&pt)) else { return [0.0; 3] };
let n = self.geom.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
let dn = dot(d, n);
[0, 1, 2].map(|k| d[k] - dn * n[k])
}
}
}
/// One Newton step of vertex `v`; whether it moved.
fn relax(&mut self, v: u32) -> bool {
let soft = self.fit > 0.0;
if (!soft && matches!(self.constraint.get(v as usize), Some(Constraint::Corner(_))))
|| self.around.get(v as usize).is_none_or(|a| a.is_empty())
{
return false;
}
let (g, h) = self.derivatives(v);
let Some(step) = solve3(h, g.map(|x| -x)) else { return false };
let step = if soft { step } else { self.restrict(v, step) };
if !step.iter().all(|x| x.is_finite()) || norm(step) == 0.0 {
return false;
}
let old = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
let e0 = self.local_energy(v);
let mut tau = 1.0;
for _ in 0..12 {
let moved = [0, 1, 2].map(|k| old[k] + tau * step[k]);
let trial = if soft { moved } else { self.project(v, moved) };
if let Some(p) = self.points.get_mut(v as usize) {
*p = trial;
}
let e1 = self.local_energy(v);
if e1 < e0 && e1.is_finite() {
return true;
}
tau *= 0.5;
}
if let Some(p) = self.points.get_mut(v as usize) {
*p = old;
}
false
}
/// Set the regularisation from the smallest corner determinant (Garanzha et al. 2021: ε
/// shrinks as the mesh untangles); that determinant and the energy at the new ε.
fn update_eps(&mut self) -> (f64, f64) {
let (min_d, energy) = self.measure();
let old = self.eps;
self.eps = (1e-12 + 0.04 * min_d.min(0.0).powi(2)).sqrt();
(min_d, if self.eps == old { energy } else { self.measure().1 })
}
/// Sweep all vertices until every corner is positive and the energy settles, or `max_sweeps`;
/// the sweeps run.
pub fn run(&mut self, max_sweeps: usize, tolerance: f64) -> usize {
let n = self.points.len() as u32;
let (_, mut energy) = self.update_eps();
let mut sweeps = 0;
for sweep in 0..max_sweeps {
let mut moved = 0usize;
for v in 0..n {
if self.relax(v) {
moved += 1;
}
}
let old_eps = self.eps;
let (min_d, e) = self.update_eps();
sweeps = sweep + 1;
let settled = old_eps == self.eps && (energy - e).abs() <= tolerance * energy.abs();
energy = e;
if moved == 0 || (min_d > 0.0 && settled) {
break;
}
}
sweeps
}
}
impl Optimizer<'_> {
/// The smallest corner scaled Jacobian of the elements around vertex `v`.
fn local_quality(&self, v: u32) -> f64 {
self.around
.get(v as usize)
.map_or(f64::INFINITY, |a| a.iter().map(|&(e, _)| min_scaled_jacobian(&self.element(e).0)).fold(f64::INFINITY, f64::min))
}
/// Move every constrained vertex exactly onto its feature where its elements keep a corner
/// scaled Jacobian of at least `floor`; the others stay where the fit left them. Returns
/// how many stayed off and the largest distance of a constrained vertex from its feature.
pub fn snap(&mut self, floor: f64) -> (usize, f64) {
let (mut off, mut worst) = (0usize, 0.0f64);
for v in 0..self.points.len() as u32 {
let p = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
let Some(t) = self.target(v, p) else { continue };
if p == t {
continue;
}
if let Some(x) = self.points.get_mut(v as usize) {
*x = t;
}
if self.local_quality(v) < floor {
if let Some(x) = self.points.get_mut(v as usize) {
*x = p;
}
off += 1;
worst = worst.max(dist2(p, t).sqrt());
}
}
(off, worst)
}
/// Raise the worst elements: every vertex of an element whose corner scaled Jacobian is
/// below `threshold` moves, within its constraint, by a compass search that maximises the
/// smallest scaled Jacobian around it (steps shrink by halves; at most `passes` rounds over
/// the worst elements, 12 step sizes per vertex).
pub fn improve_worst(&mut self, threshold: f64, passes: usize) {
for _ in 0..passes {
let mut verts: Vec<u32> = Vec::new();
for (e, h) in self.hexes.iter().enumerate() {
if min_scaled_jacobian(&self.element(e as u32).0) < threshold {
verts.extend(h.iter().copied());
}
}
verts.sort_unstable();
verts.dedup();
if verts.is_empty() {
break;
}
let mut moved = false;
for &v in &verts {
if matches!(self.constraint.get(v as usize), Some(Constraint::Free)) {
moved |= self.compass(v, &|o: &Self, v| o.local_quality(v));
}
}
if !moved {
break;
}
}
}
/// Compass search on vertex `v` (within its constraint) for the largest `quality`; steps
/// start at 0.15 of its largest element size and halve when no direction improves, 12 times
/// at most. Whether it moved.
fn compass(&mut self, v: u32, quality: &dyn Fn(&Self, u32) -> f64) -> bool {
let p0 = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
let reach = self
.around
.get(v as usize)
.map_or(0.0, |a| a.iter().map(|&(e, _)| self.size.get(e as usize).copied().unwrap_or(0.0)).fold(0.0, f64::max));
let mut step = 0.15 * reach;
let mut best = quality(self, v);
let mut cur = p0;
let mut moved = false;
for _ in 0..12 {
if step <= 1e-9 * reach.max(1e-300) {
break;
}
let mut improved = false;
for d in [[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, -1.0]] {
let dir = self.restrict(v, d.map(|x| x * step));
if norm(dir) == 0.0 {
continue;
}
let trial = self.project(v, [0, 1, 2].map(|k| cur[k] + dir[k]));
if let Some(p) = self.points.get_mut(v as usize) {
*p = trial;
}
let q = quality(self, v);
if q > best + 1e-12 {
best = q;
cur = trial;
improved = true;
moved = true;
} else if let Some(p) = self.points.get_mut(v as usize) {
*p = cur;
}
}
if !improved {
step *= 0.5;
}
}
if let Some(p) = self.points.get_mut(v as usize) {
*p = cur;
}
moved
}
/// The smallest certified Jacobian ratio of the elements around `v` (negative where the
/// trilinear map folds somewhere, even with positive corners).
fn certified_quality(&self, v: u32) -> f64 {
self.around.get(v as usize).map_or(f64::INFINITY, |a| {
a.iter()
.map(|&(e, _)| {
let x = self.element(e).0;
if min_scaled_jacobian(&x) <= 0.0 {
return -1e9;
}
element_validity(ElementKind::Hex8, &x).map_or(-1.0, |c| match c.validity {
Validity::Valid => c.ratio.max(1e-300),
_ => c.ratio.min(0.0) - 1e-12,
})
})
.fold(f64::INFINITY, f64::min)
})
}
/// Repair elements whose trilinear map is not proven positive although their corners are:
/// the vertices of each such element (free ones, and boundary ones along their features)
/// move by compass search to raise the smallest certified bound around them; `rounds` rounds
/// at most. Returns the elements still not proven valid.
pub fn repair_certified(&mut self, rounds: usize) -> Vec<u32> {
let check = |o: &Self, e: u32| element_validity(ElementKind::Hex8, &o.element(e).0).is_ok_and(|c| c.validity == Validity::Valid);
let mut bad: Vec<u32> = (0..self.hexes.len() as u32).filter(|&e| !check(self, e)).collect();
for _ in 0..rounds {
if bad.is_empty() {
break;
}
let mut verts: Vec<u32> = bad.iter().flat_map(|&e| self.hexes.get(e as usize).copied().unwrap_or([0; 8])).collect();
verts.sort_unstable();
verts.dedup();
for v in verts {
if !matches!(self.constraint.get(v as usize), Some(Constraint::Corner(_)) | None) {
self.compass(v, &|o: &Self, v| o.certified_quality(v));
}
}
let mut around: Vec<u32> = bad
.iter()
.flat_map(|&e| self.hexes.get(e as usize).copied().unwrap_or([0; 8]))
.flat_map(|v| self.around.get(v as usize).map_or(Vec::new(), |a| a.iter().map(|x| x.0).collect()))
.collect();
around.sort_unstable();
around.dedup();
bad = around.into_iter().filter(|&e| !check(self, e)).collect();
}
bad
}
}
/// Solve the symmetric 3 × 3 system `h x = b` (Cramer), `None` when singular.
fn solve3(h: [[f64; 3]; 3], b: P) -> Option<P> {
let d = dot(h[0], cross(h[1], h[2]));
if !(d.abs() > 1e-300) || !d.is_finite() {
return None;
}
let col = |k: usize| [h[0][k], h[1][k], h[2][k]];
let (c0, c1, c2) = (col(0), col(1), col(2));
Some([dot(b, cross(c1, c2)) / d, dot(c0, cross(b, c2)) / d, dot(c0, cross(c1, b)) / d])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::octree::geom::tests::box_surface;
#[test]
fn corner_frames_are_right_handed_on_the_unit_cube() {
let cube: [P; 8] = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]];
for c in 0..8 {
let j = corner_jacobian(&cube, c, 1.0);
assert!((det3(&j) - 1.0).abs() < 1e-15, "corner {c}");
}
assert!((min_scaled_jacobian(&cube) - 1.0).abs() < 1e-15);
}
#[test]
fn a_tangled_interior_vertex_is_untangled() {
// A 2 × 2 × 2 block of cubes with the centre vertex pushed through a face; the boundary
// fixed.
let s = box_surface([0.0; 3], [2.0; 3]);
let g = Geometry::new(&s, 30.0, 160.0).unwrap();
let id = |x: u32, y: u32, z: u32| x + 3 * (y + 3 * z);
let mut p = Vec::new();
for z in 0..3 {
for y in 0..3 {
for x in 0..3 {
p.push([x as f64, y as f64, z as f64]);
}
}
}
let mut h = Vec::new();
for z in 0..2 {
for y in 0..2 {
for x in 0..2 {
h.push([
id(x, y, z),
id(x + 1, y, z),
id(x + 1, y + 1, z),
id(x, y + 1, z),
id(x, y, z + 1),
id(x + 1, y, z + 1),
id(x + 1, y + 1, z + 1),
id(x, y + 1, z + 1),
]);
}
}
}
let centre = id(1, 1, 1) as usize;
p[centre] = [1.7, 1.9, 0.2];
let size = vec![1.0; h.len()];
// Boundary vertices slide on the box's faces (hard constraints, patch of the nearest
// face), the centre is free.
let cons: Vec<Constraint> = (0..p.len())
.map(|i| {
if i == centre {
return Constraint::Free;
}
let t = g.nearest(p[i], |_| true).unwrap().0;
Constraint::Patch(g.tri_patch[t as usize])
})
.collect();
let mut o = Optimizer::new(&g, p, &h, &size, cons);
assert!(o.measure().0 < 0.0);
let st = o.run(200, 1e-9);
assert!(o.measure().0 > 0.9, "{st:?}");
assert!(sub(o.points[centre], [1.0; 3]).iter().all(|c| c.abs() < 1e-3), "{:?}", o.points[centre]);
}
}
+374
View File
@@ -0,0 +1,374 @@
//! The 3-refinement transition templates: how a leaf of the balanced tree is cut into
//! hexahedra so that it conforms to finer neighbours.
//!
//! Every vertex of a leaf is *marked* when a finer leaf touches it. Under strong balance the
//! marked set of a leaf is one of five patterns (after the closure in [`super::grid`] has split
//! every leaf that had another one), each with one template:
//!
//! | marks | template | hexahedra |
//! |---|---|---|
//! | none | the leaf itself | 1 |
//! | one corner | corner cube and three blocks fanning to the far corner | 4 |
//! | the two ends of an edge | three cubes along the edge and two four-hex slabs | 11 |
//! | the four corners of a face | nine cubes on the face, nine above them, four on top | 22 |
//! | all eight | the 3 × 3 × 3 split | 27 |
//!
//! A face of a leaf is cut according to its own marks only (none, one corner, one edge, all
//! four), and an edge into thirds at its marked ends, so two leaves sharing a face cut it the same
//! way: the mesh is conforming by construction. This is the node-marking form of Schneiders'
//! 3-refinement (R. Schneiders, *Refining quadrilateral and hexahedral element meshes*, NUMGRID
//! 1996; Y. Ito, A. M. Shih, B. K. Soni, *Octree-based reasonable-quality hexahedral mesh
//! generation using a new set of refinement templates*, IJNME 77(13):1809–1833, 2009). The
//! templates are our own construction from those papers' description: the face template's top
//! block is the 3 × 3 → 1 transition traced from its dual sheets, the edge template is two
//! copies of the planar 3 → 1 transition swept across the leaf, and the tests prove every
//! template's faces, volume and validity.
//!
//! Coordinates are integers in sixths of the leaf's edge, so every vertex shared with a neighbour
//! lies on the global integer lattice of [`super::grid`] and coincident vertices merge exactly.
/// A point of a leaf in sixths of its edge, `0..=6` per axis.
pub(crate) type Local = [i64; 3];
/// A hexahedron in the model's node order (corners 1-2-3-4 then 5-6-7-8 above them).
pub(crate) type Hex = [Local; 8];
/// The marked-corner pattern of a leaf, up to symmetry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Pattern {
None,
Corner,
Edge,
Face,
Full,
}
/// A symmetry of the cube: coordinate `k` of the image is coordinate `perm[k]` of the point,
/// reflected (`6 − v`) when `flip[k]`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Sym {
perm: [usize; 3],
flip: [bool; 3],
}
impl Sym {
pub(crate) fn apply(&self, p: Local) -> Local {
let mut q = [0; 3];
for (k, out) in q.iter_mut().enumerate() {
let v = p[self.perm[k]];
*out = if self.flip[k] { 6 - v } else { v };
}
q
}
}
/// The 48 symmetries in a fixed order.
pub(crate) fn symmetries() -> Vec<Sym> {
let perms = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]];
let mut out = Vec::with_capacity(48);
for perm in perms {
for bits in 0..8u8 {
out.push(Sym { perm, flip: [bits & 1 != 0, bits & 2 != 0, bits & 4 != 0] });
}
}
out
}
/// The leaf corner `c` (bit 0: x, bit 1: y, bit 2: z) in sixths.
pub(crate) fn corner(c: usize) -> Local {
[(c & 1) as i64 * 6, ((c >> 1) & 1) as i64 * 6, ((c >> 2) & 1) as i64 * 6]
}
/// The corner index of a corner point.
fn corner_index(p: Local) -> usize {
usize::from(p[0] == 6) | (usize::from(p[1] == 6) << 1) | (usize::from(p[2] == 6) << 2)
}
/// The image of a corner mask under a symmetry.
fn map_mask(sym: &Sym, mask: u8) -> u8 {
(0..8).filter(|c| mask & (1 << c) != 0).fold(0u8, |m, c| m | (1 << corner_index(sym.apply(corner(c)))))
}
/// The pattern of a corner mask, or `None` when no template fits it.
pub(crate) fn classify(mask: u8) -> Option<Pattern> {
let corners: Vec<Local> = (0..8).filter(|c| mask & (1 << c) != 0).map(corner).collect();
let shared = |n: usize| (0..3).filter(|&k| corners.iter().all(|p| p[k] == corners[0][k])).count() == n;
match corners.len() {
0 => Some(Pattern::None),
1 => Some(Pattern::Corner),
2 if shared(2) => Some(Pattern::Edge),
4 if shared(1) => Some(Pattern::Face),
8 => Some(Pattern::Full),
_ => None,
}
}
/// The canonical mask of a pattern: corner 0; edge 0–1 (along x); face z = 0.
fn canonical_mask(p: Pattern) -> u8 {
match p {
Pattern::None => 0,
Pattern::Corner => 0b1,
Pattern::Edge => 0b11,
Pattern::Face => 0b1111,
Pattern::Full => 0xff,
}
}
/// The first symmetry (in [`symmetries`] order) taking the pattern's canonical mask to `mask`.
pub(crate) fn orientation(syms: &[Sym], pattern: Pattern, mask: u8) -> Option<Sym> {
let c = canonical_mask(pattern);
syms.iter().find(|s| map_mask(s, c) == mask).copied()
}
/// Thirds to sixths.
fn t(x: i64, y: i64, z: i64) -> Local {
[2 * x, 2 * y, 2 * z]
}
/// A thirds-aligned cube with lower corner `(x, y, z)` (in thirds) and edge `e` thirds.
fn cube(x: i64, y: i64, z: i64, e: i64) -> Hex {
let (a, b) = (t(x, y, z), t(x + e, y + e, z + e));
[
[a[0], a[1], a[2]],
[b[0], a[1], a[2]],
[b[0], b[1], a[2]],
[a[0], b[1], a[2]],
[a[0], a[1], b[2]],
[b[0], a[1], b[2]],
[b[0], b[1], b[2]],
[a[0], b[1], b[2]],
]
}
/// The planar 3 → 1 transition in `(x, s)`: x in thirds, `s` in halves of the transition height,
/// three segments at `s = 0` and one at `s = 2`.
const THREE_TO_ONE: [[(i64, i64); 4]; 4] =
[[(0, 0), (1, 0), (1, 1), (0, 2)], [(1, 0), (2, 0), (2, 1), (1, 1)], [(2, 0), (3, 0), (3, 2), (2, 1)], [(1, 1), (2, 1), (3, 2), (0, 2)]];
/// The canonical template of a pattern, in sixths of the leaf.
pub(crate) fn template(p: Pattern) -> Vec<Hex> {
match p {
Pattern::None => vec![cube(0, 0, 0, 3)],
Pattern::Full => (0..27).map(|k| cube(k % 3, (k / 3) % 3, k / 9, 1)).collect(),
Pattern::Corner => vec![
cube(0, 0, 0, 1),
[t(1, 0, 0), t(3, 0, 0), t(3, 3, 0), t(1, 1, 0), t(1, 0, 1), t(3, 0, 3), t(3, 3, 3), t(1, 1, 1)],
[t(0, 1, 0), t(1, 1, 0), t(3, 3, 0), t(0, 3, 0), t(0, 1, 1), t(1, 1, 1), t(3, 3, 3), t(0, 3, 3)],
[t(0, 0, 1), t(1, 0, 1), t(1, 1, 1), t(0, 1, 1), t(0, 0, 3), t(3, 0, 3), t(3, 3, 3), t(0, 3, 3)],
],
Pattern::Edge => {
let mut out: Vec<Hex> = (0..3).map(|i| cube(i, 0, 0, 1)).collect();
// Two slabs over the edge column's faces y = 1 and z = 1, each the 3 → 1 transition
// swept from the leaf face to the diagonal plane y = z.
for q in THREE_TO_ONE {
let b = |(x, s): (i64, i64), w: i64| t(x, 1 + s, w * (1 + s));
let c = |(x, s): (i64, i64), w: i64| t(x, w * (1 + s), 1 + s);
out.push([b(q[0], 0), b(q[1], 0), b(q[2], 0), b(q[3], 0), b(q[0], 1), b(q[1], 1), b(q[2], 1), b(q[3], 1)]);
out.push([c(q[0], 0), c(q[1], 0), c(q[2], 0), c(q[3], 0), c(q[0], 1), c(q[1], 1), c(q[2], 1), c(q[3], 1)]);
}
out
}
Pattern::Face => {
let mut out: Vec<Hex> = (0..9).map(|k| cube(k % 3, k / 3, 0, 1)).collect();
// The layer above: its top is the corners raised to the leaf's top, the side nodes of
// the 3 → 1 transitions at two thirds, the inside nodes at half height.
let top = |a: i64, b: i64| -> Local {
let edge = |v: i64| v == 0 || v == 3;
match (edge(a), edge(b)) {
(true, true) => [2 * a, 2 * b, 6],
(false, false) => [2 * a, 2 * b, 3],
_ => [2 * a, 2 * b, 4],
}
};
for k in 0..9 {
let (i, j) = (k % 3, k / 3);
out.push([
t(i, j, 1),
t(i + 1, j, 1),
t(i + 1, j + 1, 1),
t(i, j + 1, 1),
top(i, j),
top(i + 1, j),
top(i + 1, j + 1),
top(i, j + 1),
]);
}
// The four on top: the y-arch (low), the x-arch (high) and the two side blocks.
out.push([[2, 2, 3], [4, 2, 3], [4, 4, 3], [2, 4, 3], [2, 0, 4], [4, 0, 4], [4, 6, 4], [2, 6, 4]]);
out.push([[2, 0, 4], [4, 0, 4], [4, 6, 4], [2, 6, 4], [0, 0, 6], [6, 0, 6], [6, 6, 6], [0, 6, 6]]);
out.push([[2, 2, 3], [2, 4, 3], [2, 6, 4], [2, 0, 4], [0, 2, 4], [0, 4, 4], [0, 6, 6], [0, 0, 6]]);
out.push([[4, 2, 3], [4, 4, 3], [4, 6, 4], [4, 0, 4], [6, 2, 4], [6, 4, 4], [6, 6, 6], [6, 0, 6]]);
out
}
}
}
/// Six times the signed volume of the corner tetrahedron at node 0 (exact on the lattice).
pub(crate) fn corner0_orientation(h: &[[i64; 3]; 8]) -> i128 {
let e = |j: usize| [0, 1, 2].map(|k| i128::from(h[j][k] - h[0][k]));
let (a, b, c) = (e(1), e(3), e(4));
a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])
}
/// The hexahedron with its orientation reversed (the same cell, mirrored node order).
pub(crate) fn reversed<T: Copy>(h: [T; 8]) -> [T; 8] {
[h[0], h[3], h[2], h[1], h[4], h[7], h[6], h[5]]
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::quality::{Validity, element_validity};
use fieldcraft_model::ElementKind;
/// The face of the leaf a quad lies on (axis, value), if any.
fn on_leaf_face(q: &[Local; 4]) -> Option<(usize, i64)> {
(0..3).find_map(|k| [0, 6].into_iter().find(|&v| q.iter().all(|p| p[k] == v)).map(|v| (k, v)))
}
fn sorted(mut q: [Local; 4]) -> [Local; 4] {
q.sort();
q
}
/// The quads a leaf face must carry for its four corner marks, as sorted vertex sets, on the
/// face `(axis, value)`.
fn face_template(axis: usize, value: i64, marks: [bool; 4]) -> Vec<[Local; 4]> {
// Face coordinates (u, v) are the other two axes in increasing order; corners in order
// (0,0), (6,0), (0,6), (6,6).
let (ua, va) = match axis {
0 => (1, 2),
1 => (0, 2),
_ => (0, 1),
};
let lift = |u: i64, v: i64| {
let mut p = [0; 3];
p[axis] = value;
p[ua] = u;
p[va] = v;
p
};
// Build in a canonical frame, then map the 2D symmetry that takes the canonical marks to
// these marks.
let count = marks.iter().filter(|m| **m).count();
let canon: Vec<[(i64, i64); 4]> = match count {
0 => vec![[(0, 0), (6, 0), (6, 6), (0, 6)]],
1 => vec![[(0, 0), (2, 0), (2, 2), (0, 2)], [(2, 0), (6, 0), (6, 6), (2, 2)], [(0, 2), (2, 2), (6, 6), (0, 6)]],
2 => {
let mut v: Vec<[(i64, i64); 4]> = (0..3).map(|i| [(2 * i, 0), (2 * i + 2, 0), (2 * i + 2, 2), (2 * i, 2)]).collect();
v.extend([
[(0, 2), (2, 2), (2, 4), (0, 6)],
[(2, 2), (4, 2), (4, 4), (2, 4)],
[(4, 2), (6, 2), (6, 6), (4, 4)],
[(2, 4), (4, 4), (6, 6), (0, 6)],
]);
v
}
_ => (0..9)
.map(|k| {
[(2 * (k % 3), 2 * (k / 3)), (2 * (k % 3) + 2, 2 * (k / 3)), (2 * (k % 3) + 2, 2 * (k / 3) + 2), (2 * (k % 3), 2 * (k / 3) + 2)]
})
.collect(),
};
let canon_marks: [bool; 4] = match count {
0 => [false; 4],
1 => [true, false, false, false],
2 => [true, true, false, false],
_ => [true; 4],
};
let maps: [fn(i64, i64) -> (i64, i64); 8] = [
|u, v| (u, v),
|u, v| (6 - u, v),
|u, v| (u, 6 - v),
|u, v| (6 - u, 6 - v),
|u, v| (v, u),
|u, v| (6 - v, u),
|u, v| (v, 6 - u),
|u, v| (6 - v, 6 - u),
];
let corners = [(0, 0), (6, 0), (0, 6), (6, 6)];
let idx = |c: (i64, i64)| corners.iter().position(|&x| x == c).unwrap();
let m =
maps.iter().find(|m| (0..4).all(|c| canon_marks[c] == marks[idx(m(corners[c].0, corners[c].1))])).expect("a face symmetry for the marks");
canon.iter().map(|q| sorted(q.map(|(u, v)| m(u, v)).map(|(u, v)| lift(u, v)))).collect()
}
fn face_corner_marks(axis: usize, value: i64, mask: u8) -> [bool; 4] {
let (ua, va) = match axis {
0 => (1, 2),
1 => (0, 2),
_ => (0, 1),
};
[(0, 0), (6, 0), (0, 6), (6, 6)].map(|(u, v)| {
let mut p = [0; 3];
p[axis] = value;
p[ua] = u;
p[va] = v;
mask & (1 << corner_index(p)) != 0
})
}
/// Every template under every symmetry: valid elements, the leaf's volume, closed (each inner
/// face shared by two elements) and, on each leaf face, exactly the face template of that
/// face's marks.
#[test]
fn templates_are_valid_closed_and_conform_to_their_face_marks() {
let syms = symmetries();
for pattern in [Pattern::None, Pattern::Corner, Pattern::Edge, Pattern::Face, Pattern::Full] {
for sym in &syms {
let mask = map_mask(sym, canonical_mask(pattern));
assert_eq!(classify(mask), Some(pattern));
let hexes: Vec<Hex> = template(pattern)
.into_iter()
.map(|h| {
let h = h.map(|p| sym.apply(p));
if corner0_orientation(&h) < 0 { reversed(h) } else { h }
})
.collect();
let mut volume = 0.0;
let mut faces: HashMap<[Local; 4], usize> = HashMap::new();
for h in &hexes {
let xyz: Vec<[f64; 3]> = h.iter().map(|p| p.map(|v| v as f64)).collect();
let check = element_validity(ElementKind::Hex8, &xyz).unwrap();
assert_eq!(check.validity, Validity::Valid, "{pattern:?} {sym:?} {h:?}");
volume += check.volume;
for f in ElementKind::Hex8.faces() {
*faces.entry(sorted([h[f[0]], h[f[1]], h[f[2]], h[f[3]]])).or_default() += 1;
}
}
assert!((volume - 216.0).abs() < 1e-9, "{pattern:?}: volume {volume}");
let mut outer: HashMap<(usize, i64), Vec<[Local; 4]>> = HashMap::new();
for (q, n) in &faces {
match on_leaf_face(q) {
Some(f) => {
assert_eq!(*n, 1);
outer.entry(f).or_default().push(*q);
}
None => assert_eq!(*n, 2, "{pattern:?} {sym:?}: inner face {q:?} used {n} times"),
}
}
for axis in 0..3 {
for value in [0, 6] {
let mut got = outer.remove(&(axis, value)).unwrap_or_default();
let mut want = face_template(axis, value, face_corner_marks(axis, value, mask));
got.sort();
want.sort();
assert_eq!(got, want, "{pattern:?} {sym:?} face {axis}={value}");
}
}
}
}
}
#[test]
fn patterns_without_a_template_are_recognised() {
assert_eq!(classify(0b1001), None, "diagonal of a face");
assert_eq!(classify(0b1000_0001), None, "opposite corners");
assert_eq!(classify(0b0111), None, "three of a face");
assert_eq!(classify(0b0110_1001), None, "four not on a face");
assert_eq!(classify(0b0111_1111), None);
assert_eq!(classify(0b0011_0011), Some(Pattern::Face));
assert_eq!(classify(0b0101_0000), Some(Pattern::Edge));
}
}
+98
View File
@@ -0,0 +1,98 @@
use super::*;
use crate::octree::geom::tests::box_surface;
use crate::size::UniformSize;
#[test]
fn a_box_meshes_with_exact_volume_and_tagged_faces() {
let s = box_surface([0.0; 3], [10.0, 8.0, 6.0]);
let m = octree_hex_mesh(&s, &UniformSize(2.0), &OctreeOptions::default()).unwrap();
let r = &m.report;
assert!((r.volume - 480.0).abs() < 1e-9 * 480.0, "{r:?}");
assert!(r.min_scaled_jacobian > 0.3, "{r:?}");
let faces: BTreeSet<u32> = m.boundary.iter().map(|b| b.brep_face).collect();
assert_eq!(faces.len(), 6);
// Each boundary quad lies in the plane of its face.
for b in &m.boundary {
let hx = m.hexes[b.element as usize];
let q = topo::FACES[(b.face - 1) as usize].map(|i| m.nodes[hx[i] as usize]);
let (axis, value) = match b.brep_face {
0 => (2, 0.0),
1 => (2, 6.0),
2 => (1, 0.0),
3 => (1, 8.0),
4 => (0, 0.0),
_ => (0, 10.0),
};
assert!(q.iter().all(|p| (p[axis] - value).abs() < 1e-9), "{b:?} {q:?}");
}
}
/// A closed cylinder of radius `r` and height `h` on the z axis, `n` segments: faces 0 (bottom
/// cap), 1 (top cap), 2 (side).
pub(crate) fn cylinder_surface(r: f64, h: f64, n: usize) -> BoundarySurface {
let mut p = vec![[0.0, 0.0, 0.0], [0.0, 0.0, h]];
for z in [0.0, h] {
for i in 0..n {
let a = std::f64::consts::TAU * i as f64 / n as f64;
p.push([r * a.cos(), r * a.sin(), z]);
}
}
let (b, t) = (|i: usize| (2 + i % n) as u32, |i: usize| (2 + n + i % n) as u32);
let mut tris = Vec::new();
let mut face = Vec::new();
for i in 0..n {
tris.push([0, b(i + 1), b(i)]);
face.push(0);
tris.push([1, t(i), t(i + 1)]);
face.push(1);
tris.push([b(i), b(i + 1), t(i + 1)]);
tris.push([b(i), t(i + 1), t(i)]);
face.extend([2, 2]);
}
BoundarySurface::new(&p, &tris, &face, &crate::surface::SurfaceOptions::default()).unwrap()
}
/// A UV sphere of radius `r`, one face.
pub(crate) fn sphere_surface(r: f64, n: usize) -> BoundarySurface {
let rings = n / 2;
let mut p = vec![[0.0, 0.0, -r], [0.0, 0.0, r]];
for j in 1..rings {
let th = std::f64::consts::PI * j as f64 / rings as f64 - std::f64::consts::FRAC_PI_2;
for i in 0..n {
let a = std::f64::consts::TAU * i as f64 / n as f64;
p.push([r * th.cos() * a.cos(), r * th.cos() * a.sin(), r * th.sin()]);
}
}
let id = |j: usize, i: usize| (2 + (j - 1) * n + i % n) as u32;
let mut tris = Vec::new();
for i in 0..n {
tris.push([0, id(1, i + 1), id(1, i)]);
tris.push([1, id(rings - 1, i), id(rings - 1, i + 1)]);
}
for j in 1..rings - 1 {
for i in 0..n {
tris.push([id(j, i), id(j, i + 1), id(j + 1, i + 1)]);
tris.push([id(j, i), id(j + 1, i + 1), id(j + 1, i)]);
}
}
let face = vec![0; tris.len()];
BoundarySurface::new(&p, &tris, &face, &crate::surface::SurfaceOptions::default()).unwrap()
}
#[test]
fn a_cylinder_meshes_with_its_rims_captured() {
let s = cylinder_surface(5.0, 8.0, 64);
let m = octree_hex_mesh(&s, &UniformSize(2.0), &OctreeOptions::default()).unwrap();
let r = &m.report;
assert!(((r.volume - r.surface_volume) / r.surface_volume).abs() < 0.01, "{r:?}");
let faces: BTreeSet<u32> = m.boundary.iter().map(|b| b.brep_face).collect();
assert_eq!(faces.len(), 3);
}
#[test]
fn a_sphere_meshes() {
let s = sphere_surface(5.0, 48);
let m = octree_hex_mesh(&s, &UniformSize(2.0), &OctreeOptions::default()).unwrap();
let r = &m.report;
assert!(((r.volume - r.surface_volume) / r.surface_volume).abs() < 0.01, "{r:?}");
}
+404
View File
@@ -0,0 +1,404 @@
//! Hexahedral mesh topology for the boundary stages: boundary faces, the manifold clean-up of
//! the core, padding a boundary with a layer of hexahedra, and pillowing a shrink set.
//!
//! *Padding* puts one hexahedron on every boundary quad, between the quad and a copy of it, so
//! no hexahedron keeps more than one face on the boundary. *Pillowing* a shrink set `S` inserts
//! a layer of hexahedra along the faces between `S` and the rest of the mesh; the layer reaches
//! the boundary along the loop where `S`'s boundary faces meet the others' (S. A. Mitchell,
//! T. J. Tautges, *Pillowing doublets: refining a mesh to ensure that faces share at most one
//! edge*, IMR 1995; Gao, Shen, Panozzo 2019, §3.4 use the same insertion around features).
use std::collections::{BTreeMap, HashMap};
use crate::{Error, Result};
/// The six faces of a hex8 as local node positions, right-hand normals outward (the model's
/// `ElementKind::Hex8.faces()` order).
pub(crate) const FACES: [[usize; 4]; 6] = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]];
/// A face key: the vertices sorted.
pub(crate) fn key(q: [u32; 4]) -> [u32; 4] {
let mut k = q;
k.sort_unstable();
k
}
/// The root of `i` in the union-find forest `c`, compressing the path from `i`.
pub(crate) fn find(c: &mut [usize], i: usize) -> usize {
let mut r = i;
while let Some(&p) = c.get(r).filter(|&&p| p != r) {
r = p;
}
if let Some(x) = c.get_mut(i) {
*x = r;
}
r
}
/// A face of an element: (hexahedron, local face 0..6).
pub(crate) type FaceRef = (u32, u8);
/// The vertices of a local face, outward.
pub(crate) fn face(h: &[u32; 8], f: u8) -> [u32; 4] {
FACES.get(f as usize).map_or([0; 4], |l| l.map(|i| h[i]))
}
/// Every face's elements, keyed by sorted vertices (deterministic map).
pub(crate) fn face_map(hexes: &[[u32; 8]]) -> BTreeMap<[u32; 4], Vec<FaceRef>> {
let mut m: BTreeMap<[u32; 4], Vec<FaceRef>> = BTreeMap::new();
for (i, h) in hexes.iter().enumerate() {
for f in 0..6u8 {
m.entry(key(face(h, f))).or_default().push((i as u32, f));
}
}
m
}
/// The boundary faces (on one element only), outward, in face-map order.
pub(crate) fn boundary(hexes: &[[u32; 8]]) -> Vec<(FaceRef, [u32; 4])> {
face_map(hexes)
.into_values()
.filter(|v| v.len() == 1)
.filter_map(|v| v.first().copied())
.map(|(h, f)| ((h, f), hexes.get(h as usize).map_or([0; 4], |x| face(x, f))))
.collect()
}
/// Edges of a quad list with the quads using each (ascending vertex pair → quad indices).
pub(crate) fn quad_edges(quads: &[[u32; 4]]) -> BTreeMap<(u32, u32), Vec<u32>> {
let mut m: BTreeMap<(u32, u32), Vec<u32>> = BTreeMap::new();
for (i, q) in quads.iter().enumerate() {
for k in 0..4 {
let (a, b) = (q[k], q[(k + 1) % 4]);
m.entry((a.min(b), a.max(b))).or_default().push(i as u32);
}
}
m
}
/// The hexahedra to drop so that the boundary of `keep` is a 2-manifold: elements at edges with
/// four boundary faces (elements touching along an edge only) and at vertices whose boundary
/// faces form more than one fan. Returns the indices to drop (empty when manifold).
pub(crate) fn nonmanifold(hexes: &[[u32; 8]], keep: &[bool]) -> Vec<u32> {
let kept: Vec<u32> = (0..hexes.len() as u32).filter(|&i| keep.get(i as usize).copied().unwrap_or(false)).collect();
let sub: Vec<[u32; 8]> = kept.iter().filter_map(|&i| hexes.get(i as usize).copied()).collect();
let b = boundary(&sub);
let quads: Vec<[u32; 4]> = b.iter().map(|(_, q)| *q).collect();
let mut bad_vertices: Vec<u32> = Vec::new();
for ((a, c), qs) in quad_edges(&quads) {
if qs.len() != 2 {
bad_vertices.extend([a, c]);
}
}
// Fans: around each vertex, boundary quads connected through shared edges at the vertex.
let mut at: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
for (i, q) in quads.iter().enumerate() {
for &v in q {
at.entry(v).or_default().push(i as u32);
}
}
for (v, qs) in &at {
let mut comp: Vec<usize> = (0..qs.len()).collect();
for i in 0..qs.len() {
for j in i + 1..qs.len() {
let (qa, qb) = (quads[qs[i] as usize], quads[qs[j] as usize]);
// Share an edge through v: another common vertex adjacent to v in both.
let nb = |q: [u32; 4]| {
let p = q.iter().position(|&x| x == *v).unwrap_or(0);
[q[(p + 1) % 4], q[(p + 3) % 4]]
};
if nb(qa).iter().any(|x| nb(qb).contains(x)) {
let (ri, rj) = (find(&mut comp, i), find(&mut comp, j));
comp[ri] = rj;
}
}
}
let roots = (0..qs.len()).filter(|&i| find(&mut comp, i) == i).count();
if roots > 1 {
bad_vertices.push(*v);
}
}
bad_vertices.sort_unstable();
bad_vertices.dedup();
if bad_vertices.is_empty() {
return Vec::new();
}
kept.into_iter().filter(|&i| hexes.get(i as usize).is_some_and(|h| h.iter().any(|v| bad_vertices.binary_search(v).is_ok()))).collect()
}
/// Pad: a hexahedron on each listed boundary face (outward), between it and a copy; the copies
/// of the faces' vertices are new points (appended, initially at the original positions).
/// Returns the copy of each original vertex.
pub(crate) fn pad(points: &mut Vec<[f64; 3]>, hexes: &mut Vec<[u32; 8]>, faces: &[[u32; 4]]) -> Result<BTreeMap<u32, u32>> {
let mut copy: BTreeMap<u32, u32> = BTreeMap::new();
for q in faces {
for &v in q {
if let std::collections::btree_map::Entry::Vacant(slot) = copy.entry(v) {
let n = u32::try_from(points.len()).map_err(|_| Error::Limit("too many points".into()))?;
points.push(points.get(v as usize).copied().unwrap_or([0.0; 3]));
slot.insert(n);
}
}
}
for q in faces {
let c = q.map(|v| copy.get(&v).copied().unwrap_or(v));
hexes.push([q[0], q[1], q[2], q[3], c[0], c[1], c[2], c[3]]);
}
Ok(copy)
}
/// One vertex copy made by [`pillow`]: the original, the copy, and the shrink-set elements
/// around the original that now use the copy.
#[derive(Debug, Clone)]
pub(crate) struct PillowCopy {
pub original: u32,
pub copy: u32,
pub elements: Vec<u32>,
}
/// Pillow the shrink set `inside` (one flag per element): a new layer of hexahedra on every face
/// between an element of the set and one outside it. Vertices of those faces get one copy per
/// group of set elements around them (connected through faces inside the set), which the set's
/// elements now use; the new layer joins the originals to the copies. Faces on the boundary are
/// not pillowed: where the layer meets the boundary, the set's boundary faces move to the copies,
/// so the boundary gains a ring of quads along the loop. New points are appended at the
/// originals' positions. Returns the copies made.
pub(crate) fn pillow(points: &mut Vec<[f64; 3]>, hexes: &mut Vec<[u32; 8]>, inside: &[bool]) -> Result<Vec<PillowCopy>> {
let fm = face_map(hexes);
let in_set = |h: u32| inside.get(h as usize).copied().unwrap_or(false);
// Faces to pillow, oriented from the outside element into the set.
let mut sheet: Vec<(u32, [u32; 4])> = Vec::new();
for refs in fm.values() {
if let [(a, fa), (b, fb)] = refs.as_slice() {
match (in_set(*a), in_set(*b)) {
(true, false) => sheet.push((*a, hexes.get(*a as usize).map_or([0; 4], |h| face(h, *fa)))),
(false, true) => sheet.push((*b, hexes.get(*b as usize).map_or([0; 4], |h| face(h, *fb)))),
_ => {}
}
}
}
if sheet.is_empty() {
return Ok(Vec::new());
}
let mut on_sheet: Vec<u32> = sheet.iter().flat_map(|(_, q)| *q).collect();
on_sheet.sort_unstable();
on_sheet.dedup();
// Set elements around each sheet vertex.
let mut around: HashMap<u32, Vec<u32>> = HashMap::new();
for (i, h) in hexes.iter().enumerate() {
if !in_set(i as u32) {
continue;
}
for &v in h {
if on_sheet.binary_search(&v).is_ok() {
let e = around.entry(v).or_default();
if e.last() != Some(&(i as u32)) {
e.push(i as u32);
}
}
}
}
let mut copies: Vec<PillowCopy> = Vec::new();
// (vertex, element) → copy.
let mut copy_of: HashMap<(u32, u32), u32> = HashMap::new();
for &v in &on_sheet {
let mut els = around.get(&v).cloned().unwrap_or_default();
els.sort_unstable();
els.dedup();
// Group by faces inside the set that contain v.
let mut group: Vec<usize> = (0..els.len()).collect();
for i in 0..els.len() {
for j in i + 1..els.len() {
let (Some(hi), Some(hj)) = (hexes.get(els[i] as usize), hexes.get(els[j] as usize)) else { continue };
let shares = (0..6u8).any(|f| {
let q = face(hi, f);
q.contains(&v) && (0..6u8).any(|g| key(face(hj, g)) == key(q))
});
if shares {
let (a, b) = (find(&mut group, i), find(&mut group, j));
group[a] = b;
}
}
}
// Per group: its copy and that copy's index in `copies`.
let mut made: BTreeMap<usize, (u32, usize)> = BTreeMap::new();
for (i, &e) in els.iter().enumerate() {
let r = find(&mut group, i);
let (c, at) = match made.get(&r) {
Some(&m) => m,
None => {
let c = u32::try_from(points.len()).map_err(|_| Error::Limit("too many points".into()))?;
points.push(points.get(v as usize).copied().unwrap_or([0.0; 3]));
copies.push(PillowCopy { original: v, copy: c, elements: Vec::new() });
made.insert(r, (c, copies.len() - 1));
(c, copies.len() - 1)
}
};
copy_of.insert((v, e), c);
if let Some(pc) = copies.get_mut(at) {
pc.elements.push(e);
}
}
}
// The new layer: original face (seen from outside, normal into the set) below, copies above.
let mut layer: Vec<[u32; 8]> = Vec::with_capacity(sheet.len());
for (h, q) in &sheet {
let r = [q[0], q[3], q[2], q[1]];
let c = r.map(|v| copy_of.get(&(v, *h)).copied().unwrap_or(v));
layer.push([r[0], r[1], r[2], r[3], c[0], c[1], c[2], c[3]]);
}
for (i, h) in hexes.iter_mut().enumerate() {
if in_set(i as u32) {
for v in h.iter_mut() {
if let Some(&c) = copy_of.get(&(*v, i as u32)) {
*v = c;
}
}
}
}
hexes.extend(layer);
Ok(copies)
}
/// Hexahedra to drop so that every vertex of the boundary of `keep` can be pushed outward: the
/// normals of its boundary quads must lie strictly inside one half-space (their mean has a
/// positive product with each, at least `margin`). Where this fails, padding from the vertex
/// to the part's surface inverts elements, so for each such vertex the kept element at it with
/// the most boundary faces is dropped (the buffer-zone rule of H. Tong, E. Halilaj, Y. J. Zhang,
/// *HybridOctree_Hex*, arXiv 2401.05984, 2024, §2.3, in a mean-normal form).
pub(crate) fn hidden_vertices(hexes: &[[u32; 8]], keep: &[bool], xyz: &dyn Fn(u32) -> [f64; 3], margin: f64) -> Vec<u32> {
let kept: Vec<u32> = (0..hexes.len() as u32).filter(|&i| keep.get(i as usize).copied().unwrap_or(false)).collect();
let sub: Vec<[u32; 8]> = kept.iter().filter_map(|&i| hexes.get(i as usize).copied()).collect();
let b = boundary(&sub);
let mut normals: BTreeMap<u32, Vec<[f64; 3]>> = BTreeMap::new();
let mut faces_of: BTreeMap<u32, usize> = BTreeMap::new();
for ((h, _), q) in &b {
let p = q.map(xyz);
let d1 = [0, 1, 2].map(|k| p[2][k] - p[0][k]);
let d2 = [0, 1, 2].map(|k| p[3][k] - p[1][k]);
let n = [d1[1] * d2[2] - d1[2] * d2[1], d1[2] * d2[0] - d1[0] * d2[2], d1[0] * d2[1] - d1[1] * d2[0]];
let l = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
if l > 0.0 {
for &v in q {
normals.entry(v).or_default().push(n.map(|c| c / l));
}
}
*faces_of.entry(*h).or_default() += 1;
}
let mut drop: Vec<u32> = Vec::new();
for (v, ns) in &normals {
let mut m = [0.0; 3];
for n in ns {
for k in 0..3 {
m[k] += n[k];
}
}
let l = (m[0] * m[0] + m[1] * m[1] + m[2] * m[2]).sqrt();
let ok = l > 0.0 && ns.iter().all(|n| (n[0] * m[0] + n[1] * m[1] + n[2] * m[2]) / l > margin);
if ok {
continue;
}
// The kept element at v with the most boundary faces (lowest index on ties).
let best = sub
.iter()
.enumerate()
.filter(|(_, h)| h.contains(v))
.max_by(|a, b| faces_of.get(&(a.0 as u32)).unwrap_or(&0).cmp(faces_of.get(&(b.0 as u32)).unwrap_or(&0)).then(b.0.cmp(&a.0)))
.and_then(|(i, _)| kept.get(i).copied());
if let Some(e) = best {
drop.push(e);
}
}
drop.sort_unstable();
drop.dedup();
drop
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn faces_follow_the_model_hex8_face_order() {
let model = fieldcraft_model::ElementKind::Hex8.faces();
assert_eq!(model.len(), FACES.len());
for (m, f) in model.iter().zip(FACES) {
assert_eq!(*m, &f[..]);
}
}
/// An `n × n × n` block of unit cubes.
pub(crate) fn block(n: u32) -> (Vec<[f64; 3]>, Vec<[u32; 8]>) {
let id = |x: u32, y: u32, z: u32| x + (n + 1) * (y + (n + 1) * z);
let mut p = Vec::new();
for z in 0..=n {
for y in 0..=n {
for x in 0..=n {
p.push([x as f64, y as f64, z as f64]);
}
}
}
let mut h = Vec::new();
for z in 0..n {
for y in 0..n {
for x in 0..n {
h.push([
id(x, y, z),
id(x + 1, y, z),
id(x + 1, y + 1, z),
id(x, y + 1, z),
id(x, y, z + 1),
id(x + 1, y, z + 1),
id(x + 1, y + 1, z + 1),
id(x, y + 1, z + 1),
]);
}
}
}
(p, h)
}
fn closed(hexes: &[[u32; 8]]) {
for (k, v) in face_map(hexes) {
assert!(v.len() <= 2, "face {k:?} on {} elements", v.len());
}
let b = boundary(hexes);
for (_, qs) in quad_edges(&b.iter().map(|x| x.1).collect::<Vec<_>>()) {
assert_eq!(qs.len(), 2);
}
}
#[test]
fn padding_and_pillowing_keep_a_closed_conforming_mesh() {
let (mut p, mut h) = block(3);
let b: Vec<[u32; 4]> = boundary(&h).into_iter().map(|x| x.1).collect();
assert_eq!(b.len(), 54);
let copy = pad(&mut p, &mut h, &b).unwrap();
assert_eq!(copy.len(), 56);
assert_eq!(h.len(), 27 + 54);
closed(&h);
// Pillow the padding hexes on the face z = 0 (outer faces down).
let pad_start = 27;
let inside: Vec<bool> = (0..h.len()).map(|i| i >= pad_start && (0..4).all(|k| p[h[i][k] as usize][2] == 0.0)).collect();
assert_eq!(inside.iter().filter(|x| **x).count(), 9);
let before = h.len();
let copies = pillow(&mut p, &mut h, &inside).unwrap();
closed(&h);
// Sheet: the 9 inner faces plus the 12 side faces to the neighbouring padding.
assert_eq!(h.len(), before + 9 + 12);
// The boundary gains the ring: 54 + 12 quads.
assert_eq!(boundary(&h).len(), 54 + 12);
assert!(!copies.is_empty());
}
#[test]
fn nonmanifold_edges_and_vertices_are_found() {
let (_, h) = block(2);
// Two cubes touching along an edge.
let keep = |ids: &[usize]| (0..8).map(|i| ids.contains(&i)).collect::<Vec<bool>>();
assert!(!nonmanifold(&h, &keep(&[0, 3])).is_empty(), "edge contact");
assert!(!nonmanifold(&h, &keep(&[0, 7])).is_empty(), "vertex contact");
assert!(nonmanifold(&h, &keep(&[0, 1, 3])).is_empty());
assert!(nonmanifold(&h, &keep(&[0, 1, 2, 3, 4, 5, 6, 7])).is_empty());
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ fn dist<const D: usize>(a: &[f64; D], b: &[f64; D]) -> f64 {
}
/// Normalized cumulative arc length of a side, from 0 to 1.
fn params<const D: usize>(side: &[[f64; D]], name: &str) -> Result<Vec<f64>> {
pub(crate) fn params<const D: usize>(side: &[[f64; D]], name: &str) -> Result<Vec<f64>> {
let mut acc = Vec::with_capacity(side.len());
let mut s = 0.0;
acc.push(0.0);
+2 -2
View File
@@ -62,8 +62,8 @@ pub use mesh::{Element, ElementEdge, ElementFace, ElementKind, Node, check_coord
pub use sections::{BeamProfile, Extent, Formulation, Integration, Section, SectionKind};
pub use sets::Set;
pub use steps::{
BulkViscosity, ExplicitDynamic, Hourglass, HourglassKind, InitialVelocity, MAX_NONLINEAR_INCREMENTS, MAX_NONLINEAR_ITERATIONS, NonlinearStatic,
OutputVariable, Procedure, RigidWall, Step, StressRate, Tolerances, Transient,
BulkViscosity, ExplicitDynamic, Hourglass, HourglassKind, InitialVelocity, MAX_LOAD_PATH_POINTS, MAX_NONLINEAR_INCREMENTS,
MAX_NONLINEAR_ITERATIONS, NonlinearStatic, OutputVariable, Procedure, RigidWall, Step, StressRate, Tolerances, Transient,
};
pub use units::UnitSystem;
+71 -6
View File
@@ -1,7 +1,7 @@
//! Materials: linear elasticity, mass density, fluid behaviour, thermal properties (conductivity,
//! specific heat, expansion), and the inelastic behaviours of
//! explicit dynamics: von Mises (J2) plasticity with a bilinear, tabulated or Johnson–Cook flow stress,
//! Johnson–Cook damage, and element erosion criteria.
//! specific heat, expansion), and the inelastic behaviours: von Mises (J2) plasticity with a
//! bilinear, tabulated or Johnson–Cook flow stress (explicit dynamics and nonlinear static
//! steps), Johnson–Cook damage and element erosion criteria (explicit dynamics).
//!
//! The records are solver-neutral and named for what they describe, never after a product's
//! keyword or material number; the deck writers map them to each format.
@@ -88,6 +88,16 @@ pub enum Hyperelastic {
/// reference volume W = μ/2 (Ī₁ − 3) + κ/2 (J − 1)², with Ī₁ = J^(−2/3) tr C (C₁₀ = μ/2,
/// D₁ = 2/κ in the usual polynomial notation).
NeoHookean {},
/// Mooney–Rivlin (M. Mooney, J. Appl. Phys. 11:582–592, 1940; R. S. Rivlin, Phil. Trans. R.
/// Soc. A 241:379–397, 1948), decoupled: W = C₁₀ (Ī₁ − 3) + C₀₁ (Ī₂ − 3) + κ/2 (J − 1)². The
/// small-strain shear modulus 2 (C₁₀ + C₀₁) is the elastic μ, so `c01` sets the second
/// invariant's share and C₁₀ = μ/2 − C₀₁ (0 ≤ C₀₁ ≤ μ/2, both constants non-negative).
MooneyRivlin { c01: f64 },
/// Yeoh (O. H. Yeoh, Rubber Chem. Technol. 66(5):754–771, 1993), decoupled:
/// W = C₁₀ (Ī₁ − 3) + C₂₀ (Ī₁ − 3)² + C₃₀ (Ī₁ − 3)³ + κ/2 (J − 1)², with C₁₀ = μ/2 from the
/// elastic constants (the small-strain shear modulus); `c20` (often negative) and `c30`
/// shape the large-strain response.
Yeoh { c20: f64, c30: f64 },
}
impl Hyperelastic {
@@ -101,8 +111,39 @@ impl Hyperelastic {
pub fn name(&self) -> &'static str {
match self {
Hyperelastic::NeoHookean {} => "neo-Hookean",
Hyperelastic::MooneyRivlin { .. } => "Mooney–Rivlin",
Hyperelastic::Yeoh { .. } => "Yeoh",
}
}
/// The polynomial constants (C₁₀, C₀₁, C₂₀, C₃₀) for the shear modulus μ.
pub fn constants(&self, shear: f64) -> [f64; 4] {
match *self {
Hyperelastic::NeoHookean {} => [shear / 2.0, 0.0, 0.0, 0.0],
Hyperelastic::MooneyRivlin { c01 } => [shear / 2.0 - c01, c01, 0.0, 0.0],
Hyperelastic::Yeoh { c20, c30 } => [shear / 2.0, 0.0, c20, c30],
}
}
/// Check the option's constants against the elastic shear modulus μ.
pub fn validate(&self, shear: f64) -> Result<()> {
match *self {
Hyperelastic::NeoHookean {} => {}
Hyperelastic::MooneyRivlin { c01 } => {
if !(c01 >= 0.0 && c01 <= shear / 2.0) {
return Err(Error::Invalid(format!(
"the Mooney–Rivlin C01 = {c01} must be within [0, μ/2 = {}] (C10 = μ/2 − C01 and C01 both non-negative)",
shear / 2.0
)));
}
}
Hyperelastic::Yeoh { c20, c30 } => {
finite("Yeoh C20", c20)?;
finite("Yeoh C30", c30)?;
}
}
Ok(())
}
}
/// Fluid behaviour.
@@ -372,8 +413,8 @@ pub struct Material {
/// How it flows, for fluid domains (needs a density).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fluid: Option<Fluid>,
/// Von Mises plasticity (explicit dynamics; the linear implicit solver uses the elastic
/// constants alone).
/// Von Mises plasticity (explicit dynamics and nonlinear static steps; the linear solvers use
/// the elastic constants alone).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plastic: Option<Plastic>,
/// Damage to failure (explicit dynamics: a failed element erodes).
@@ -407,6 +448,9 @@ impl Material {
if self.plastic.is_some() {
return Err(Error::Invalid(format!("plasticity of a {} hyperelastic material is not supported yet", h.name())));
}
if let Some(Elastic::Isotropic { young, poisson }) = self.elastic {
h.validate(Hyperelastic::moduli(young, poisson).0)?;
}
}
if let Some(d) = self.density {
positive("density", d)?;
@@ -607,7 +651,28 @@ mod tests {
let ortho = Elastic::EngineeringConstants { young: [1.0; 3], poisson: [0.0; 3], shear: [1.0; 3] };
assert!(Material { elastic: Some(ortho), ..alone }.validate().is_err());
let plastic = Plastic::Bilinear { yield_stress: 1.0, hardening_modulus: 0.0, hardening: Hardening::Isotropic };
assert!(Material { plastic: Some(plastic), ..m }.validate().is_err());
assert!(Material { plastic: Some(plastic), ..m.clone() }.validate().is_err());
// Mooney–Rivlin and Yeoh: μ = E/(2(1 + ν)) = 1.00671 here, so C01 is within [0, μ/2].
let mr: Material = serde_json::from_value(serde_json::json!({
"elastic": {"type": "isotropic", "young": 3.0, "poisson": 0.49}, "hyperelastic": {"type": "mooney_rivlin", "c01": 0.1}
}))
.unwrap();
mr.validate().unwrap();
let mu = Hyperelastic::moduli(3.0, 0.49).0;
let c = Hyperelastic::MooneyRivlin { c01: 0.1 }.constants(mu);
assert!((2.0 * (c[0] + c[1]) - mu).abs() < 1e-15 && c[1] == 0.1);
let yeoh = Hyperelastic::Yeoh { c20: -0.01, c30: 0.001 };
assert_eq!(yeoh.constants(mu), [mu / 2.0, 0.0, -0.01, 0.001]);
assert!(Material { hyperelastic: Some(yeoh), ..m.clone() }.validate().is_ok());
for bad in [
Hyperelastic::MooneyRivlin { c01: -0.1 },
Hyperelastic::MooneyRivlin { c01: 0.6 },
Hyperelastic::MooneyRivlin { c01: f64::NAN },
Hyperelastic::Yeoh { c20: f64::INFINITY, c30: 0.0 },
Hyperelastic::Yeoh { c20: 0.0, c30: f64::NAN },
] {
assert!(Material { hyperelastic: Some(bad), ..m.clone() }.validate().is_err(), "{bad:?}");
}
}
#[test]
+65 -3
View File
@@ -87,9 +87,9 @@ pub enum Procedure {
/// together: every boundary condition and load of the step acts.
CoupledTemperatureDisplacement {},
/// Static equilibrium followed in load increments with Newton–Raphson iterations: large
/// displacements, rotations and strains (`nonlinear_geometry`), hyperelastic materials and
/// follower pressure. Loads and prescribed displacements grow in proportion to the step's
/// load factor, from 0 to 1.
/// displacements, rotations and strains (`nonlinear_geometry`), hyperelastic and plastic
/// materials and follower pressure. Loads and prescribed displacements are the step's load
/// factor times their values: a ramp from 0 to 1, or the step's load path.
NonlinearStatic(NonlinearStatic),
}
@@ -130,8 +130,18 @@ pub struct NonlinearStatic {
/// A line search along each Newton correction (off by default: full Newton steps converge
/// best for elastic large-rotation problems; a search helps where the material softens).
pub line_search: bool,
/// The load factor against the step fraction: piecewise linear through these
/// `[fraction, factor]` points, from `[0, 0]` to fraction 1 (`None`: the linear ramp, the
/// factor equal to the fraction). Loads and prescribed displacements are the factor times
/// their values, so a path that comes down unloads and one that turns negative reverses
/// them (cyclic loading). Increments end at every point of the path.
#[serde(skip_serializing_if = "Option::is_none")]
pub load_path: Option<Vec<[f64; 2]>>,
}
/// Most points of a nonlinear step's load path.
pub const MAX_LOAD_PATH_POINTS: usize = 1000;
/// When an increment's Newton iterations have converged: all three hold.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
@@ -162,6 +172,7 @@ impl Default for NonlinearStatic {
max_iterations: 16,
tolerances: Tolerances::default(),
line_search: false,
load_path: None,
}
}
}
@@ -192,8 +203,41 @@ impl NonlinearStatic {
return Err(Error::Invalid(format!("the {what} tolerance is in (0, 1), got {v}")));
}
}
if let Some(path) = &self.load_path {
if !(2..=MAX_LOAD_PATH_POINTS).contains(&path.len()) {
return Err(Error::Invalid(format!("a load path has 2 to {MAX_LOAD_PATH_POINTS} points, got {}", path.len())));
}
if path.first() != Some(&[0.0, 0.0]) || path.last().map(|p| p[0]) != Some(1.0) {
return Err(Error::Invalid("a load path starts at [0, 0] (the unloaded start of the step) and ends at fraction 1".into()));
}
if path.iter().flatten().any(|v| !v.is_finite()) {
return Err(Error::Invalid("the points of a load path must be finite".into()));
}
if path.windows(2).any(|w| w.get(1).map(|b| b[0]) <= w.first().map(|a| a[0])) {
return Err(Error::Invalid("the fractions of a load path must increase".into()));
}
}
Ok(())
}
/// The load factor at step fraction `t` (in [0, 1]).
pub fn load_factor(&self, t: f64) -> f64 {
let Some(path) = &self.load_path else { return t };
for w in path.windows(2) {
if let [[t0, f0], [t1, f1]] = *w
&& t <= t1
{
return if t1 > t0 { f0 + (f1 - f0) * ((t - t0) / (t1 - t0)).clamp(0.0, 1.0) } else { f1 };
}
}
path.last().map_or(t, |p| p[1])
}
/// The first point of the load path after fraction `t` (1 without a path), where an
/// increment from `t` must end.
pub fn next_turn(&self, t: f64) -> f64 {
self.load_path.as_ref().and_then(|path| path.iter().map(|p| p[0]).find(|f| *f > t * (1.0 + 1e-12) + 1e-12)).unwrap_or(1.0)
}
}
/// Most time increments of a transient heat transfer step.
@@ -900,6 +944,24 @@ mod tests {
assert!(Procedure::NonlinearStatic(b.clone()).validate().is_err(), "{b:?}");
}
assert!(serde_json::from_value::<Procedure>(serde_json::json!({"type": "nonlinear_static", "nlgeom": true})).is_err());
// A load path: up, down through zero, back to zero.
let cyclic = NonlinearStatic { load_path: Some(vec![[0.0, 0.0], [0.25, 1.0], [0.75, -1.0], [1.0, 0.0]]), ..base.clone() };
assert!(Procedure::NonlinearStatic(cyclic.clone()).validate().is_ok());
for (t, f) in [(0.0, 0.0), (0.125, 0.5), (0.25, 1.0), (0.5, 0.0), (0.75, -1.0), (0.875, -0.5), (1.0, 0.0)] {
assert!((cyclic.load_factor(t) - f).abs() < 1e-15, "{t}: {}", cyclic.load_factor(t));
}
assert_eq!((cyclic.next_turn(0.0), cyclic.next_turn(0.25), cyclic.next_turn(0.3), cyclic.next_turn(0.75)), (0.25, 0.75, 0.75, 1.0));
assert_eq!((base.load_factor(0.3), base.next_turn(0.3)), (0.3, 1.0));
for path in [
vec![[0.0, 0.0]],
vec![[0.0, 0.5], [1.0, 1.0]],
vec![[0.0, 0.0], [0.9, 1.0]],
vec![[0.0, 0.0], [0.5, 1.0], [0.5, 2.0], [1.0, 0.0]],
vec![[0.0, 0.0], [0.5, f64::NAN], [1.0, 0.0]],
vec![[0.0, 0.0]; MAX_LOAD_PATH_POINTS + 1],
] {
assert!(Procedure::NonlinearStatic(NonlinearStatic { load_path: Some(path.clone()), ..base.clone() }).validate().is_err(), "{path:?}");
}
}
#[test]
+23 -1
View File
@@ -55,6 +55,8 @@ pub struct DisplayNode {
pub struct SurfaceTri {
pub p: [Vec3; 3],
pub normal: Vec3,
/// Which surface (body) it belongs to: the order [`DisplayMesh::add_surface`] got them in.
pub body: u32,
}
/// Display data for a model and its geometry.
@@ -72,6 +74,8 @@ pub struct DisplayMesh {
pub surface_tris: Vec<SurfaceTri>,
/// Geometry edges: where two faces meet, and open boundaries.
pub surface_edges: Vec<[Vec3; 2]>,
/// Geometry surfaces (bodies) added.
pub bodies: u32,
pub bounds: Aabb3,
}
@@ -225,7 +229,7 @@ impl DisplayMesh {
for p in [a, b, c] {
self.bounds.add(p);
}
self.surface_tris.push(SurfaceTri { p: [a, b, c], normal });
self.surface_tris.push(SurfaceTri { p: [a, b, c], normal, body: self.bodies });
let face = tri_face.get(t).copied().unwrap_or(u32::MAX);
let [v0, v1, v2] = [vertex(i0), vertex(i1), vertex(i2)];
for (u, v) in [(v0, v1), (v1, v2), (v2, v0)] {
@@ -241,6 +245,17 @@ impl DisplayMesh {
self.surface_edges.push([a, b]);
}
}
self.bodies = self.bodies.saturating_add(1);
}
/// The colour of a geometry triangle: `single` when there is one body, else body `k`'s
/// colour from `palette` (cycling), so the bodies of a design, such as the cells of a
/// partitioned part, tell apart.
pub fn body_color<C: Copy>(&self, tri: &SurfaceTri, single: C, palette: &[C]) -> C {
if self.bodies <= 1 || palette.is_empty() {
return single;
}
palette.get(tri.body as usize % palette.len()).copied().unwrap_or(single)
}
/// Where node `label` is drawn, if it is (nodes are kept in label order).
@@ -401,6 +416,13 @@ mod tests {
welded.add_surface(&soup, &[[0, 1, 2], [3, 4, 5]], &[0, 0]);
assert_eq!(welded.surface_edges.len(), 4);
let mut hostile = DisplayMesh::default();
let mut two = DisplayMesh::default();
two.add_surface(&p, &[[0, 1, 2]], &[0]);
two.add_surface(&p, &[[0, 2, 3]], &[0]);
assert_eq!(two.bodies, 2);
let colors: Vec<u8> = two.surface_tris.iter().map(|t| two.body_color(t, 0u8, &[7, 8])).collect();
assert_eq!(colors, vec![7, 8]);
assert_eq!(one_face.body_color(&one_face.surface_tris[0], 0u8, &[7, 8]), 0);
hostile.add_surface(&[[f64::NAN, 0., 0.], [1., 0., 0.], [0., 1., 0.]], &[[0, 1, 2], [u32::MAX, 0, 1]], &[]);
assert!(hostile.surface_tris.is_empty() && hostile.surface_edges.is_empty());
}
+8 -5
View File
@@ -61,6 +61,9 @@ pub struct ElementStress {
pub points: Vec<PointStress>,
/// Extrapolated to the element's nodes, in its node order.
pub nodal: Vec<[f64; 6]>,
/// The equivalent plastic strain at the integration points (nonlinear steps with a plastic
/// material; empty otherwise).
pub plastic_strain: Vec<f64>,
}
/// The motion and reaction of a coupled reference point: translations along X, Y, Z, then
@@ -350,9 +353,9 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
// loads.
let (field, temperature) = match step.procedure {
Procedure::CoupledTemperatureDisplacement {} => {
let (labels, t) = crate::heat::temperatures(model, &resolved, step, limits)?;
let at = |l: &u32| labels.binary_search(l).ok().and_then(|i| t.get(i)).copied();
(Some(mesh.labels.iter().map(at).collect::<Vec<_>>()), Some(mesh.labels.iter().map(|l| at(l).unwrap_or(0.0)).collect()))
let field = crate::heat::temperatures_at(model, &resolved, step, limits, &mesh.labels)?;
let temperature = field.iter().map(|t| t.unwrap_or(0.0)).collect();
(Some(field), Some(temperature))
}
_ => (temperature_loads(model, &resolved, &mesh, step)?, None),
};
@@ -431,7 +434,7 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
*c += 1;
}
}
elements.push(ElementStress { label: e.label, kind: e.kind, points, nodal });
elements.push(ElementStress { label: e.label, kind: e.kind, points, nodal, plastic_strain: Vec::new() });
}
for t in &mesh.trusses {
let truss = t.truss()?;
@@ -448,7 +451,7 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
*c += 1;
}
}
elements.push(ElementStress { label: t.label, kind: t.kind, points, nodal });
elements.push(ElementStress { label: t.label, kind: t.kind, points, nodal, plastic_strain: Vec::new() });
}
elements.sort_by_key(|e| e.label);
let (mut shells, mut beams) = (Vec::new(), Vec::new());
+7
View File
@@ -414,6 +414,13 @@ pub(crate) fn temperatures(model: &Model, resolved: &Resolved, step: &Step, limi
Ok((s.mesh.labels, t))
}
/// The steady temperatures of a step's thermal conditions at the nodes `labels` (sorted), `None`
/// at the nodes conduction does not reach.
pub(crate) fn temperatures_at(model: &Model, resolved: &Resolved, step: &Step, limits: &Limits, labels: &[u32]) -> Result<Vec<Option<f64>>> {
let (solved, t) = temperatures(model, resolved, step, limits)?;
Ok(labels.iter().map(|l| solved.binary_search(l).ok().and_then(|i| t.get(i)).copied()).collect())
}
/// Solve a heat transfer step: the step named `step`, or the model's only one.
pub fn solve_heat(model: &Model, step: Option<&str>, limits: &Limits) -> Result<HeatResult> {
let resolved = model.resolve()?;
+262 -50
View File
@@ -25,7 +25,9 @@
//! for the solution of nonlinear finite element equations*, CMAME 22:59–85, 1980);
//!
//! or when the force residual is a thousandth of its tolerance (round-off: no correction can
//! improve it).
//! improve it). With path-dependent materials, an increment that starts within the force
//! tolerance (a predictor that already solved it) waives the energy criterion, whose reference
//! would be round-off.
//!
//! **Automatic incrementation.** Each increment starts from the linear extrapolation of the
//! last converged one (the free motion scaled by the ratio of their sizes), which keeps the
@@ -50,10 +52,31 @@
//!
//! **Elements.** Solids (tet4, tet10, wedge6, hex8, hex20 full and reduced, hybrid hex8, hex20
//! and tet10) and 2D plane strain and axisymmetric elements, total Lagrangian
//! ([`fieldcraft_fem::finite`]); without nonlinear geometry the elements are the linear ones
//! (every material is elastic today, so such a step is the linear solution reached by Newton).
//! ([`fieldcraft_fem::finite`]); without nonlinear geometry an element of a plastic material
//! takes the same loops with small strain, and an elastic one its linear stiffness (K u).
//! Springs act along their fixed directions. Trusses, shells and beams (large rotations), plane
//! stress, incompatible modes, temperature and centrifugal loads are refused by name.
//! stress, incompatible modes (with nonlinear geometry, or with an inelastic material) and
//! centrifugal loads are refused by name.
//!
//! **Thermal strain.** The step's temperatures are its temperature loads, or, when it has
//! temperature boundary conditions or heat loads, their steady conduction solved first (one-way
//! coupling, as the linear coupled temperature–displacement step). They ramp with the load
//! factor from each material's reference temperature, ΔT(λ) = λ (T − T_ref). With nonlinear
//! geometry the deformation gradient splits multiplicatively, F = F_m F_θ with
//! F_θ = diag(1 + αᵢ ΔT), the material sees F_m and the stress is J_θ τ_m per reference volume
//! (fem's `Temperature`); with small strain the split is additive, ε_m = ε − α ΔT; elements on
//! their linear stiffness take the equivalent nodal forces, a dead load. A Johnson–Cook material
//! softens at the point's temperature.
//!
//! **Plasticity** (`Material::plastic`, J2 with the radial return explicit dynamics uses,
//! [`fieldcraft_fem::elastoplastic`]): Simo's exponential return map at finite strain, the
//! additive radial return at small strain, both with their consistent tangents, so Newton keeps
//! its quadratic rate. The plastic history is committed when an increment converges and
//! discarded when it is cut back. A static step has no time, so the Johnson–Cook strain-rate term
//! is refused (C = 0 is the rate-independent law), and kinematic hardening at finite strain is
//! refused (its back stress breaks the principal-frame return); damage, erosion and adiabatic
//! heating act in explicit dynamics only, with a warning. Results add the equivalent plastic
//! strain at the integration points.
//! Couplings stay the linear (small-rotation) constraints of the linear solver, with a warning.
//!
//! Element forces and tangents are computed in parallel and added in label order, so results are
@@ -62,9 +85,11 @@
use fieldcraft_fem::constitutive::MaterialModel;
use fieldcraft_fem::dense::Matrix;
use fieldcraft_fem::element::{Extrapolation, extrapolation};
use fieldcraft_fem::finite::{Kinematics, Temperature};
use fieldcraft_fem::plane::Theory;
use fieldcraft_fem::quadrature::Rule;
use fieldcraft_fem::{DofKind, DofSet, ElementType, PointStress, face, von_mises};
use fieldcraft_model::{Extent, Load, Model, NonlinearStatic, Procedure, Step};
use fieldcraft_model::{BoundaryCondition, Extent, Hardening, Load, Model, NonlinearStatic, Plastic, Procedure, SectionKind, Step};
use fieldcraft_parallel as parallel;
use serde::Serialize;
@@ -75,7 +100,7 @@ use crate::analysis::{
use crate::cholesky::FactorError;
use crate::constraints::Reduction;
use crate::sparse::{Csr, add_block};
use crate::system::{Mesh, inelastic_warnings, loads, translations};
use crate::system::{Mesh, add_element_thermal_loads, loads, temperature_loads, translations};
use crate::{Error, Limits, Result};
/// One Newton iteration of an increment.
@@ -179,6 +204,9 @@ struct Problem<'a> {
element_kinds: Vec<DofSet>,
/// Each element's material model.
materials: Vec<MaterialModel>,
/// The step's temperature field at λ = 1, per mesh node (`None` where the field leaves a
/// node at its material's reference temperature); `None` without one.
field: Option<Vec<Option<f64>>>,
/// The tangent's pattern, zero.
pattern: Csr,
}
@@ -236,21 +264,45 @@ impl Problem<'_> {
self.element_dofs.get(e).map(|dofs| dofs.iter().map(|d| u.get(*d).copied().unwrap_or(0.0)).collect()).unwrap_or_default()
}
/// How element `e` is evaluated: total Lagrangian with nonlinear geometry, with small
/// strain when its material is inelastic, and by its linear stiffness otherwise (`None`).
fn kinematics(&self, e: usize) -> Option<Kinematics> {
if self.finite { Some(Kinematics::Finite) } else { self.materials.get(e).filter(|m| m.is_path_dependent()).map(|_| Kinematics::Small) }
}
/// The temperatures of element `e` at load factor λ, which ramps them from the reference
/// temperature to the field's (J2 Johnson–Cook softening and thermal strain): the
/// material's expansion and reference temperature, or no expansion from the Johnson–Cook
/// room temperature; `None` without a field or for a material neither affects.
fn temperature(&self, e: usize, lambda: f64) -> Option<Temperature> {
let field = self.field.as_ref()?;
let el = self.mesh.elements.get(e)?;
let softens = el.plastic.as_ref().is_some_and(|j2| j2.is_temperature_dependent());
let (alpha, reference) = match el.expansion {
Some(x) => x,
None if softens => ([0.0; 3], el.plastic.as_ref().map_or(0.0, |j2| j2.initial_temperature())),
None => return None,
};
let nodal: Vec<f64> =
el.nodes.iter().map(|n| field.get(*n).copied().flatten().map_or(reference, |t| reference + lambda * (t - reference))).collect();
(softens || nodal.iter().any(|t| *t != reference)).then_some(Temperature { alpha, reference, nodal })
}
/// Internal forces and the tangent of element `e` at displacements `u` (all degrees of
/// freedom).
fn element(&self, e: usize, u: &[f64], tangent: bool, committed: &[f64]) -> Result<(Vec<f64>, Option<Matrix>, Vec<f64>)> {
/// freedom) and load factor λ.
fn element(&self, e: usize, u: &[f64], lambda: f64, tangent: bool, committed: &[f64]) -> Result<(Vec<f64>, Option<Matrix>, Vec<f64>)> {
let el = self.mesh.elements.get(e).ok_or_else(|| Error::Internal("element index".into()))?;
let at = |err| Error::element(el.label, err);
let ue = self.element_vector(e, u);
let kernel = el.kernel()?;
if !self.finite {
let Some(kin) = self.kinematics(e) else {
let k = kernel.stiffness(&el.d).map_err(at)?;
let f = k.mul_vec(&ue);
return Ok((f, tangent.then_some(k), committed.to_vec()));
}
};
let material = self.materials.get(e).ok_or_else(|| Error::Internal("element material".into()))?;
let mut updated = committed.to_vec();
let r = kernel.finite_strain(material, &ue, committed, &mut updated, tangent).map_err(at)?;
let r = kernel.nonlinear(material, &ue, kin, self.temperature(e, lambda).as_ref(), committed, &mut updated, tangent).map_err(at)?;
Ok((r.force, r.tangent, updated))
}
@@ -284,7 +336,7 @@ impl Problem<'_> {
for round in parallel::blocks(self.mesh.elements.len(), ROUND) {
let start = round.start;
let computed = parallel::map_grained(round.len(), GRAIN, |i| {
self.element(start + i, u, tangent, committed.get(start + i).map(Vec::as_slice).unwrap_or_default())
self.element(start + i, u, lambda, tangent, committed.get(start + i).map(Vec::as_slice).unwrap_or_default())
});
for (i, c) in computed.into_iter().enumerate() {
let (f, ke, updated) = c?;
@@ -381,6 +433,10 @@ struct Run<'a> {
/// Every element's material history at the end of the last converged increment
/// (integration points × the material's history length).
committed: Vec<Vec<f64>>,
/// Whether a material keeps history (the tangent predictor and its convergence rules).
path_dependent: bool,
/// Whether the undeformed model's tangent factors, once asked.
undeformed_stable: Option<bool>,
}
/// The outcome of a converged increment.
@@ -404,12 +460,53 @@ impl Run<'_> {
Ok(self.problem.evaluate(&self.problem.red.expand_scaled(q, lambda), lambda, tangent, &self.committed)?)
}
/// Whether the tangent of the undeformed, unloaded model is positive definite: a first
/// tangent that is not, at a state the first increment has already strained, is then the
/// material or the geometry (a cutback), not a model that can move freely (an error).
fn undeformed_is_stable(&mut self) -> std::result::Result<bool, Failure> {
if let Some(stable) = self.undeformed_stable {
return Ok(stable);
}
let mut e = self.problem.evaluate(&vec![0.0; self.problem.mesh.dofs()], 0.0, true, &self.committed)?;
let k = e.tangent.take().ok_or_else(|| Failure::Fatal(Error::Internal("tangent".into())))?;
let [kff] = self.problem.red.reduce([&k])?;
let stable = factor_raw(&kff, self.limits).is_ok();
self.undeformed_stable = Some(stable);
Ok(stable)
}
/// The tangent predictor of an increment from the converged `q0` at load factor `from` to
/// `to`, and its work δq · r (the increment's reference for the energy criterion, since the
/// Newton iterations after it only correct it): one linear solve with the tangent of the converged state for the change of the
/// loads and of the prescribed displacements, K δq = T_fᵀ (f_ext(to) − f_int(u₀) − K T_p Δλ u_p).
/// It spreads a prescribed motion into the interior before the material sees it, which a
/// path-dependent material needs: started from the boundary's jump alone, the elements next
/// to it would flow plastically far beyond the solution. `None` when the tangent cannot be
/// factored (the increment then starts from `q0`).
fn tangent_predictor(&mut self, q0: &[f64], from: f64, to: f64) -> std::result::Result<Option<(Vec<f64>, f64)>, Failure> {
let red = self.problem.red;
let mut e = self.problem.evaluate(&red.expand_scaled(q0, from), to, true, &self.committed)?;
let k = e.tangent.take().ok_or_else(|| Failure::Fatal(Error::Internal("tangent".into())))?;
let jump = red.expand_scaled(&vec![0.0; red.neq()], to - from);
let mut kj = vec![0.0; jump.len()];
k.mul(&jump, &mut kj);
let rf: Vec<f64> = free_residual(red, &e).iter().zip(red.restrict(&kj)).map(|(a, b)| a - b).collect();
let mut dq = rf.clone();
let [kff] = red.reduce([&k])?;
drop(k);
let Ok(chol) = factor_raw(&kff, self.limits) else { return Ok(None) };
chol.solve(&mut dq)?;
let energy = dot(&dq, &rf).abs();
Ok(dq.iter().all(|v| v.is_finite()).then(|| (q0.iter().zip(&dq).map(|(a, d)| a + d).collect(), energy)))
}
/// Newton iterations from the converged `q0` (at the previous load factor) to load factor
/// `lambda`.
fn increment(
&mut self,
q0: &[f64],
predictor: Option<&[f64]>,
predicted_energy: Option<f64>,
lambda: f64,
size: f64,
iterations: &mut Vec<Iteration>,
@@ -424,8 +521,14 @@ impl Run<'_> {
Some(Err(Failure::Cutback(_))) | None => (q0.to_vec(), self.state(q0, lambda, true)?),
};
let mut r = self.residual(&eval);
// With path-dependent materials, an increment that starts within the force tolerance (a
// predictor that already solved it) has only round-off left for the energy criterion to
// measure against: it is waived.
let started_converged = self.path_dependent && self.force_floor.is_some() && norm_inf(&r) <= tol.force * self.scale();
let prescribed = size * red.largest_prescribed();
let mut first_energy: Option<f64> = None;
// The energy criterion's reference: the first correction's work, or the tangent
// predictor's when the increment started from one that is still in use.
let mut first_energy: Option<f64> = predicted_energy.filter(|_| q.as_slice() != q0);
let mut history: Vec<f64> = Vec::new();
let max_iterations = usize::try_from(self.settings.max_iterations).unwrap_or(usize::MAX);
for _ in 0..max_iterations {
@@ -452,7 +555,7 @@ impl Run<'_> {
drop(k);
let chol = match factor_raw(&kff, self.limits) {
Ok(c) => c,
Err(FactorError::NotPositiveDefinite { .. }) if !first => {
Err(FactorError::NotPositiveDefinite { .. }) if !first || self.undeformed_is_stable()? => {
return Err(Failure::Cutback(
"the tangent stiffness is not positive definite (a limit point, a bifurcation or an unstable material)".into(),
));
@@ -487,7 +590,8 @@ impl Run<'_> {
if !it.force.is_finite() {
return Err(Failure::Cutback("the residual is not finite".into()));
}
if (it.force <= tol.force && it.displacement <= tol.displacement && it.energy <= tol.energy) || it.force <= ROUND_OFF * tol.force {
let energy_met = it.energy <= tol.energy || started_converged;
if (it.force <= tol.force && it.displacement <= tol.displacement && energy_met) || it.force <= ROUND_OFF * tol.force {
return Ok(Converged { q, evaluation: eval });
}
history.push(it.force);
@@ -574,13 +678,10 @@ fn check_supported(model: &Model, mesh: &Mesh, step: &Step, settings: &Nonlinear
return Err(at(format!("a hyperelastic material without nonlinear geometry (element {})", e.label)));
}
}
for name in &step.loads {
match model.load(name) {
Some(Load::Temperature { .. }) => return Err(at(format!("temperature loads (load `{name}`): thermal strain"))),
Some(Load::Centrifugal { .. }) => return Err(at(format!("centrifugal loads (load `{name}`), which depend on the deformed position"))),
_ => {}
}
if let Some(name) = step.loads.iter().find(|n| matches!(model.load(n), Some(Load::Centrifugal { .. }))) {
return Err(at(format!("centrifugal loads (load `{name}`), which depend on the deformed position")));
}
plasticity(model, step, settings, warnings)?;
if settings.nonlinear_geometry
&& let Some(name) = model.couplings().keys().next()
{
@@ -591,6 +692,62 @@ fn check_supported(model: &Model, mesh: &Mesh, step: &Step, settings: &Nonlinear
Ok(())
}
/// Refuse the plasticity a static step cannot take, and warn about what it leaves out, for the
/// materials of the solid and 2D sections.
fn plasticity(model: &Model, step: &Step, settings: &NonlinearStatic, warnings: &mut Vec<String>) -> Result<()> {
let at = |what: String| Error::NotSupported(format!("{what} in nonlinear steps (step `{}`)", step.name));
let used: std::collections::BTreeSet<&str> = model
.sections()
.values()
.filter(|s| matches!(s.kind, SectionKind::Solid {}) || Theory::from_section(&s.kind).is_some())
.map(|s| s.material.as_str())
.collect();
for name in used {
let Some(m) = model.material(name) else { continue };
match &m.plastic {
Some(Plastic::JohnsonCook { c, specific_heat, .. }) => {
if *c != 0.0 {
return Err(at(format!(
"the Johnson–Cook strain-rate term of material `{name}` (C = {c}): a static step has no time, so no strain rate; set C to 0 or use explicit dynamics"
)));
}
if specific_heat.is_some() {
warnings.push(format!(
"material `{name}`: a static step is isothermal, so plastic work does not heat it (adiabatic heating acts in explicit dynamics)"
));
}
}
Some(Plastic::Bilinear { hardening: Hardening::Kinematic, .. }) if settings.nonlinear_geometry => {
return Err(at(format!(
"the kinematic hardening of material `{name}` at finite strain (its back stress is not coaxial with the elastic stretch, which the exponential return map needs); use nonlinear_geometry false or isotropic hardening"
)));
}
_ => {}
}
if m.plastic.is_some() && (m.damage.is_some() || m.erosion.is_some()) {
warnings.push(format!("material `{name}`: damage and erosion act in explicit dynamics; this static step leaves them out"));
}
}
Ok(())
}
/// The step's temperature field per mesh node: the steady heat solution of its temperature
/// boundary conditions and heat loads when it has some (one-way coupling, as the coupled
/// temperature–displacement step of the linear solver), or its temperature loads.
fn temperatures(model: &Model, resolved: &fieldcraft_model::Resolved, mesh: &Mesh, step: &Step, limits: &Limits) -> Result<Option<Vec<Option<f64>>>> {
let conducts = step.boundary_conditions.iter().any(|b| model.boundary_conditions().get(b).is_some_and(BoundaryCondition::is_thermal))
|| step.loads.iter().any(|l| model.load(l).is_some_and(Load::is_thermal));
let loads = temperature_loads(model, resolved, mesh, step)?;
match (conducts, loads) {
(true, Some(_)) => Err(Error::Invalid(format!(
"step `{}` gives temperatures both by temperature loads and by heat conditions (temperature boundary conditions, heat loads); use one",
step.name
))),
(true, None) => Ok(Some(crate::heat::temperatures_at(model, resolved, step, limits, &mesh.labels)?)),
(false, field) => Ok(field),
}
}
/// The follower pressures of a step (with nonlinear geometry), and the step without them for
/// the dead loads.
fn followers(model: &Model, resolved: &fieldcraft_model::Resolved, mesh: &Mesh, step: &Step) -> Result<(Vec<Follower>, Step)> {
@@ -645,7 +802,6 @@ pub fn solve_nonlinear(model: &Model, step: Option<&str>, limits: &Limits) -> Re
settings.validate()?;
let mesh = Mesh::new(model, &resolved, limits.max_dofs)?;
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).chain(mesh.warnings.iter().cloned()).collect();
warnings.extend(inelastic_warnings(model));
check_supported(model, &mesh, step, settings, &mut warnings)?;
let (graph, order) = pattern(&mesh);
ignored_dashpots(&mesh, &mut warnings);
@@ -657,6 +813,7 @@ pub fn solve_nonlinear(model: &Model, step: Option<&str>, limits: &Limits) -> Re
let (follower_loads, dead_step) =
if settings.nonlinear_geometry { followers(model, &resolved, &mesh, step)? } else { (Vec::new(), step.clone()) };
let dead = loads(model, &resolved, &mesh, &dead_step, &red.active)?;
let field = temperatures(model, &resolved, &mesh, step, limits)?;
let springs = if mesh.discrete.springs.iter().any(|s| !s.dashpot) {
let mut s = graph.zero_matrix(&mesh.layout);
mesh.discrete.add_stiffness(&mut s)?;
@@ -673,7 +830,7 @@ pub fn solve_nonlinear(model: &Model, step: Option<&str>, limits: &Limits) -> Re
mesh.layout.element_dofs(&e.nodes, *kinds).ok_or_else(|| Error::Internal(format!("element {} degrees of freedom", e.label)))
})
.collect::<Result<Vec<_>>>()?;
let problem = Problem {
let mut problem = Problem {
mesh: &mesh,
red: &red,
dead,
@@ -682,9 +839,24 @@ pub fn solve_nonlinear(model: &Model, step: Option<&str>, limits: &Limits) -> Re
finite: settings.nonlinear_geometry,
element_dofs,
element_kinds,
materials: mesh.elements.iter().map(|e| MaterialModel::from_elasticity(&e.d, e.hyperelastic.as_ref())).collect(),
materials: mesh
.elements
.iter()
.map(|e| match &e.plastic {
Some(j2) => MaterialModel::Plastic(j2.clone()),
None => MaterialModel::from_elasticity(&e.d, e.hyperelastic.as_ref()),
})
.collect(),
field,
pattern: graph.zero_matrix(&mesh.layout),
};
// Elements on their linear stiffness take the thermal strain as the equivalent nodal
// forces, a dead load proportional to the load factor like the temperatures.
if let Some(field) = problem.field.take() {
let linear: Vec<bool> = (0..mesh.elements.len()).map(|i| problem.kinematics(i).is_none()).collect();
add_element_thermal_loads(&mut problem.dead, &mesh, &field, |i| linear.get(i).copied().unwrap_or(false))?;
problem.field = Some(field);
}
let (lo, hi) = mesh.xyz.iter().fold(([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]), |(lo, hi), p| {
(std::array::from_fn(|k| lo[k].min(p[k])), std::array::from_fn(|k| hi[k].max(p[k])))
});
@@ -698,8 +870,20 @@ pub fn solve_nonlinear(model: &Model, step: Option<&str>, limits: &Limits) -> Re
// The undeformed state first: an element that is bad before anything moves is an error in
// the model, not a reason to cut increments back.
problem.evaluate(&vec![0.0; mesh.dofs()], 0.0, false, &committed)?;
let mut run =
Run { problem: &problem, settings, limits, force_scale: 0.0, force_floor: None, length, iterations: 0, factor_entries: 0, committed };
let path_dependent = problem.materials.iter().any(MaterialModel::is_path_dependent);
let mut run = Run {
problem: &problem,
settings,
limits,
force_scale: 0.0,
force_floor: None,
length,
iterations: 0,
factor_entries: 0,
committed,
path_dependent,
undeformed_stable: None,
};
let mut history = History::default();
let (mut frame_values, mut frames_full) = (0usize, false);
@@ -718,32 +902,50 @@ pub fn solve_nonlinear(model: &Model, step: Option<&str>, limits: &Limits) -> Re
};
keep_frame(&mut history, 0.0, &vec![0.0; mesh.dofs()], &mut warnings);
let mut q = vec![0.0; red.neq()];
let mut lambda = 0.0f64;
// The step fraction t and the load factor λ(t) of the step's load path (λ = t without one).
let (mut t, mut lambda) = (0.0f64, 0.0f64);
let mut size = settings.initial_increment;
let mut fast = 0usize;
let mut last: Option<Evaluation> = None;
// The last converged increment's free motion and the change of the load factor over it.
let mut previous: Option<(Vec<f64>, f64)> = None;
let max_increments = usize::try_from(settings.max_increments).unwrap_or(usize::MAX);
while lambda < 1.0 {
while t < 1.0 {
if history.increments.len() >= max_increments {
return Err(Error::Limit(format!(
"step `{}`: {} increments reached the step's cap at load factor {lambda:.6} (raise max_increments)",
step.name, settings.max_increments
)));
}
let remaining = 1.0 - lambda;
let (target, this) = if size >= remaining * (1.0 - 1e-9) { (1.0, remaining) } else { (lambda + size, size) };
// An increment ends at the next point of the load path at the latest.
let turn = settings.next_turn(t);
let remaining = turn - t;
let (target, this) = if size >= remaining * (1.0 - 1e-9) { (turn, remaining) } else { (t + size, size) };
let target_lambda = settings.load_factor(target);
let change = if settings.load_path.is_some() { target_lambda - lambda } else { this };
let mut iterations = Vec::new();
let number = history.increments.len() + 1;
// Linear extrapolation of the last converged increment's free motion.
let predictor: Option<Vec<f64>> =
previous.as_ref().map(|(dq, prev): &(Vec<f64>, f64)| q.iter().zip(dq).map(|(a, d)| a + this / prev * d).collect());
match run.increment(&q, predictor.as_deref(), target, this, &mut iterations) {
// Linear extrapolation of the last converged increment's free motion, in proportion to
// the change of the load factor (so it turns with the load path).
let mut predictor: Option<Vec<f64>> = previous
.as_ref()
.filter(|(_, prev)| *prev != 0.0)
.map(|(dq, prev): &(Vec<f64>, f64)| q.iter().zip(dq).map(|(a, d)| a + change / prev * d).collect());
// Path-dependent materials start without one from the tangent predictor.
let mut predicted_energy = None;
if predictor.is_none() && path_dependent {
match run.tangent_predictor(&q, lambda, target_lambda) {
Ok(Some((p, energy))) => (predictor, predicted_energy) = (Some(p), Some(energy)),
Ok(None) | Err(Failure::Cutback(_)) => {}
Err(Failure::Fatal(e)) => return Err(e),
}
}
match run.increment(&q, predictor.as_deref(), predicted_energy, target_lambda, change.abs(), &mut iterations) {
Ok(c) => {
previous = Some((c.q.iter().zip(&q).map(|(a, b)| a - b).collect(), this));
previous = Some((c.q.iter().zip(&q).map(|(a, b)| a - b).collect(), change));
let count = iterations.len();
history.increments.push(Increment { number, load_factor: target, size: this, iterations, converged: true, cutback: None });
lambda = target;
history.increments.push(Increment { number, load_factor: target_lambda, size: this, iterations, converged: true, cutback: None });
(t, lambda) = (target, target_lambda);
q = c.q;
keep_frame(&mut history, lambda, &red.expand_scaled(&q, lambda), &mut warnings);
run.committed.clone_from(&c.evaluation.history);
@@ -760,7 +962,7 @@ pub fn solve_nonlinear(model: &Model, step: Option<&str>, limits: &Limits) -> Re
Err(Failure::Cutback(reason)) => {
history.increments.push(Increment {
number,
load_factor: target,
load_factor: target_lambda,
size: this,
iterations,
converged: false,
@@ -770,7 +972,7 @@ pub fn solve_nonlinear(model: &Model, step: Option<&str>, limits: &Limits) -> Re
size = this * CUTBACK;
if size < settings.min_increment * (1.0 - 1e-9) {
return Err(Error::NotConverged(format!(
"step `{}` stopped at load factor {lambda:.6}: the increment to {target:.6} failed ({reason}) and a smaller one would be below the minimum increment {}",
"step `{}` stopped at load factor {lambda:.6} (step fraction {t:.6}): the increment to {target_lambda:.6} failed ({reason}) and a smaller one would be below the minimum increment {}",
step.name, settings.min_increment
)));
}
@@ -790,7 +992,7 @@ pub fn solve_nonlinear(model: &Model, step: Option<&str>, limits: &Limits) -> Re
"follower pressure: the skew part of its load stiffness ({a:.1e} of it, over the free degrees of freedom) is left out of the tangent; the solution is exact, Newton may converge more slowly"
));
}
finish(&mesh, step, &red, &problem, u, evaluation, &run.committed, history, run.factor_entries, warnings)
finish(&mesh, step, &red, &problem, u, lambda, evaluation, &run.committed, history, run.factor_entries, warnings)
}
/// The final state as a static result: displacements, reactions, Cauchy stresses and
@@ -802,6 +1004,7 @@ fn finish(
red: &Reduction,
problem: &Problem,
u: Vec<f64>,
lambda: f64,
e: Evaluation,
committed: &[Vec<f64>],
history: History,
@@ -826,27 +1029,36 @@ fn finish(
.zip(point_motion.iter().zip(&point_reaction))
.map(|(p, (d, r))| PointResult { name: p.name.clone(), label: p.label, displacement: *d, reaction: *r })
.collect();
let all_points = parallel::map_grained(mesh.elements.len(), GRAIN, |i| -> Result<(Vec<PointStress>, &'static ElementType, Rule)> {
#[allow(clippy::type_complexity)]
let all_points = parallel::map_grained(mesh.elements.len(), GRAIN, |i| -> Result<(Vec<PointStress>, Vec<f64>, &'static ElementType, Rule)> {
let el = mesh.elements.get(i).ok_or_else(|| Error::Internal("element index".into()))?;
let at = |err| Error::element(el.label, err);
let ue = problem.element_vector(i, &u);
let kernel = el.kernel()?;
let points = if problem.finite {
let material = problem.materials.get(i).ok_or_else(|| Error::Internal("element material".into()))?;
kernel.finite_strain_stresses(material, &ue, committed.get(i).map(Vec::as_slice).unwrap_or_default())
} else {
let per_node: Vec<[f64; 3]> = el.nodes.iter().map(|n| displacement.get(*n).copied().unwrap_or_default()).collect();
kernel.stresses_with(&el.d, &per_node, None)
let history = committed.get(i).map(Vec::as_slice).unwrap_or_default();
let material = problem.materials.get(i).ok_or_else(|| Error::Internal("element material".into()))?;
let points = match problem.kinematics(i) {
Some(kin) => kernel.nonlinear_stresses(material, &ue, kin, problem.temperature(i, lambda).as_ref(), history),
None => {
let per_node: Vec<[f64; 3]> = el.nodes.iter().map(|n| displacement.get(*n).copied().unwrap_or_default()).collect();
let thermal = problem.field.as_deref().and_then(|f| el.thermal_strain(f)).map(|mut t| {
t.delta.iter_mut().for_each(|d| *d *= lambda);
t
});
kernel.stresses_with(&el.d, &per_node, thermal.as_ref())
}
}
.map_err(at)?;
Ok((points, kernel.ty(), kernel.rule()))
let len = material.history_len();
let plastic: Vec<f64> = if len > 0 { history.chunks(len).filter_map(|h| material.plastic_strain(h)).collect() } else { Vec::new() };
Ok((points, plastic, kernel.ty(), kernel.rule()))
});
let mut extrapolations: Vec<(&'static ElementType, Rule, Extrapolation)> = Vec::new();
let mut elements = Vec::with_capacity(mesh.elements.len());
let mut sum = vec![[0.0; 6]; mesh.labels.len()];
let mut count = vec![0u32; mesh.labels.len()];
for (el, computed) in mesh.elements.iter().zip(all_points) {
let (points, ty, rule) = computed?;
let (points, plastic_strain, ty, rule) = computed?;
let values: Vec<[f64; 6]> = points.iter().map(|p| p.stress).collect();
let index = match extrapolations.iter().position(|(t, r, _)| std::ptr::eq(*t, ty) && *r == rule) {
Some(i) => i,
@@ -864,7 +1076,7 @@ fn finish(
*c += 1;
}
}
elements.push(ElementStress { label: el.label, kind: el.kind, points, nodal });
elements.push(ElementStress { label: el.label, kind: el.kind, points, nodal, plastic_strain });
}
let stress: Vec<[f64; 6]> = sum.iter().zip(&count).map(|(s, c)| s.map(|v| if *c > 0 { v / f64::from(*c) } else { 0.0 })).collect();
let von_mises = stress.iter().map(von_mises).collect();
+97 -26
View File
@@ -11,12 +11,15 @@ use fieldcraft_fem::beam::{Beam, BeamSection};
use fieldcraft_fem::constitutive::MaterialModel;
use fieldcraft_fem::dense::Matrix;
use fieldcraft_fem::element::{Formulation, Solid, ThermalStrain};
use fieldcraft_fem::finite::{Kinematics, Temperature};
use fieldcraft_fem::plane::{Plane, Theory};
use fieldcraft_fem::plasticity::J2;
use fieldcraft_fem::shell::{Shell, ShellSection};
use fieldcraft_fem::truss::{Truss, young_modulus};
use fieldcraft_fem::{DofKind, DofLayout, DofSet, Elasticity, ElementDofs, PointStress, elasticity, face, plane};
use fieldcraft_model::nodal::edge_pressure;
use std::collections::BTreeSet;
use std::sync::Arc;
use fieldcraft_model::{Elastic, ElementKind, Hyperelastic, Integration, Load, Model, Resolved, SectionKind, Step, Target, centrifugal_acceleration};
@@ -41,6 +44,9 @@ pub struct Elem {
pub expansion: Option<([f64; 3], f64)>,
/// The large-strain option of the element's material (nonlinear steps).
pub hyperelastic: Option<Hyperelastic>,
/// The element's material's plasticity (nonlinear steps), shared by the elements of a
/// section.
pub plastic: Option<Arc<J2>>,
}
/// An element's kernel: a solid or a 2D element.
@@ -113,27 +119,39 @@ impl Kernel<'_> {
}
}
/// Finite-strain internal forces and tangent ([`fieldcraft_fem::finite`]) for the element's
/// displacements `u` (its degrees of freedom in order: 3 per node, or 2 for 2D elements).
pub fn finite_strain(
/// Nonlinear internal forces and tangent ([`fieldcraft_fem::finite`]) for the element's
/// displacements `u` (its degrees of freedom in order: 3 per node, or 2 for 2D elements),
/// total Lagrangian or with small strain.
#[allow(clippy::too_many_arguments)]
pub fn nonlinear(
&self,
m: &MaterialModel,
u: &[f64],
kin: Kinematics,
thermal: Option<&Temperature>,
committed: &[f64],
updated: &mut [f64],
tangent: bool,
) -> fieldcraft_fem::Result<fieldcraft_fem::finite::Finite> {
match self {
Kernel::Solid(s) => s.finite_strain(m, u.as_chunks::<3>().0, committed, updated, tangent),
Kernel::Plane(p) => p.finite_strain(m, u.as_chunks::<2>().0, committed, updated, tangent),
Kernel::Solid(s) => s.nonlinear(m, u.as_chunks::<3>().0, kin, thermal, committed, updated, tangent),
Kernel::Plane(p) => p.nonlinear(m, u.as_chunks::<2>().0, kin, thermal, committed, updated, tangent),
}
}
/// Cauchy stress and logarithmic strain at the integration points, as [`Self::finite_strain`].
pub fn finite_strain_stresses(&self, m: &MaterialModel, u: &[f64], committed: &[f64]) -> fieldcraft_fem::Result<Vec<PointStress>> {
/// Cauchy stress and strain (logarithmic at finite strain) at the integration points, as
/// [`Self::nonlinear`].
pub fn nonlinear_stresses(
&self,
m: &MaterialModel,
u: &[f64],
kin: Kinematics,
thermal: Option<&Temperature>,
committed: &[f64],
) -> fieldcraft_fem::Result<Vec<PointStress>> {
match self {
Kernel::Solid(s) => s.finite_strain_stresses(m, u.as_chunks::<3>().0, committed),
Kernel::Plane(p) => p.finite_strain_stresses(m, u.as_chunks::<2>().0, committed),
Kernel::Solid(s) => s.nonlinear_stresses(m, u.as_chunks::<3>().0, kin, thermal, committed),
Kernel::Plane(p) => p.nonlinear_stresses(m, u.as_chunks::<2>().0, kin, thermal, committed),
}
}
@@ -204,6 +222,21 @@ fn thermal_strain(expansion: Option<([f64; 3], f64)>, nodes: &[usize], field: &[
delta.iter().any(|d| *d != 0.0).then_some(ThermalStrain { alpha, delta })
}
/// A solid or 2D element of a section, by model labels, before the mesh numbers its nodes.
struct SolidEntry {
label: u32,
kind: ElementKind,
nodes: Vec<u32>,
formulation: Formulation,
d: Elasticity,
density: Option<f64>,
theory: Option<Theory>,
dofs: ElementDofs,
expansion: Option<([f64; 3], f64)>,
hyperelastic: Option<Hyperelastic>,
plastic: Option<Arc<J2>>,
}
/// What a shell or beam element is made of.
#[derive(Debug, Clone, Copy)]
pub enum Section {
@@ -468,6 +501,13 @@ impl Mesh {
continue;
}
let d = elasticity(elastic).map_err(|e| Error::Invalid(format!("material `{}`: {e}", section.material)))?;
let plastic = match &material.plastic {
Some(p) => Some(Arc::new(
J2::from_model(elastic, Some(p), material.damage.as_ref())
.map_err(|e| Error::Invalid(format!("material `{}`: {e}", section.material)))?,
)),
None => None,
};
for &label in &set.elements {
let el = model.element(label).ok_or_else(|| Error::Invalid(format!("element {label} is missing")))?;
let (formulation, dofs) = match theory {
@@ -476,20 +516,32 @@ impl Mesh {
}
.map_err(|e| Error::element(label, e))?;
let expansion = material.thermal.and_then(|t| t.expansion.map(|a| (a.axes(), t.reference_temperature)));
elements.push((label, el.kind, el.nodes.clone(), formulation, d, material.density, theory, dofs, expansion, material.hyperelastic));
elements.push(SolidEntry {
label,
kind: el.kind,
nodes: el.nodes.clone(),
formulation,
d,
density: material.density,
theory,
dofs,
expansion,
hyperelastic: material.hyperelastic,
plastic: plastic.clone(),
});
}
}
if let (Some(_), Some(name)) = (planar, model.couplings().keys().next()) {
return Err(Error::NotSupported(format!("couplings in 2D models (coupling `{name}`)")));
}
elements.sort_by_key(|e| e.0);
elements.sort_by_key(|e| e.label);
trusses.sort_by_key(|e| e.0);
structural.sort_by_key(|e| e.0);
let translations = translation_kinds(planar.is_some());
// What discrete elements need: at mesh nodes (some only they use), and at reference
// points, which then get their six degrees of freedom like coupled ones.
let needs: Vec<(u32, DofSet)> = crate::discrete::needs(resolved, translations).collect();
let mut labels: Vec<u32> = elements.iter().flat_map(|e| e.2.iter().copied()).collect();
let mut labels: Vec<u32> = elements.iter().flat_map(|e| e.nodes.iter().copied()).collect();
labels.extend(trusses.iter().flat_map(|e| e.2.iter().copied()));
labels.extend(structural.iter().flat_map(|e| e.2.iter().copied()));
labels.extend(needs.iter().map(|n| n.0).filter(|l| model.node(*l).is_some()));
@@ -507,7 +559,7 @@ impl Mesh {
.collect::<Result<_>>()?;
let mut layout = DofLayout::builder();
for e in &elements {
layout.element(&e.2, e.7);
layout.element(&e.nodes, e.dofs);
}
for e in &trusses {
layout.element(&e.2, fieldcraft_fem::ElementDofs::new(translations));
@@ -542,10 +594,22 @@ impl Mesh {
warnings,
discrete: crate::discrete::Discrete::default(),
};
for (label, kind, nodes, formulation, d, density, theory, _, expansion, hyperelastic) in elements {
let idx: Vec<usize> = nodes.iter().filter_map(|n| mesh.node(*n)).collect();
for e in elements {
let idx: Vec<usize> = e.nodes.iter().filter_map(|n| mesh.node(*n)).collect();
let exyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
mesh.elements.push(Elem { label, kind, nodes: idx, xyz: exyz, formulation, d, density, theory, expansion, hyperelastic });
mesh.elements.push(Elem {
label: e.label,
kind: e.kind,
nodes: idx,
xyz: exyz,
formulation: e.formulation,
d: e.d,
density: e.density,
theory: e.theory,
expansion: e.expansion,
hyperelastic: e.hyperelastic,
plastic: e.plastic,
});
}
for (label, kind, nodes, young, area, density, expansion) in trusses {
let idx: Vec<usize> = nodes.iter().filter_map(|n| mesh.node(*n)).collect();
@@ -881,13 +945,7 @@ pub(crate) fn hold(slot: Option<&mut Option<f64>>, value: f64, conflict: impl Fn
/// Add the equivalent nodal forces of the thermal strains of a temperature field.
pub fn add_thermal_loads(f: &mut [f64], mesh: &Mesh, field: &[Option<f64>]) -> Result<()> {
for e in &mesh.elements {
let Some(th) = e.thermal_strain(field) else { continue };
let forces = e.kernel()?.thermal_load(&e.d, &th).map_err(|err| Error::element(e.label, err))?;
for (node, v) in e.nodes.iter().zip(forces) {
add(f, &translations(mesh, *node)?, v);
}
}
add_element_thermal_loads(f, mesh, field, |_| true)?;
for t in &mesh.trusses {
let Some(th) = t.thermal_strain(field) else { continue };
let forces = t.truss()?.thermal_load(t.young, t.area, &th).map_err(|err| Error::element(t.label, err))?;
@@ -898,6 +956,19 @@ pub fn add_thermal_loads(f: &mut [f64], mesh: &Mesh, field: &[Option<f64>]) -> R
Ok(())
}
/// Add the equivalent nodal forces of the thermal strains of the solid and 2D elements whose
/// index `keep` accepts.
pub fn add_element_thermal_loads(f: &mut [f64], mesh: &Mesh, field: &[Option<f64>], keep: impl Fn(usize) -> bool) -> Result<()> {
for (_, e) in mesh.elements.iter().enumerate().filter(|(i, _)| keep(*i)) {
let Some(th) = e.thermal_strain(field) else { continue };
let forces = e.kernel()?.thermal_load(&e.d, &th).map_err(|err| Error::element(e.label, err))?;
for (node, v) in e.nodes.iter().zip(forces) {
add(f, &translations(mesh, *node)?, v);
}
}
Ok(())
}
/// The uniform force per volume a step's gravity and body forces put on each shell and beam
/// element ([`Mesh::structural`] order): beam end forces are recovered with it.
pub fn structural_body_forces(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step) -> Result<Vec<[f64; 3]>> {
@@ -936,15 +1007,15 @@ fn body(f: &mut [f64], mesh: &Mesh, e: &Elem, b: [f64; 3]) -> Result<()> {
Ok(())
}
/// The linear solver uses the elastic constants alone: a warning for each material in use whose
/// plasticity, damage or erosion it leaves out.
/// The linear solvers use the elastic constants alone: a warning for each material in use whose
/// plasticity, damage or erosion they leave out.
pub fn inelastic_warnings(model: &Model) -> Vec<String> {
let used: BTreeSet<&str> = model.sections().values().map(|s| s.material.as_str()).collect();
used.into_iter()
.filter(|name| model.material(name).is_some_and(|m| m.plastic.is_some() || m.damage.is_some() || m.erosion.is_some()))
.map(|name| {
format!(
"material `{name}`: the linear solver uses its elastic constants only (its plasticity, damage and erosion act in explicit dynamics)"
"material `{name}`: the linear solver uses its elastic constants only (its plasticity acts in nonlinear static steps and explicit dynamics, its damage and erosion in explicit dynamics)"
)
})
.collect()
+85 -4
View File
@@ -130,7 +130,7 @@ fn nonlinear_patch_test() {
};
let (e, nu) = (100.0, 0.3);
let neo = fieldcraft_fem::constitutive::MaterialModel::from_model(&iso(e, nu), Some(&Hyperelastic::NeoHookean {})).unwrap();
let tau = neo.response(&f, &[], &mut []).unwrap().stress;
let tau = neo.response(&f, None, &[], &mut []).unwrap().stress;
let j = fieldcraft_fem::dense::det3(&f);
let hybrid = Formulation { hybrid: true, ..Formulation::default() };
let mut cases: Vec<(ElementKind, Formulation, &str)> = variants().into_iter().filter(|v| v.1 != incompatible()).collect();
@@ -387,7 +387,7 @@ fn nonlinear_steps_fail_cleanly() {
step.set_procedure(Procedure::NonlinearStatic(bad.clone()));
assert!(capped.model.replace_step(step).is_err(), "{bad:?}");
}
// Refused by name: incompatible modes, a hybrid St Venant–Kirchhoff element, temperature.
// Refused by name: incompatible modes, a hybrid St Venant–Kirchhoff element, centrifugal loads.
let mut im = grid([2, 1, 1], ElementKind::Hex8, |q| q);
im.define(iso(100.0, 0.3), None, incompatible(), nonlinear());
im.fixed("root", |q| near(q[0], 0.0));
@@ -402,10 +402,13 @@ fn nonlinear_steps_fail_cleanly() {
hy.load("p", Load::Pressure { set: "end".into(), magnitude: 1.0 });
let err = solve_nonlinear(&hy.model, None, &Limits::default()).unwrap_err().to_string();
assert!(err.contains("hybrid elements of a St Venant–Kirchhoff material"), "{err}");
hy.load("hot", Load::Temperature { target: Target::Set("root".into()), value: 50.0 });
hy.load("spin", Load::Centrifugal { angular_velocity: 10.0, point: [0.0; 3], axis: [0.0, 0.0, 1.0], set: None });
neo_hookean(&mut hy, 100.0, 0.3);
let mut m = hy.model.material("Mat").unwrap().clone();
m.density = Some(1.0);
hy.model.replace_material("Mat", m).unwrap();
let err = solve_nonlinear(&hy.model, None, &Limits::default()).unwrap_err().to_string();
assert!(err.contains("temperature loads"), "{err}");
assert!(err.contains("centrifugal loads"), "{err}");
// A model that can move freely: the first tangent is singular, named like the linear solver.
let mut free = grid([2, 1, 1], ElementKind::Hex8, |q| q);
free.define(iso(100.0, 0.3), None, full(), nonlinear());
@@ -433,3 +436,81 @@ fn nonlinear_results_are_deterministic() {
assert_eq!(fieldcraft_parallel::with_threads(threads, || solve(&g)), a, "{threads} threads");
}
}
/// The principal Cauchy stresses of a decoupled polynomial energy at principal stretches `l`:
/// σᵢ = (λᵢ/J) ∂W/∂λᵢ by central differences of W itself (independent of the solver's
/// stress and tangent), W = C₁₀ (Ī₁ − 3) + C₀₁ (Ī₂ − 3) + C₂₀ (Ī₁ − 3)² + C₃₀ (Ī₁ − 3)³ + κ/2 (J − 1)².
fn polynomial_stress(c: [f64; 4], kappa: f64, l: [f64; 3]) -> [f64; 3] {
let w = |l: [f64; 3]| {
let j = l[0] * l[1] * l[2];
let b: Vec<f64> = l.iter().map(|x| x * x * j.powf(-2.0 / 3.0)).collect();
let i1 = b[0] + b[1] + b[2];
let i2 = b[0] * b[1] + b[1] * b[2] + b[2] * b[0];
let x = i1 - 3.0;
c[0] * x + c[1] * (i2 - 3.0) + c[2] * x * x + c[3] * x * x * x + 0.5 * kappa * (j - 1.0) * (j - 1.0)
};
let j = l[0] * l[1] * l[2];
std::array::from_fn(|i| {
let h = 1e-6 * l[i];
let (mut p, mut m) = (l, l);
p[i] += h;
m[i] -= h;
l[i] / j * (w(p) - w(m)) / (2.0 * h)
})
}
/// Mooney–Rivlin and Yeoh blocks in the homogeneous modes of rubber testing (L. R. G. Treloar,
/// *The Physics of Rubber Elasticity*, 3rd ed., Oxford 1975, ch. 10): uniaxial and equibiaxial
/// tension and pure shear, against the closed form of the compressible decoupled energy (the
/// free stretches found by bisection so the free faces carry no stress).
#[test]
fn mooney_rivlin_and_yeoh_in_the_homogeneous_modes() {
let (e, nu) = (3.0, 0.49);
let (mu, kappa) = (e / (2.0 * (1.0 + nu)), e / (3.0 * (1.0 - 2.0 * nu)));
let hybrid = Formulation { hybrid: true, ..Formulation::default() };
let (c01, c20, c30) = (0.2, -0.02, 0.004);
for option in [Hyperelastic::MooneyRivlin { c01 }, Hyperelastic::Yeoh { c20, c30 }] {
let c = option.constants(mu);
for (mode, lam) in [("uniaxial", 2.0), ("equibiaxial", 1.5), ("pure shear", 2.0)] {
// The stretches with the free one(s) l from σ_free(l) = 0.
let stretches = |l: f64| match mode {
"uniaxial" => [lam, l, l],
"equibiaxial" => [lam, lam, l],
_ => [lam, 1.0, l],
};
let (mut lo, mut hi) = (0.05, 1.5);
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if polynomial_stress(c, kappa, stretches(mid))[2] > 0.0 { hi = mid } else { lo = mid }
}
let want = polynomial_stress(c, kappa, stretches(0.5 * (lo + hi)));
let mut g = grid([1, 1, 1], ElementKind::Hex20, |x| x);
g.define(iso(e, nu), None, hybrid, nonlinear());
g.model.replace_material("Mat", Material { elastic: Some(iso(e, nu)), hyperelastic: Some(option), ..Material::default() }).unwrap();
g.symmetry("x0", Symmetry::X, |x| near(x[0], 0.0));
g.symmetry("y0", Symmetry::Y, |x| near(x[1], 0.0));
g.symmetry("z0", Symmetry::Z, |x| near(x[2], 0.0));
g.node_set("x1", |x| near(x[0], 1.0));
g.bc(
"pull",
BoundaryCondition::Displacement { target: Target::Set("x1".into()), values: [Some(lam - 1.0), None, None, None, None, None] },
);
g.node_set("y1", |x| near(x[1], 1.0));
let y = match mode {
"equibiaxial" => Some(lam - 1.0),
"pure shear" => Some(0.0),
_ => None,
};
if let Some(v) = y {
g.bc("y1", BoundaryCondition::Displacement { target: Target::Set("y1".into()), values: [None, Some(v), None, None, None, None] });
}
let r = solve(&g);
for s in &r.stress {
for k in 0..3 {
assert!((s[k] - want[k]).abs() < 1e-7 * want[0], "{} {mode}: {s:?} vs {want:?}", option.name());
}
}
eprintln!("{} {mode} λ = {lam}: σ = {:.6?} (closed form {want:.6?})", option.name(), &r.stress[0][..3]);
}
}
}
+147
View File
@@ -0,0 +1,147 @@
//! Thermal strain in nonlinear static steps against closed forms: free expansion and a fully
//! constrained heated block at finite strain (the multiplicative split F = F_m F_θ), a heated
//! bar held at its ends past yield at small strain (the additive split), the linear limit
//! against the linear solver, and a step's own heat conduction feeding its temperatures.
mod common;
use common::*;
use fieldcraft_model::*;
use fieldcraft_solver::{Limits, StaticResult, solve_nonlinear, solve_static};
fn expanding(g: &mut Grid, e: f64, nu: f64, alpha: f64, hyperelastic: Option<Hyperelastic>, plastic: Option<Plastic>) {
let thermal = Thermal {
expansion: Some(Directional::Isotropic(alpha)),
reference_temperature: 20.0,
conductivity: Some(Directional::Isotropic(45.0)),
..Thermal::default()
};
let m = Material { elastic: Some(iso(e, nu)), hyperelastic, plastic, thermal: Some(thermal), ..Material::default() };
g.model.replace_material("Mat", m).unwrap();
}
fn heat_all(g: &mut Grid, value: f64) {
g.node_set("everything", |_| true);
g.load("heat", Load::Temperature { target: Target::Set("everything".into()), value });
}
fn solve(g: &Grid) -> StaticResult {
solve_nonlinear(&g.model, Some("Step"), &Limits::default()).unwrap()
}
fn symmetric(g: &mut Grid) {
g.symmetry("x0", Symmetry::X, |x| near(x[0], 0.0));
g.symmetry("y0", Symmetry::Y, |x| near(x[1], 0.0));
g.symmetry("z0", Symmetry::Z, |x| near(x[2], 0.0));
}
/// A free neo-Hookean cube heated by 200 with α = 10⁻³ grows by the thermal stretch 1.2 along
/// every edge, stress-free; held on all six faces it takes the pressure of the mechanical
/// volume ratio ϑ⁻³: σ = κ (ϑ⁻³ − 1), full and hybrid elements.
#[test]
fn finite_strain_free_and_constrained_expansion() {
let (e, nu, alpha) = (10.0, 0.3, 1e-3);
let kappa = e / (3.0 * (1.0 - 2.0 * nu));
let theta: f64 = 1.0 + alpha * 200.0;
for formulation in [Formulation::default(), Formulation { hybrid: true, ..Formulation::default() }] {
let mut g = grid([2, 2, 2], ElementKind::Hex20, |x| x);
g.define(iso(e, nu), None, formulation, Procedure::NonlinearStatic(NonlinearStatic::default()));
expanding(&mut g, e, nu, alpha, Some(Hyperelastic::NeoHookean {}), None);
symmetric(&mut g);
heat_all(&mut g, 220.0);
let r = solve(&g);
for (label, u) in r.nodes.iter().zip(&r.displacement) {
let x = g.model.node(*label).unwrap().xyz;
assert!((0..3).all(|k| (u[k] - (theta - 1.0) * x[k]).abs() < 1e-9), "{formulation:?} node {label}: {u:?}");
}
assert!(r.stress.iter().flatten().all(|s| s.abs() < 1e-9 * kappa), "{formulation:?}: {:?}", r.stress[0]);
assert!((r.elements[0].points[0].strain[0] - theta.ln()).abs() < 1e-9, "the total logarithmic strain");
g.symmetry("x1", Symmetry::X, |x| near(x[0], 1.0));
g.symmetry("y1", Symmetry::Y, |x| near(x[1], 1.0));
g.symmetry("z1", Symmetry::Z, |x| near(x[2], 1.0));
let r = solve(&g);
let want = kappa * (theta.powi(-3) - 1.0);
for s in &r.stress {
assert!((0..3).all(|k| (s[k] - want).abs() < 1e-9 * kappa) && s[3].abs() < 1e-9 * kappa, "{formulation:?}: {s:?} vs {want}");
}
eprintln!("{formulation:?}: constrained σ = {:.9} (closed form {want:.9})", r.stress[0][0]);
}
}
/// A steel bar held axially at both ends (lateral faces free) and heated at small strain: the
/// mechanical strain is −αΔT, so the stress is −EαΔT until it yields, then
/// −(σ_y + E_t (αΔT − σ_y/E)) with linear hardening.
#[test]
fn small_strain_heated_bar_held_at_its_ends_yields() {
let (e, nu, alpha, y0, h) = (200_000.0, 0.3, 1.2e-5, 250.0, 2000.0);
let small = NonlinearStatic { nonlinear_geometry: false, ..NonlinearStatic::default() };
let plastic = Plastic::Bilinear { yield_stress: y0, hardening_modulus: h, hardening: Hardening::Isotropic };
let et = e * h / (e + h);
for (dt, want) in [(80.0, -e * alpha * 80.0), (300.0, -(y0 + et * (alpha * 300.0 - y0 / e)))] {
let mut g = grid([4, 1, 1], ElementKind::Hex8, |x| [4.0 * x[0], x[1], x[2]]);
g.define(iso(e, nu), None, Formulation::default(), Procedure::NonlinearStatic(small.clone()));
expanding(&mut g, e, nu, alpha, None, Some(plastic.clone()));
symmetric(&mut g);
g.symmetry("x1", Symmetry::X, |x| near(x[0], 1.0));
heat_all(&mut g, 20.0 + dt);
let r = solve(&g);
for s in &r.stress {
assert!((s[0] - want).abs() < 1e-8 * want.abs() && s[1].abs() < 1e-8 * want.abs(), "ΔT = {dt}: {s:?} vs {want}");
}
eprintln!("ΔT = {dt}: σ = {:.6} (closed form {want:.6})", r.stress[0][0]);
}
}
/// Without nonlinear geometry an elastic model is the linear solution, thermal strain
/// included; a step's own temperature boundary conditions give its temperatures by conduction,
/// as in the linear coupled step.
#[test]
fn thermal_linear_limit_and_conducted_temperatures() {
let (e, nu, alpha) = (200_000.0, 0.3, 1.2e-5);
let build = |procedure: Procedure, plastic: Option<Plastic>, conducted: bool| {
let mut g = grid([6, 1, 2], ElementKind::Hex20, |q| [6.0 * q[0], q[1], q[2]]);
g.define(iso(e, nu), None, Formulation::default(), procedure);
expanding(&mut g, e, nu, alpha, None, plastic);
g.fixed("root", |q| near(q[0], 0.0));
if conducted {
g.node_set("cold", |q| near(q[0], 0.0));
g.bc("cold_t", BoundaryCondition::Temperature { target: Target::Set("cold".into()), value: 20.0 });
g.node_set("hot", |q| near(q[0], 1.0));
g.bc("hot_t", BoundaryCondition::Temperature { target: Target::Set("hot".into()), value: 320.0 });
} else {
// Hot on top, cold below: the bar bends.
g.node_set("top", |q| near(q[2], 1.0));
g.load("hot", Load::Temperature { target: Target::Set("top".into()), value: 120.0 });
}
g
};
let small = Procedure::NonlinearStatic(NonlinearStatic { nonlinear_geometry: false, ..NonlinearStatic::default() });
let linear = solve_static(&build(Procedure::Static {}, None, false).model, None, &Limits::default()).unwrap();
let r = solve(&build(small.clone(), None, false));
let scale = linear.displacement.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs()));
for (a, b) in r.displacement.iter().zip(&linear.displacement) {
assert!((0..3).all(|k| (a[k] - b[k]).abs() < 1e-9 * scale), "{a:?} vs {b:?}");
}
for (a, b) in r.stress.iter().zip(&linear.stress) {
assert!((0..6).all(|k| (a[k] - b[k]).abs() < 1e-6 * e * alpha * 100.0), "{a:?} vs {b:?}");
}
// Conducted: T linear in x from 20 to 320 over a bar fixed at its cold end; the step's
// temperature conditions are solved first, as the coupled linear step does, and a plastic
// material that stays below yield gives that step's solution.
let plastic = Plastic::Bilinear { yield_stress: 250.0, hardening_modulus: 0.0, hardening: Hardening::Isotropic };
let r = solve(&build(small, Some(plastic), true));
let coupled = solve_static(&build(Procedure::CoupledTemperatureDisplacement {}, None, true).model, None, &Limits::default()).unwrap();
let largest = coupled.von_mises.iter().fold(0.0f64, |m, v| m.max(*v));
assert!(largest < 250.0 && largest > 1.0, "{largest}");
let scale = coupled.displacement.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs()));
for (a, b) in r.displacement.iter().zip(&coupled.displacement) {
assert!((0..3).all(|k| (a[k] - b[k]).abs() < 1e-8 * scale), "{a:?} vs {b:?}");
}
assert!(r.elements.iter().flat_map(|e| &e.plastic_strain).all(|p| *p == 0.0));
// Both ways of giving temperatures at once is refused.
let mut both = build(Procedure::NonlinearStatic(NonlinearStatic::default()), None, true);
heat_all(&mut both, 50.0);
let err = solve_nonlinear(&both.model, None, &Limits::default()).unwrap_err().to_string();
assert!(err.contains("both by temperature loads and by heat conditions"), "{err}");
}
+249
View File
@@ -0,0 +1,249 @@
//! Implicit J2 plasticity in nonlinear static steps against closed forms: uniaxial tension at
//! finite strain (Kirchhoff stress against the logarithmic strain) and at small strain in every
//! element kind, the Bauschinger effect of linear kinematic hardening on a load path that comes
//! back, Newton's quadratic rate with the consistent tangent, and what plastic steps refuse or
//! stop at. Run with `--nocapture` to see the residuals.
mod common;
use common::*;
use fieldcraft_model::*;
use fieldcraft_solver::{Limits, StaticResult, solve_nonlinear};
const E: f64 = 200_000.0;
const NU: f64 = 0.3;
const Y0: f64 = 250.0;
const H: f64 = 2000.0;
fn bilinear(hardening: Hardening) -> Plastic {
Plastic::Bilinear { yield_stress: Y0, hardening_modulus: H, hardening }
}
fn plastic(g: &mut Grid, p: Plastic) {
let m = Material { elastic: Some(iso(E, NU)), plastic: Some(p), ..Material::default() };
g.model.replace_material("Mat", m).unwrap();
}
/// A unit cube on three symmetry planes, its x = 1 face pulled by `stretch` (uniaxial stress).
fn cube(kind: ElementKind, formulation: Formulation, step: NonlinearStatic, p: Plastic, stretch: f64) -> Grid {
let mut g = grid([2, 2, 2], kind, |x| x);
g.define(iso(E, NU), None, formulation, Procedure::NonlinearStatic(step));
plastic(&mut g, p);
g.symmetry("x0", Symmetry::X, |x| near(x[0], 0.0));
g.symmetry("y0", Symmetry::Y, |x| near(x[1], 0.0));
g.symmetry("z0", Symmetry::Z, |x| near(x[2], 0.0));
g.node_set("x1", |x| near(x[0], 1.0));
g.bc("pull", BoundaryCondition::Displacement { target: Target::Set("x1".into()), values: [Some(stretch), None, None, None, None, None] });
g
}
fn solve(g: &Grid) -> StaticResult {
solve_nonlinear(&g.model, Some("Step"), &Limits::default()).unwrap()
}
/// The uniaxial stress of linear hardening at strain ε past yield: σ_y + E_t (ε − σ_y/E).
fn uniaxial(strain: f64) -> f64 {
let et = E * H / (E + H);
Y0 + et * (strain - Y0 / E)
}
fn hybrid() -> Formulation {
Formulation { hybrid: true, ..Formulation::default() }
}
/// Every element kind (and hybrid hex8, hex20, tet10) stretched to a logarithmic strain of 0.2:
/// the Kirchhoff stress τ = J σ follows the small-strain curve in the logarithmic strain
/// exactly (Simo's exponential map integrates a proportional path exactly), and the
/// equivalent plastic strain is (τ − σ_y)/H.
#[test]
fn finite_strain_uniaxial_tension_follows_the_logarithmic_closed_form() {
let strain: f64 = 0.2;
let tau = uniaxial(strain);
let mut cases: Vec<(ElementKind, Formulation, &str)> = variants().into_iter().filter(|v| v.1 != incompatible()).collect();
cases.extend([
(ElementKind::Hex8, hybrid(), "hex8 hybrid"),
(ElementKind::Hex20, hybrid(), "hex20 hybrid"),
(ElementKind::Tet10, hybrid(), "tet10 hybrid"),
]);
for (kind, formulation, name) in cases {
let g = cube(kind, formulation, NonlinearStatic::default(), bilinear(Hardening::Isotropic), strain.exp() - 1.0);
let r = solve(&g);
for e in &r.elements {
for (p, ep) in e.points.iter().zip(&e.plastic_strain) {
// J from the logarithmic strains, σ the Cauchy stress.
let j = (p.strain[0] + p.strain[1] + p.strain[2]).exp();
assert!((p.stress[0] * j - tau).abs() < 1e-7 * tau, "{name}: τ = {} vs {tau}", p.stress[0] * j);
assert!(p.stress[1].abs() < 1e-7 * tau && p.stress[3].abs() < 1e-7 * tau, "{name}: {:?}", p.stress);
assert!((ep - (tau - Y0) / H).abs() < 1e-9, "{name}: ε_p = {ep}");
assert!((p.strain[0] - strain).abs() < 1e-12, "{name}: {:?}", p.strain);
}
assert_eq!(e.plastic_strain.len(), e.points.len(), "{name}");
}
let h = r.nonlinear.as_ref().unwrap();
eprintln!("{name}: {} increments, {} iterations", h.increments.len(), h.iterations);
}
}
/// Without nonlinear geometry: σ = σ_y + E_t (ε − σ_y/E) in the small strain, isotropic and
/// kinematic hardening alike under monotonic loading, hybrid elements too.
#[test]
fn small_strain_uniaxial_tension_follows_the_closed_form() {
let strain = 0.01;
let sigma = uniaxial(strain);
let small = NonlinearStatic { nonlinear_geometry: false, ..NonlinearStatic::default() };
for hardening in [Hardening::Isotropic, Hardening::Kinematic] {
for (kind, formulation, name) in [
(ElementKind::Hex8, full(), "hex8"),
(ElementKind::Tet10, full(), "tet10"),
(ElementKind::Hex20, reduced(), "hex20 reduced"),
(ElementKind::Hex20, hybrid(), "hex20 hybrid"),
] {
let r = solve(&cube(kind, formulation, small.clone(), bilinear(hardening), strain));
for e in &r.elements {
for (p, ep) in e.points.iter().zip(&e.plastic_strain) {
assert!((p.stress[0] - sigma).abs() < 1e-8 * sigma && p.stress[1].abs() < 1e-8 * sigma, "{name} {hardening:?}: {:?}", p.stress);
assert!((ep - (sigma - Y0) / H).abs() < 1e-10, "{name}: {ep}");
}
}
}
}
}
/// The Bauschinger effect: pulled to ε_a and brought back to zero strain on a load path,
/// linear kinematic hardening yields again in reverse at σ_a − 2σ_y and ends at the residual
/// stress σ_a − 2σ_y − E_t (ε_a − 2σ_y/E); isotropic hardening yields again only at −σ_a and
/// ends at −σ_a − E_t (ε_a − 2σ_a/E).
#[test]
fn kinematic_hardening_shows_the_bauschinger_effect_on_unloading() {
let strain = 0.01;
let sa = uniaxial(strain);
let et = E * H / (E + H);
let path = NonlinearStatic {
nonlinear_geometry: false,
initial_increment: 0.05,
max_increment: 0.05,
load_path: Some(vec![[0.0, 0.0], [0.5, 1.0], [1.0, 0.0]]),
..NonlinearStatic::default()
};
for (hardening, want) in
[(Hardening::Kinematic, sa - 2.0 * Y0 - et * (strain - 2.0 * Y0 / E)), (Hardening::Isotropic, -sa - et * (strain - 2.0 * sa / E))]
{
let r = solve(&cube(ElementKind::Hex8, full(), path.clone(), bilinear(hardening), strain));
let s = r.elements[0].points[0].stress[0];
assert!((s - want).abs() < 1e-8 * sa, "{hardening:?}: residual stress {s} vs {want}");
assert!(r.displacement.iter().all(|u| u[0].abs() < 1e-12), "back at zero strain");
let h = r.nonlinear.unwrap();
assert!(h.increments.iter().any(|i| i.converged && i.load_factor == 1.0), "the path's turn is an increment's end");
assert_eq!(h.frames.last().map(|f| f.load_factor), Some(0.0));
eprintln!("{hardening:?}: residual stress {s:.6} (closed form {want:.6})");
}
}
/// Newton with the consistent tangent converges quadratically in a plastic structure: a notched
/// bar pulled past general yield, and a cantilever bent into plastic flow at finite strain.
#[test]
fn plastic_newton_converges_quadratically() {
for nonlinear_geometry in [false, true] {
let mut step = NonlinearStatic { initial_increment: 0.5, nonlinear_geometry, ..NonlinearStatic::default() };
step.tolerances.force = 1e-8;
step.tolerances.displacement = 1e-8;
step.tolerances.energy = 1e-16;
let mut g = grid([8, 1, 2], ElementKind::Hex20, |q| [10.0 * q[0], q[1], q[2]]);
g.define(iso(E, NU), None, full(), Procedure::NonlinearStatic(step));
plastic(&mut g, bilinear(Hardening::Isotropic));
g.fixed("root", |q| near(q[0], 0.0));
g.node_set("tip", |q| near(q[0], 1.0));
// A tip deflection of ten times the first yield's.
let first_yield = 2.0 * Y0 * 100.0 / (3.0 * E * 2.0);
g.bc(
"push",
BoundaryCondition::Displacement { target: Target::Set("tip".into()), values: [None, None, Some(-10.0 * first_yield), None, None, None] },
);
let r = solve(&g);
assert!(r.elements.iter().flat_map(|e| &e.plastic_strain).any(|p| *p > 0.0), "plastic");
let h = r.nonlinear.unwrap();
let mut best = 0.0f64;
for inc in h.increments.iter().filter(|i| i.converged) {
let res: Vec<f64> = inc.iterations.iter().map(|i| i.force).collect();
let orders: Vec<f64> = res.windows(3).filter(|w| w[0] < 1e-2 && w[2] > 1e-11).map(|w| (w[2] / w[1]).ln() / (w[1] / w[0]).ln()).collect();
eprintln!("geometry {nonlinear_geometry}: increment {} (λ = {}): residuals {res:?}, orders {orders:?}", inc.number, inc.load_factor);
best = orders.iter().fold(best, |m, o| m.max(*o));
}
assert!(best > 1.8, "nonlinear geometry {nonlinear_geometry}: no quadratic convergence (best order {best})");
}
}
/// What a static step cannot take is refused by name; a material that softens faster than it
/// unloads makes the step cut back and stop cleanly; the results do not depend on the threads.
#[test]
fn plastic_steps_refuse_and_fail_cleanly() {
let jc = |c: f64| Plastic::JohnsonCook {
a: 350.0,
b: 275.0,
n: 0.36,
c,
m: 1.0,
reference_strain_rate: 1.0,
room_temperature: 293.0,
melt_temperature: 1356.0,
specific_heat: Some(4.5e8),
taylor_quinney: 0.9,
};
let err = solve_nonlinear(&cube(ElementKind::Hex8, full(), NonlinearStatic::default(), jc(0.02), 0.01).model, None, &Limits::default())
.unwrap_err()
.to_string();
assert!(err.contains("strain-rate term") && err.contains("C = 0.02"), "{err}");
// C = 0 is the rate-independent law: it solves, warning that a static step does not heat.
let r = solve(&cube(ElementKind::Hex8, full(), NonlinearStatic::default(), jc(0.0), 0.01));
assert!(r.warnings.iter().any(|w| w.contains("isothermal")), "{:?}", r.warnings);
let err = solve_nonlinear(
&cube(ElementKind::Hex8, full(), NonlinearStatic::default(), bilinear(Hardening::Kinematic), 0.01).model,
None,
&Limits::default(),
)
.unwrap_err()
.to_string();
assert!(err.contains("kinematic hardening") && err.contains("finite strain"), "{err}");
let small = NonlinearStatic { nonlinear_geometry: false, ..NonlinearStatic::default() };
let err = solve_nonlinear(
&cube(ElementKind::Hex8, incompatible(), small.clone(), bilinear(Hardening::Isotropic), 0.01).model,
None,
&Limits::default(),
)
.unwrap_err()
.to_string();
assert!(err.contains("incompatible modes with an inelastic material"), "{err}");
// Softening steeper than −3G past a small plastic strain: the point localises, the step cuts
// back to its minimum increment and stops, naming the element.
let g_mod = E / (2.0 * (1.0 + NU));
let softening =
Plastic::Table { points: vec![HardeningPoint { strain: 0.0, stress: Y0 }, HardeningPoint { strain: 1e-4, stress: Y0 - 4.0 * g_mod * 1e-4 }] };
let soft = NonlinearStatic { min_increment: 1e-3, ..small.clone() };
let err = solve_nonlinear(&cube(ElementKind::Hex8, full(), soft, softening, 0.01).model, None, &Limits::default()).unwrap_err().to_string();
assert!(err.contains("stopped at load factor") && err.contains("element"), "{err}");
// Damage is left out with a warning.
let mut g = cube(ElementKind::Hex8, full(), small, bilinear(Hardening::Isotropic), 0.01);
let mut m = g.model.material("Mat").unwrap().clone();
m.damage = Some(Damage::JohnsonCook { d1: 0.5, d2: 0.0, d3: 0.0, d4: 0.0, d5: 0.0 });
g.model.replace_material("Mat", m).unwrap();
let r = solve(&g);
assert!(r.warnings.iter().any(|w| w.contains("damage and erosion")), "{:?}", r.warnings);
// Hostile plasticity never reaches the solver: non-finite or negative tables and yield.
let mut g = cube(ElementKind::Hex8, full(), NonlinearStatic::default(), bilinear(Hardening::Isotropic), 0.01);
for bad in [
Plastic::Bilinear { yield_stress: -250.0, hardening_modulus: H, hardening: Hardening::Isotropic },
Plastic::Bilinear { yield_stress: Y0, hardening_modulus: f64::NAN, hardening: Hardening::Isotropic },
Plastic::Table { points: vec![HardeningPoint { strain: 0.0, stress: f64::INFINITY }] },
Plastic::Table { points: vec![HardeningPoint { strain: 0.0, stress: Y0 }, HardeningPoint { strain: f64::NAN, stress: Y0 }] },
Plastic::Table { points: vec![HardeningPoint { strain: 0.0, stress: Y0 }, HardeningPoint { strain: 0.1, stress: -1.0 }] },
] {
let m = Material { elastic: Some(iso(E, NU)), plastic: Some(bad.clone()), ..Material::default() };
assert!(g.model.replace_material("Mat", m).is_err(), "{bad:?}");
}
// Bit for bit on any number of threads.
let g = cube(ElementKind::Tet10, full(), NonlinearStatic::default(), bilinear(Hardening::Isotropic), 0.1);
let a = fieldcraft_parallel::with_threads(1, || solve(&g));
for threads in [2, 7] {
assert_eq!(fieldcraft_parallel::with_threads(threads, || solve(&g)), a, "{threads} threads");
}
}
+1
View File
@@ -40,6 +40,7 @@ pub const CATEGORIES: &[(&str, &str)] = &[
("blocky", "axis-aligned blocks (polycube-friendly)"),
("organic", "free-form, lofted or revolved spline surfaces"),
("assembly", "several bodies placed by component transforms"),
("partitioned", "split into cells that each mesh as a mapped block (conforming multi-block hex)"),
("analytic", "closed-form geometry for solver verification"),
];
+11 -1
View File
@@ -549,6 +549,16 @@ fn fixture_text(f: &FixtureCheck) -> &'static str {
}
}
/// The fixture column of docs/oracle.md. A value blessed in this run matches its new fixture by
/// construction, so the page reads the same whether or not the run blessed anything; otherwise
/// a page written by a blessing run would differ from the one CI regenerates.
fn page_fixture_text(f: &FixtureCheck) -> &'static str {
match f {
FixtureCheck::Blessed => fixture_text(&FixtureCheck::Matches),
other => fixture_text(other),
}
}
fn cell(s: &str) -> String {
s.replace('|', "\\|").replace('\n', " ")
}
@@ -609,7 +619,7 @@ benchmark, {} experiment; {} pending cases wait for capabilities still being bui
error_of(r),
r.quantity.tolerance.describe(),
cell(&result),
fixture_text(&r.fixture),
page_fixture_text(&r.fixture),
);
}
}
+103 -14
View File
@@ -15,7 +15,8 @@ use std::time::Instant;
use crate::corpus::{self, CATEGORIES, Part};
use fieldcraft_geometry::{Design, FaceSurface, Measure, SurfaceMesh};
use fieldcraft_mesh::cdt::{CdtOptions, tetrahedralize};
use fieldcraft_mesh::hex::{HexOptions, Surface, hex_mesh};
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};
@@ -33,6 +34,8 @@ pub struct Loaded<'a> {
pub surfaces: Vec<SurfaceMesh>,
/// The chordal tolerance the surfaces were made with (mm).
pub tolerance: f64,
/// The bounding-box diagonal of all bodies (mm).
pub diagonal: f64,
}
/// A column group of the results table: a mesher, a check.
@@ -44,7 +47,7 @@ pub trait Stage {
/// The stages, in column order. Meshers add theirs here.
fn stages() -> Vec<Box<dyn Stage>> {
vec![Box::new(SurfaceVolume), Box::new(BoundaryCheck), Box::new(TetCdt), Box::new(StructuredHex)]
vec![Box::new(SurfaceVolume), Box::new(BoundaryCheck), Box::new(TetCdt), Box::new(StructuredHex), Box::new(OctreeHex)]
}
/// The boundary surface's enclosed volume against SolidCraft's measured volume and the closed
@@ -116,14 +119,15 @@ impl Stage for TetCdt {
}
}
/// Element size of the structured hex stage, relative to the part's bounding-box diagonal.
/// Element size of the hex stages, relative to the part's bounding-box diagonal.
const HEX_RELATIVE_SIZE: f64 = 1.0 / 25.0;
/// Characters of a "not meshed" reason kept in the table.
const REASON_CHARS: usize = 240;
/// Structured all-hex meshing (`fieldcraft_mesh::hex`): the method that meshed each body, the
/// element count and the smallest scaled Jacobian of the hex8 mesh and of the hex20 mesh (mid-edge
/// nodes on the geometry), or why no structured method fits.
/// nodes on the geometry), or why no structured method fits. A `partitioned` part's bodies are
/// the cells of one conforming mesh (`blocks`).
struct StructuredHex;
impl Stage for StructuredHex {
@@ -133,17 +137,15 @@ impl Stage for StructuredHex {
fn run(&self, l: &Loaded) -> Result<Vec<String>, String> {
let start = Instant::now();
let (lo, hi) = l.measures.iter().fold(([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]), |(mut lo, mut hi), m| {
for i in 0..3 {
lo[i] = lo[i].min(m.bbox_min[i]);
hi[i] = hi[i].max(m.bbox_max[i]);
}
(lo, hi)
});
let diagonal = (0..3).map(|i| (hi[i] - lo[i]).powi(2)).sum::<f64>().sqrt();
let size = UniformSize(HEX_RELATIVE_SIZE * diagonal);
let size = UniformSize(HEX_RELATIVE_SIZE * l.diagonal);
let linear = HexOptions { chordal_tolerance: l.tolerance, ..HexOptions::default() };
let quadratic = HexOptions { quadratic: true, ..linear.clone() };
if l.part.categories.iter().any(|c| c == "partitioned") {
return blocks(l, &size, &linear, &quadratic).map(|mut cells| {
cells.push(format!("{:.0}", start.elapsed().as_secs_f64() * 1e3));
cells
});
}
let (mut methods, mut elements, mut sj8, mut sj20) = (Vec::new(), 0usize, f64::INFINITY, Ok(f64::INFINITY));
for (body, mesh) in l.design.bodies.iter().zip(&l.surfaces) {
let surface_options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() };
@@ -180,6 +182,39 @@ impl Stage for StructuredHex {
}
}
/// The bodies of a partitioned part as the cells of one conforming mesh: method, elements and
/// the hex8 and hex20 smallest scaled Jacobians.
fn blocks(l: &Loaded, size: &UniformSize, linear: &HexOptions, quadratic: &HexOptions) -> Result<Vec<String>, String> {
let mut surfaces = Vec::with_capacity(l.surfaces.len());
let mut faces: Vec<Vec<Option<Surface>>> = Vec::with_capacity(l.surfaces.len());
for (body, mesh) in l.design.bodies.iter().zip(&l.surfaces) {
let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() };
surfaces.push(BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options).map_err(|e| format!("{}: {e}", body.name))?);
faces.push(body.face_surfaces().map_err(|e| format!("{}: {e}", body.name))?.into_iter().map(|f| f.map(analytic)).collect());
}
let cells: Vec<Cell> =
l.design.bodies.iter().zip(&surfaces).zip(&faces).map(|((b, surface), faces)| Cell { name: &b.name, surface, faces }).collect();
let m = match hex_mesh_cells(&cells, size, linear) {
Ok(m) => m,
Err(e) => {
return Ok(vec![
format!("no: {}", e.to_string().chars().take(REASON_CHARS).collect::<String>()),
String::new(),
String::new(),
String::new(),
]);
}
};
let sj20 =
hex_mesh_cells(&cells, size, quadratic).map_or_else(|e| format!("error: {e}"), |q| format!("{:.3}", q.mesh.report.min_scaled_jacobian));
Ok(vec![
format!("blocks ({} cells, {} shared faces)", cells.len(), m.interfaces),
m.mesh.element_count().to_string(),
format!("{:.3}", m.mesh.report.min_scaled_jacobian),
sj20,
])
}
/// The geometry's analytic face surface in the mesher's terms.
pub fn analytic(s: FaceSurface) -> Surface {
match s {
@@ -191,6 +226,49 @@ pub fn analytic(s: FaceSurface) -> Surface {
}
}
/// Element cap of the octree hex column: well above every part that meshes, and low enough that a
/// part whose grid explodes (a thin spline wall refined everywhere) is refused in seconds instead
/// of optimised for an hour.
const OCTREE_MAX_ELEMENTS: usize = 500_000;
/// The unstructured all-hex mesher (`fieldcraft_mesh::octree`) at a size of 1/25 of the part's
/// diagonal: elements, the smallest and mean scaled Jacobian, and the mesh volume against
/// SolidCraft's. Every element is proven valid before the mesher returns; a part it cannot mesh
/// gets the reason, as in the structured hex column.
struct OctreeHex;
impl Stage for OctreeHex {
fn headers(&self) -> &'static [&'static str] {
&["octree hexes", "min SJ", "mean SJ", "hex vol. diff", "octree ms"]
}
fn run(&self, l: &Loaded) -> Result<Vec<String>, String> {
let start = Instant::now();
let (mut hexes, mut min_sj, mut sum_sj, mut volume) = (0usize, f64::INFINITY, 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 options = OctreeOptions { max_elements: OCTREE_MAX_ELEMENTS, ..OctreeOptions::default() };
let m = match octree_hex_mesh(&s, &UniformSize(HEX_RELATIVE_SIZE * l.diagonal), &options) {
Ok(m) => m,
Err(e) => {
let reason: String = e.to_string().chars().take(REASON_CHARS).collect();
let ms = start.elapsed().as_secs_f64() * 1e3;
return Ok(vec![format!("no: {}: {reason}", body.name), String::new(), String::new(), String::new(), format!("{ms:.0}")]);
}
};
hexes += m.hexes.len();
min_sj = min_sj.min(m.report.min_scaled_jacobian);
sum_sj += m.report.mean_scaled_jacobian * m.hexes.len() as f64;
volume += m.report.volume;
}
let measured: f64 = l.measures.iter().map(|m| m.volume).sum();
let rel = if measured.abs() > 0.0 { (volume - measured) / measured } else { f64::NAN };
let ms = start.elapsed().as_secs_f64() * 1e3;
Ok(vec![hexes.to_string(), format!("{min_sj:.3}"), format!("{:.3}", sum_sj / hexes.max(1) as f64), format!("{rel:.1e}"), format!("{ms:.0}")])
}
}
fn load(part: &Part) -> Result<(Loaded<'_>, f64), String> {
let start = Instant::now();
let design = corpus::load(part).map_err(|e| e.to_string())?;
@@ -210,7 +288,7 @@ fn load(part: &Part) -> Result<(Loaded<'_>, f64), String> {
let surfaces =
design.bodies.iter().map(|b| b.tessellate(tolerance).map_err(|e| format!("tessellate {}: {e}", b.name))).collect::<Result<Vec<_>, _>>()?;
let ms = start.elapsed().as_secs_f64() * 1e3;
Ok((Loaded { part, design, measures, surfaces, tolerance }, ms))
Ok((Loaded { part, design, measures, surfaces, tolerance, diagonal }, ms))
}
/// A failed part or stage: the error, then blanks to fill `width` cells.
@@ -330,6 +408,17 @@ mod tests {
}
}
/// The partitioned bracket's 30 cells mesh into one conforming hex mesh.
#[test]
fn partitioned_bracket_meshes_as_blocks() {
let part = corpus::read_recipe(&corpus::corpus_dir().join("filleted-bracket-blocks")).unwrap();
let (loaded, _) = load(&part).unwrap();
assert_eq!(loaded.design.bodies.len(), 30);
let cells = StructuredHex.run(&loaded).unwrap();
assert!(cells[0].starts_with("blocks (30 cells"), "{cells:?}");
assert!(cells[2].parse::<f64>().unwrap() > 0.5, "{cells:?}");
}
/// The constrained Delaunay tetrahedralization passes the gate on parts that exercise each
/// of its stages: an assembly whose rounding needs a few-ulp repair, cavities refilled by
/// gift-wrapping, and a holed plate.
+96
View File
@@ -0,0 +1,96 @@
//! The filleted bracket meshed all-hex by partitioning (`tests/corpus/filleted-bracket-blocks`):
//! the upright's end clamped, the foot's end pushed down by 1 kN (steel). The conforming hex8 and
//! hex20 meshes converge: the tip deflection, the von Mises stress at the middle of the fillet and
//! the peak (at the upright's hole) agree between hex20 at 3 mm and 2 mm, and hex8 approaches them.
//! For reference, a 48 016-element tet10 mesh of the unpartitioned part (curvature-refined, from
//! the tet quality pipeline) gives a tip deflection of 0.5093 mm, 133.2 MPa at the fillet and a
//! peak of 291.8 MPa; hex20 at 2 mm gives 0.5089 mm, 137.4 MPa and 297.0 MPa.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use fieldcraft_engine::Session;
use fieldcraft_testkit::corpus;
use serde_json::{Value, json};
fn open(part: &str) -> (Session, std::path::PathBuf) {
let parts = corpus::parts().unwrap();
let p = parts.iter().find(|p| p.name == part).unwrap();
let dir = std::env::temp_dir().join(format!("fieldcraft-bracket-{}-{part}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("part.solidcraft");
std::fs::write(&path, corpus::design_bytes(p).unwrap()).unwrap();
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path})).unwrap();
(s, dir)
}
/// Every (body, face) whose name is `face` (a face split into cells keeps its name in each).
fn faces_named(s: &mut Session, face: &str) -> Vec<Value> {
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
let mut out = Vec::new();
for b in bodies.as_array().unwrap() {
for f in b["faces"].as_array().unwrap() {
if f["name"] == face {
out.push(json!({"body": b["name"], "face": face}));
}
}
}
out
}
/// Clamp the upright's top (y = 50), push the foot's end (x = 60) down by 1 kN; tip deflection,
/// von Mises at the middle of the fillet and the largest von Mises.
fn analyse(s: &mut Session) -> (f64, f64, f64) {
let top = faces_named(s, "F2:side:l5");
let tip = faces_named(s, "F2:side:l2");
assert!(!top.is_empty() && !tip.is_empty());
for (name, geometry) in [
(
"all",
json!(
s.execute("geometry.bodies", &json!({})).unwrap().as_array().unwrap().iter().map(|b| json!({"body": b["name"]})).collect::<Vec<_>>()
),
),
("top", json!(top)),
("tip", json!(tip)),
] {
s.execute("model.set.create", &json!({"name": name, "geometry": geometry})).unwrap();
}
s.execute("model.material.create", &json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}})).unwrap();
s.execute("model.section.create", &json!({"name": "solid", "set": "all", "material": "Steel", "kind": {"type": "solid"}})).unwrap();
s.execute("model.boundary_condition.create", &json!({"name": "clamp", "type": "fixed", "target": {"set": "top"}})).unwrap();
s.execute("model.load.create", &json!({"name": "push", "type": "force", "target": {"set": "tip"}, "vector": [0.0, -1000.0, 0.0]})).unwrap();
s.execute("model.step.create", &json!({"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["clamp"], "loads": ["push"]}))
.unwrap();
let r = s.execute("solve.static", &json!({})).unwrap();
let probe = s.execute("solve.probe", &json!({"step": "Load", "at": [[60.0, 4.0, 20.0], [9.757359312880714, 9.757359312880714, 20.0]]})).unwrap();
let nodes = probe["nodes"].as_array().unwrap();
let tip = nodes[0]["displacement"][1].as_f64().unwrap();
let fillet = nodes[1]["von_mises"].as_f64().unwrap();
(tip, fillet, r["max_von_mises"]["value"].as_f64().unwrap())
}
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn partitioned_bracket_converges_with_hex8_and_hex20() {
let mut results = Vec::new();
for (order, size) in [(1, 2.0), (2, 3.0), (2, 2.0)] {
let (mut s, dir) = open("filleted-bracket-blocks");
let m = s.execute("mesh.hex.blocks", &json!({"size": size, "order": order})).unwrap();
assert_eq!(m["cells"], 30, "{m}");
assert!(m["quality"]["min_scaled_jacobian"].as_f64().unwrap() > 0.6, "{m}");
let r = analyse(&mut s);
eprintln!("order {order}, size {size}: {} elements; tip {:.5} mm, fillet {:.2} MPa, peak {:.2} MPa", m["elements"], r.0, r.1, r.2);
results.push(r);
std::fs::remove_dir_all(dir).ok();
}
let close = |a: f64, b: f64, rel: f64| (a - b).abs() <= rel * b.abs();
let [hex8, coarse, fine] = [results[0], results[1], results[2]];
// The tip moves down; hex20 has converged on it, hex8 is within a few percent (stiffer).
assert!(fine.0 < 0.0);
assert!(close(coarse.0, fine.0, 0.005), "{coarse:?} {fine:?}");
assert!(close(hex8.0, fine.0, 0.04) && hex8.0.abs() < fine.0.abs(), "{hex8:?} {fine:?}");
// Stresses at the fillet and the peak.
assert!(close(coarse.1, fine.1, 0.06), "{coarse:?} {fine:?}");
assert!(close(coarse.2, fine.2, 0.02), "{coarse:?} {fine:?}");
assert!(close(hex8.1, fine.1, 0.15) && close(hex8.2, fine.2, 0.10), "{hex8:?} {fine:?}");
}
+5 -1
View File
@@ -79,7 +79,11 @@ pub fn build(display: &DisplayMesh, style: Style, show: Show, t: &Tokens) -> Sce
s.tris = display.tris.iter().map(|d| SceneTri { p: d.p, normal: d.normal, color: rgb(t.element(d.kind)) }).collect();
}
if !mesh_faces && show.geometry {
s.tris.extend(display.surface_tris.iter().map(|d| SceneTri { p: d.p, normal: d.normal, color: rgb(t.geometry) }));
s.tris.extend(display.surface_tris.iter().map(|d| SceneTri {
p: d.p,
normal: d.normal,
color: rgb(display.body_color(d, t.geometry, &t.bodies)),
}));
}
}
if style != Style::Shaded && show.mesh {
+22
View File
@@ -66,6 +66,8 @@ pub struct Tokens {
pub geometry: Color32,
/// Their face boundaries.
pub geometry_edge: Color32,
/// The bodies of a design with several (the cells of a partitioned part), in turn.
pub bodies: [Color32; 8],
// ---- results ----
/// The contour colour scale, low to high (the same in both themes).
pub contour: [Color32; 5],
@@ -137,6 +139,16 @@ impl Tokens {
node: Color32::from_rgb(0xb4, 0x3c, 0x8c),
geometry: Color32::from_rgb(176, 186, 198),
geometry_edge: Color32::from_rgb(40, 44, 52),
bodies: [
Color32::from_rgb(0x8f, 0xb4, 0xd9),
Color32::from_rgb(0xe0, 0xa4, 0x6c),
Color32::from_rgb(0x8c, 0xc4, 0x8a),
Color32::from_rgb(0xd6, 0x8c, 0x96),
Color32::from_rgb(0xb0, 0x9c, 0xd8),
Color32::from_rgb(0xd8, 0xc8, 0x70),
Color32::from_rgb(0x7c, 0xc4, 0xc0),
Color32::from_rgb(0xc0, 0xa0, 0x84),
],
contour: CONTOUR,
no_result: Color32::from_rgb(0x9a, 0xa0, 0xaa),
glyph_constraint: Color32::from_rgb(0x00, 0x8a, 0xa8),
@@ -188,6 +200,16 @@ impl Tokens {
t.node = Color32::from_rgb(0xf2, 0xe6, 0x6e);
t.geometry = Color32::from_rgb(152, 156, 162);
t.geometry_edge = Color32::from_rgb(16, 16, 18);
t.bodies = [
Color32::from_rgb(0x6c, 0x92, 0xb8),
Color32::from_rgb(0xbc, 0x84, 0x50),
Color32::from_rgb(0x6c, 0xa2, 0x6a),
Color32::from_rgb(0xb4, 0x6e, 0x78),
Color32::from_rgb(0x90, 0x7e, 0xb6),
Color32::from_rgb(0xb6, 0xa6, 0x54),
Color32::from_rgb(0x5c, 0xa2, 0x9e),
Color32::from_rgb(0xa0, 0x82, 0x66),
];
t.no_result = Color32::from_rgb(0x70, 0x76, 0x80);
t.glyph_constraint = Color32::from_rgb(0x3c, 0xdc, 0xf0);
t.glyph_load = Color32::from_rgb(0xff, 0x5a, 0x4c);
+14 -5
View File
@@ -68,12 +68,21 @@ Rules beyond the layer order:
its B-rep face tag survives. Then constrained Delaunay refinement to a
size field, ODT smoothing interleaved as in Tournois et al. 2009, and the Klingner–Shewchuk 2007
improvement schedule against slivers.
4. **Hexahedral meshing (the core).** Octree/grid dual all-hex first, the one family that meshes
any input validly (Livesu et al. 2021 dual schemes, Pitzalis et al. 2021 refinement, Gao et al.
2019 feature projection and padding, Garanzha et al. 2021 untangling). Then hex-dominant by tet
4. **Hexahedral meshing (the core).** Grid-based all-hex first, the one family that meshes any
input validly. `fieldcraft-mesh::octree` (command `mesh.hex.octree`): a 27-tree over the part,
refined by curvature, thickness and narrow faces, balanced and cut by the 3-refinement
node-marking templates (Schneiders 1996, Ito et al. 2009) into a conforming grid; the core of
grid elements inside the part (exact inside test) is cleaned to a manifold boundary; the part's
patches, sharp curves and corners are mapped onto it (Gao et al. 2019); one padding layer and a
pillow under each patch carry the boundary and capture every sharp curve; the foldover-free
energy (Garanzha et al. 2021) with a growing fitting term moves the boundary onto the geometry
while every element stays valid; the worst elements are improved, and every element is proven
valid by the certified Bernstein Jacobian check (Johnen et al. 2013; Marschner et al. 2020)
before the boundary quads are tagged with their B-rep faces. Where a stage fails the grid is
refined there and the mesh rebuilt. The dual schemes of Livesu et al. 2021 (fewer elements,
concave transitions) are the next upgrade of the transitions. Then hex-dominant by tet
recombination (Pellerin et al. 2018), then polycube (DualCube labelling) for blocky parts;
frame-field/integer-grid-map methods stay research. Validity uses a certified trilinear Jacobian
check (Marschner et al. 2020), not corner Jacobians alone.
frame-field/integer-grid-map methods stay research.
5. **Structured hex and face meshing.** Faces get all-quad meshes on shared, prescribed edge
nodes (`fieldcraft-mesh::quad`): transfinite interpolation for four-sided faces (Gordon and
Hall 1973), and for free faces a half-resolution constrained Delaunay triangulation,
+3 -2
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.
54 ready, 49 coming soon.
56 ready, 48 coming soon.
## Geometry
@@ -35,9 +35,10 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Structured Hex | Quad Faces — All-quad meshes of faces on shared edge nodes | coming soon (M5) | |
| Structured Hex | Mapped Hex — Mapped hex mesh of a six-sided block (curved faces allowed) | ready | `mesh.hex.mapped`, `mesh.hex.generate` |
| Structured Hex | Sweep Hex — Hex mesh swept along extrusions (several levels), around revolutions, or from a source face to a target | ready | `mesh.hex.sweep`, `mesh.hex.generate` |
| Structured Hex | Block Hex — One conforming hex mesh of a part split into six-sided blocks: shared faces meshed once | ready | `mesh.hex.blocks` |
| Structured Hex | Decompose — Find sweepable and mappable regions and propose the splits | coming soon (M5) | |
| Structured Hex | Transition — Conforming tet–hex interfaces with pyramids | coming soon (M5) | |
| Automatic Hex | All-Hex — Octree dual all-hex mesh of any part | coming soon (M6) | |
| Automatic Hex | Octree Hex — Unstructured all-hex mesh of any part: adaptive grid, boundary layers on every sharp edge, untangled and proven valid | ready | `mesh.hex.octree` |
| Automatic Hex | Hex-Dominant — Hex-dominant mesh by recombining tets | coming soon (M6) | |
| Automatic Hex | Polycube — Polycube hex mesh of blocky parts | coming soon (M6) | |
| Improve | Smooth — Smooth node positions, keeping every element valid | coming soon (M6) | |
+82 -10
View File
@@ -171,6 +171,23 @@ converges, so a cut-back increment leaves no trace. The materials:
|---|---|
| Elastic (isotropic or orthotropic), no option | St Venant–Kirchhoff: S = D E, E = ½(FᵀF − I). Right for large rotations with small strains; it softens without bound in large compression. |
| Isotropic elastic with `"hyperelastic": {"type": "neo_hookean"}` | Compressible neo-Hookean with the isochoric–volumetric split, W = μ/2 (Ī₁ − 3) + κ/2 (J − 1)², μ and κ from E and ν (C₁₀ = μ/2, D₁ = 2/κ). Its small-strain limit is the linear material, which linear steps use. |
| Isotropic elastic with `{"type": "mooney_rivlin", "c01": …}` or `{"type": "yeoh", "c20": …, "c30": …}` | Decoupled polynomials W̄ = C₁₀ (Ī₁ − 3) + C₀₁ (Ī₂ − 3) + C₂₀ (Ī₁ − 3)² + C₃₀ (Ī₁ − 3)³ with the same volume κ/2 (J − 1)²: Mooney–Rivlin with C₁₀ = μ/2 − C₀₁ (0 ≤ C₀₁ ≤ μ/2), Yeoh with C₁₀ = μ/2, so 2(C₁₀ + C₀₁) is the elastic μ and the small-strain material stays the elastic one. Stress and tangent from the fictitious τ̄ and c̄ (Holzapfel 2000, eqs. 6.168, 6.193). Abaqus `*HYPERELASTIC, MOONEY-RIVLIN` / `YEOH`; LS-DYNA refuses them by name. |
| Isotropic elastic with `"plastic"` (bilinear isotropic or kinematic, table, Johnson–Cook) | J2 plasticity (`fieldcraft_fem::elastoplastic`): at finite strain Simo's multiplicative model (CMAME 99, 1992) with a Hencky elastic energy, τ = K ln J I + 2G dev ½ ln bₑ, the history Cₚ⁻¹ and the equivalent plastic strain, integrated by the exponential return map on the principal logarithmic strains; at small strain the additive radial return with the back stress. Both call `J2::radial_return`, the update of explicit dynamics, and its consistent tangent `J2::tangent`. |
**Plasticity's tangent.** With Cₚ⁻¹ frozen over an iteration, L_v τ = 𝔻ᵉᵖ : ½ (∂ ln b/∂b) : (d b + b d) − (d τ + τ d), 𝔻ᵉᵖ the algorithmic tangent of the return
(K I⊗I + 2Gθ ℙ − 2Gθ̄ n⊗n, Simo & Hughes 1998 Box 3.2) and the derivative of the logarithm the
Daleckii–Krein divided differences θ_AB = artanh(r)/r, r = (λ²_A − λ²_B)/(λ²_A + λ²_B), in the
principal frame of the trial bₑ: smooth through equal stretches, so uniaxial and undeformed
states need no special case. The tangent is symmetric (isotropic hardening) and checked against
central differences of the stress and of every element's forces from a plastic history. Hybrid
elements take the deviatoric return and the volume K/2 (ln J)². Refused by name in nonlinear
steps: kinematic hardening at finite strain (a back stress is not coaxial with bₑ, which the
principal-frame return needs; it works with small strain), the Johnson–Cook strain-rate term (a
static step has no time; C = 0 is the rate-independent law), and incompatible-mode elements of a
plastic material. Damage, erosion and adiabatic heating act in explicit dynamics only (a warning
says so). A hardening slope below −3G leaves the point without a tangent: the increment cuts
back and the step stops at the minimum increment, naming the element.
"Isotropic elastic" keeps meaning the same thing everywhere: the neo-Hookean model is chosen
only by the explicit option, and explicit dynamics refuses a hyperelastic material by name.
@@ -190,9 +207,11 @@ differences of its forces.
| plane stress | refused (σ_zz = 0 needs a local iteration for the thickness) |
| trusses, shells, beams | refused (large rotations of structural elements come later in M15) |
Without nonlinear geometry (`"nonlinear_geometry": false`) the elements are the linear ones:
every material is elastic today, so such a step is the linear solution reached by Newton (the
path the next materials, plasticity first, take with small strains).
Without nonlinear geometry (`"nonlinear_geometry": false`) an elastic element is the linear one
(K u), and an element of a plastic material takes the same loops with small strain
(`Kinematics::Small`: ε = sym ∇u, the reference gradients, f = ∫ Bᵀσ dV, K = ∫ Bᵀ𝔻B dV with the
consistent tangent, no geometric stiffness; hybrid elements project 1 + tr ε and K tr ε), so an
elastic model is still the linear solution reached by Newton.
**Follower pressure** (`fieldcraft_fem::face::follower_pressure`, `follower_edge_pressure`):
fᵢ = −p ∫ Nᵢ (x,ξ × x,η) dξ dη on the current face, with its load stiffness
@@ -207,6 +226,23 @@ the rate of convergence of open loaded surfaces; it is revisited with the symmet
(LDLᵀ) factorization that arc-length and Taylor–Hood need. Without nonlinear geometry a pressure
is a dead load.
**Load path.** A step's `load_path`, `[[fraction, factor], …]` from `[0, 0]` to fraction 1, makes
the load factor piecewise linear in the step fraction (without one it equals the fraction), so
loads and prescribed displacements can come down or reverse within a step: cyclic loading.
Increments end at every point of the path, and the extrapolated predictor scales with the change
of the load factor, so it turns with the path. LS-DYNA writes the path as the step's curve;
Abaqus refuses it by name for now (it would be an amplitude on every load and boundary
condition).
**Predictors.** An increment starts from the linear extrapolation of the last one; with
path-dependent materials an increment without one (the first, after a turn of the path to zero
change) starts from the tangent predictor: one solve with the converged state's tangent for the
change of the loads and of the prescribed displacements, so a prescribed motion is spread into the
interior before the material sees it (started from the boundary's jump alone, the elements next
to it would flow plastically far beyond the solution). Its work is then the energy criterion's
reference. A first tangent that is not positive definite is a cutback when the undeformed
model's tangent is (the strain of the increment, not a model that can move freely).
**Newton–Raphson** with the consistent tangent refactored every iteration, an optional line
search (off by default: on large elastic rotations the full step converges in fewer
iterations), and convergence when the force residual (∞-norm over the largest force seen), the
@@ -219,19 +255,35 @@ tangent is not positive definite, or the iterations run out; the step stops with
and the reason at the minimum increment, the increment cap, or `Limits::max_newton_iterations`.
**Results**: the final displacements and reactions, the **Cauchy stress** and the
**logarithmic strain** (ln V, engineering shears) at the integration points and averaged at the
nodes, every increment's load factor, iterations and residuals, and a displacement frame per
**logarithmic strain** (ln V, engineering shears; the small strain without nonlinear geometry) at
the integration points and averaged at the nodes, the **equivalent plastic strain** at the
integration points of plastic elements (`solve.probe` with `elements`, the largest in the step
summary), every increment's load factor, iterations and residuals, and a displacement frame per
converged increment (`results.frame`, the slider in the results panel).
**Couplings** stay the linear constraints of the linear solver (small rotations of the
reference points), with a warning; springs act along their fixed directions. Temperature and
centrifugal loads are refused by name in nonlinear steps.
reference points), with a warning; springs act along their fixed directions. Centrifugal loads
are refused by name in nonlinear steps.
**Thermal strain.** A nonlinear step's temperatures are its temperature loads, or the steady
conduction of its temperature boundary conditions and heat loads solved first (one-way, as the
linear coupled temperature–displacement step); they ramp with the load factor from each
material's reference temperature. With nonlinear geometry F = F_m F_θ, F_θ = diag(1 + αᵢ ΔT) at
each integration point (V. A. Lubarda, Appl. Mech. Rev. 57:95–108, 2004): the material sees F_m,
the loops run on the expanded volume J_θ dV₀ (mixed elements project det F_m), and the reported
strain is the total logarithmic one; the linear stretch 1 + α ΔT keeps α the engineering
coefficient, so a free body grows by exactly α ΔT. Without nonlinear geometry the split is
additive (ε_m = ε − α ΔT), and elastic elements on their linear stiffness take the equivalent
nodal forces as a dead load. Johnson–Cook plasticity softens at the point's temperature. The
expansion is constant in temperature (the model has no temperature-dependent coefficient yet).
**Decks**: Abaqus `*STEP, NLGEOM=YES|NO, INC=` with `*STATIC` (initial, period 1, minimum,
maximum) and `*CONTROLS, PARAMETERS=LINE SEARCH`; the neo-Hookean option as `*HYPERELASTIC, NEO
HOOKE`. LS-DYNA: the ramped implicit run with full Newton (`ilimit` 1, `maxref`) and
HOOKE`; plasticity as for explicit dynamics (`*PLASTIC`); a load path, and a plastic deck of
several general steps (Abaqus carries the plastic state from one to the next, every model step
starts undeformed), are refused. LS-DYNA: the ramped implicit run with full Newton (`ilimit` 1, `maxref`) and
`*CONTROL_IMPLICIT_AUTO`; hyperelastic materials in nonlinear steps and steps without nonlinear
geometry are refused. Nastran refuses nonlinear steps (SOL 400) by name. Iteration caps and
geometry are refused; a load path is the step's curve. Nastran refuses nonlinear steps (SOL 400) by name. Iteration caps and
tolerances are left to each solver's own controls, whose norms differ from ours.
**Verification** (`crates/solver/tests/nonlinear.rs`, `crates/fem/src/{constitutive,finite,face}.rs`,
@@ -242,4 +294,24 @@ test on a distorted mesh (all solids, hybrid ones included); a 90° rigid rotati
the elastica (Bisshopp–Drucker) at P L²/(E I) = 1, 2, 5, 10 with hex20 (0.2–0.5 %); the inflation of a thick neo-Hookean tube by a follower pressure (Rivlin's closed form);
Newton's quadratic convergence; the linear limit; a finite-strain Cook's membrane is pending
its transcribed reference; and the failures (an element inverted by
crushing stops at the minimum increment, naming it; the caps; what is refused).
crushing stops at the minimum increment, naming it; the caps; what is refused). Plasticity
(`crates/solver/tests/plasticity.rs`, `crates/fem/src/elastoplastic.rs`, `tests/oracle/`
`nl-j2-uniaxial`, `j2-bauschinger`, `plastic-thick-cylinder`, `plastic-hinge-cantilever`): the
consistent tangents against central differences from a plastic history (material point and
every element, finite and small strain, full and hybrid); uniaxial tension to a logarithmic
strain of 0.2 in every element kind, hybrid ones included (Kirchhoff stress exact to 10⁻⁷, and
0.13 % from explicit dynamics' hypoelastic Cauchy stress, the elastic volume change between the
two laws); the Bauschinger effect on a load path back to zero strain (exact); Hill's thick
cylinder, the bore displacement at a plastic radius of 1.5a (0.009 %) and the collapse pressure
(2·10⁻⁸); a cantilever's collapse 3.1 % above beam theory's hinge, converged under refinement;
Newton's order with the consistent tangent 1.9 (small strain) and 2.3 (finite strain) in a
plastic cantilever; and the refusals and the softening material that stops at the minimum
increment. Mooney–Rivlin and Yeoh (`nl-mooney-rivlin-modes`, `nl-yeoh-uniaxial`,
`nl-mooney-rivlin-tube`): tangents by central differences (material point and hybrid
elements), Treloar's uniaxial, equibiaxial and pure-shear modes against the compressible closed
form (exact), and a tube inflated by a follower pressure while stretched axially to λ_z = 1.5
(Rivlin's incompressible solution, 0.6 % at ν = 0.4995). Thermal strain (`nl-thermal-expansion`,
`j2-heated-bar`, `crates/solver/tests/nonlinear_thermal.rs`): free expansion and a fully held
block at a thermal stretch of 1.2 (exact, full and hybrid), a bar held at its ends heated past
yield (exact), the linear limit against the linear and coupled solvers, and tangents with a
temperature field.
+29 -26
View File
@@ -8,30 +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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| bent-tube | sweepable, thin-wall, genus | 1 | 18 | 4296 | 0.121 | 2726 | 4213.1 | 4159.4 | 1.3e-2 | 4159.8 | ok, 2148 vertices | 1 | 268 | 19.8 | 15550 | 2338 | 0 | pass | 4070 | sweep | 192 | 0.859 | 0.997 | 535 |
| bolted-assembly | assembly, blocky | 3 | 22 | 284 | 0.117 | 1141 | 33004.7 | 32999.0 | 1.7e-4 | 32998.9 | ok, 144 vertices | 0, 1, 1 | 104 | 1.6 | 690 | 181 | 0 | pass | 211 | extrude | 357 | 0.398 | 0.222 | 139 |
| cantilever-i-beam | sweepable, thin-wall | 1 | 14 | 44 | 0.208 | 483 | 61600.0 | 61600.0 | 0.0e0 | 61600.0 | ok, 24 vertices | 0 | 36 | 0.2 | 90 | 24 | 0 | pass | 5 | extrude | 336 | 0.707 | 0.707 | 47 |
| cross-cylinders | boolean, sharp | 1 | 10 | 1395 | 0.087 | 59348 | 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; … | | | | |
| filleted-bracket | fillet, genus, sweepable | 1 | 13 | 540 | 0.088 | 3706 | 32162.3 | 32145.0 | 5.4e-4 | 32144.8 | ok, 268 vertices | 2 | 135 | 2.1 | 5582 | 1613 | 0 | pass | 1769 | 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: | | | | 10 |
| i-beam | sweepable, thin-wall | 1 | 14 | 44 | 0.208 | 280 | 61600.0 | 61600.0 | 0.0e0 | 61600.0 | ok, 24 vertices | 0 | 36 | 0.3 | 94 | 24 | 0 | pass | 7 | extrude | 336 | 0.707 | 0.707 | 52 |
| loft-transition | organic | 1 | 6 | 292 | 0.075 | 397 | 56390.2 | 56450.6 | -1.1e-3 | – | ok, 148 vertices | 0 | 40 | 1.0 | 1329 | 256 | 1 | pass | 172 | sweep | 2873 | 0.189 | 0.189 | 2056 |
| perforated-plate | genus, thin-wall, sweepable | 1 | 54 | 1644 | 0.144 | 2864 | 26822.2 | 26764.6 | 2.2e-3 | 26764.2 | ok, 776 vertices | 24 | 828 | 10.6 | 33611 | 9798 | 0 | pass | 6513 | extrude | 467 | 0.304 | 0.033 | 480 |
| pinhole-block | small-feature, genus | 1 | 38 | 4044 | 0.073 | 2053 | 49919.4 | 49909.6 | 2.0e-4 | 49909.5 | ok, 1992 vertices | 16 | 1548 | 41.8 | 96411 | 30221 | 0 | pass | 28180 | extrude | 5236 | 0.408 | 0.392 | 3838 |
| rounded-box | fillet | 1 | 26 | 2540 | 0.054 | 1069 | 23325.7 | 23340.5 | -6.3e-4 | 23340.8 | ok, 1272 vertices | 0 | 216 | 11.5 | 11653 | 1914 | 0 | pass | 2265 | no: Box: not supported yet: no structured hex method fits this body (extrude: cylindrical face 8 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 | | | | 67 |
| shallow-groove | near-tangent, boolean | 1 | 8 | 62 | 0.075 | 218 | 47795.2 | 47782.3 | 2.7e-4 | 47781.5 | ok, 33 vertices | 0 | 24 | 0.2 | 267 | 87 | 0 | pass | 24 | extrude | 1960 | 0.991 | 0.986 | 1078 |
| sharp-wedge | sharp, thin-wall, sweepable | 1 | 5 | 8 | 0.083 | 72 | 3200.0 | 3200.0 | 0.0e0 | 3200.0 | ok, 6 vertices | 0 | 9 | 0.0 | 11 | 3 | 0 | pass | 1 | extrude | 294 | 0.050 | 0.050 | 87 |
| sphere-on-plate | near-tangent, boolean | 1 | 12 | 2826 | 0.098 | 3775 | 69353.3 | 69509.0 | -2.2e-3 | 69509.7 | ok, 1415 vertices | 0 | 124 | 8.3 | 4898 | 309 | 9 | pass | 1265 | 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 | | | | 75 |
| spline-vase | organic | 1 | 11 | 3890 | 0.106 | 1097 | 94891.2 | 94948.5 | -6.0e-4 | – | ok, 1947 vertices | 0 | 243 | 23.5 | 12461 | 1512 | 0 | pass | 2487 | sweep | 4554 | 0.649 | 0.649 | 3030 |
| stepped-block | blocky, sharp | 1 | 14 | 96 | 0.078 | 438 | 47000.0 | 47000.0 | 0.0e0 | 47000.0 | ok, 48 vertices | 1 | 60 | 0.6 | 223 | 41 | 0 | pass | 11 | extrude | 1416 | 0.999 | 0.999 | 906 |
| thin-ribs | thin-wall, blocky | 1 | 26 | 92 | 0.119 | 1085 | 32000.0 | 32000.0 | -4.5e-16 | 32000.0 | ok, 48 vertices | 0 | 72 | 0.7 | 171 | 33 | 0 | pass | 10 | extrude | 777 | 0.844 | 0.844 | 236 |
| thin-wall-enclosure | thin-wall, blocky | 1 | 11 | 36 | 0.108 | 189 | 15564.0 | 15564.0 | 0.0e0 | 15564.0 | ok, 20 vertices | 0 | 28 | 0.2 | 191 | 47 | 2 | pass | 8 | extrude | 894 | 0.297 | 0.297 | 349 |
| analytic-sphere | analytic | 1 | 6 | 2784 | 0.069 | 1340 | 33353.4 | 33507.9 | -4.6e-3 | 33510.3 | ok, 1394 vertices | 0 | 96 | 8.9 | 4211 | 58 | 0 | pass | 1430 | 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 | | | | 159 |
| analytic-torus | analytic, genus | 1 | 6 | 7230 | 0.109 | 1725 | 37644.1 | 37893.7 | -6.6e-3 | 37899.3 | ok, 3615 vertices | 1 | 180 | 31.7 | 19790 | 466 | 0 | pass | 4946 | revolve | 1134 | 0.706 | 0.706 | 1155 |
| analytic-cylinder | analytic, sweepable | 1 | 4 | 1004 | 0.049 | 368 | 12533.1 | 12564.7 | -2.5e-3 | 12566.4 | ok, 504 vertices | 0 | 106 | 9.3 | 7437 | 1568 | 0 | pass | 1797 | extrude | 3200 | 0.707 | 0.707 | 2316 |
| analytic-pipe | analytic, genus, sweepable | 1 | 6 | 1448 | 0.104 | 506 | 94150.9 | 94238.0 | -9.3e-4 | 94247.8 | ok, 724 vertices | 1 | 220 | 9.5 | 6238 | 1340 | 0 | pass | 1734 | extrude | 1288 | 0.998 | 0.998 | 1059 |
| analytic-plate-hole | analytic, genus, sweepable | 1 | 8 | 112 | 0.224 | 524 | 98449.1 | 98429.5 | 2.0e-4 | 98429.2 | ok, 56 vertices | 1 | 62 | 0.6 | 1868 | 543 | 0 | pass | 78 | extrude | 321 | 0.320 | 0.142 | 248 |
| 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 |
## Categories
@@ -39,8 +40,8 @@ clock on the machine that ran it; every other column is deterministic.
|---|---|---|
| thin-wall | walls thin against the part's size | 7 |
| small-feature | features much smaller than the part | 1 |
| fillet | blends between faces | 2 |
| genus | through holes (genus > 0) | 7 |
| fillet | blends between faces | 3 |
| genus | through holes (genus > 0) | 8 |
| sharp | sharp edges and acute dihedral angles | 3 |
| near-tangent | faces meeting at a shallow angle | 2 |
| boolean | intersection curves from booleans | 3 |
@@ -48,6 +49,7 @@ clock on the machine that ran it; every other column is deterministic.
| blocky | axis-aligned blocks (polycube-friendly) | 4 |
| organic | free-form, lofted or revolved spline surfaces | 2 |
| assembly | several bodies placed by component transforms | 1 |
| partitioned | split into cells that each mesh as a mapped block (conforming multi-block hex) | 1 |
| analytic | closed-form geometry for solver verification | 5 |
## Parts
@@ -57,6 +59,7 @@ clock on the machine that ran it; every other column is deterministic.
- `cantilever-i-beam`: The 200 mm I-beam of `i-beam` lying along X with its web upright (Z up): a sweep along a horizontal axis, and the cantilever of the README demo.
- `cross-cylinders`: Two orthogonal R10 cylinders joined: equal radii, so the intersection curves cross at two singular points.
- `filleted-bracket`: An 8 mm L-bracket with an R6 inside fillet and a hole through each leg: a blend beside holes.
- `filleted-bracket-blocks`: The filleted bracket split into 30 cells with SolidCraft's Split Body (three slabs across its width, the corner halved along its diagonal, an O-grid of four cells round each hole) so that every cell is a mapped block: conforming multi-block hex meshing.
- `i-beam`: A 200 mm I-beam with 3 mm flanges and a 2 mm web: a long sweepable prism with thin walls.
- `loft-transition`: A 40 mm square lofted to an R15 circle 50 mm above: a ruled free-form surface between unlike sections.
- `perforated-plate`: A 3 mm plate perforated by a 6 x 4 grid of 6 mm holes: genus 24 in a thin sheet.
+59 -2
View File
@@ -531,6 +531,53 @@ Delaunay methods with Voronoi/ODT improvement, then (2) all-hex and hex-dominant
with conforming templates and no dualization.
- **Use.** Lets us refine a coarse aligned hex mesh, for example polycube output, as well as an octree.
- **Code.** `cg3hci/Gen-Adapt-Ref-for-Hexmeshing` is **MIT**: READ OK.
- **Finding (2026-10).** The reference pipeline chooses transition vertices with an integer linear
program (Gurobi) and installs CinoLib's schemes by heuristics that are retried with other
parameters when they fail; neither was adopted (see B1.10).
**B1.7 Schneiders, Algorithms for quadrilateral and hexahedral mesh generation (VKI lecture notes,
2000), §5.** **Verdict: built on (the transition templates of `fieldcraft-mesh::octree`).**
- **Link.** https://www.robertschneiders.de/papers/vki.pdf (author's copy).
- **Method.** 3-refinement driven by nodal marks: an element is split by the template of its
marked corners, and faces split by their own marks only, so the refinement is conforming.
- **Limit (stated there).** The 2D templates of the original set make some 3D mark patterns
impossible: a hexahedron with seven marked corners has 51 boundary quads, an odd number, so no
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.
**B1.8 Ito, Shih, Soni 2009, Octree-based reasonable-quality hexahedral mesh generation using a
new set of refinement templates.** **Verdict: reference.**
- **Citation.** IJNME 77(13):1809–1833, 2009. Templates for concave edges of the refined region
(paper not obtained; cited after Tong and Zhang 2025).
**B1.9 Tong, Halilaj, Zhang 2024, HybridOctree_Hex.** **Verdict: reference (buffer-zone rule adopted).**
- **Citation.** H. Tong, E. Halilaj, Y. J. Zhang, *HybridOctree_Hex: Hybrid octree-based adaptive
all-hexahedral mesh generation with Jacobian control*, arXiv 2401.05984, 2024.
- **Method.** Dual of a strongly balanced, paired octree with five templates (a 13-hexahedron
frustum per transition face plus four transition-edge templates); a core cleared at the
boundary so that the normals of the quads around each core boundary vertex lie in one
half-space, then a buffer layer projected onto the surface and optimised.
- **Code.** `CMU-CBML/HybridOctree_Hex` is **BSD-3-Clause**: READ OK (hard-coded template
geometry, not adopted).
**B1.10 Tong, Zhang 2025, MCHex.** **Verdict: reference (next boundary stage to evaluate).**
- **Citation.** H. Tong, Y. J. Zhang, *MCHex: Marching cubes based adaptive hexahedral mesh
generation with guaranteed positive Jacobian*, arXiv 2511.02064, 2025.
- **Method.** The same 3-refinement templates (B1.7); the boundary cells are cut by marching
cubes on a signed distance field, every cut cell's pieces are 3-regular polyhedra, and their
midpoint subdivision gives hexahedra with a proven positive Jacobian (Bernstein coefficients) and
a boundary that is pillowed by construction; 17 of the 31 cube cases are refined locally first.
- **Limits (stated there).** Sharp edges and corners are lost (marching cubes smooths them), and
the convex-only transitions over-refine. ModelCraft keeps the core-padding-pillow boundary with
feature capture for CAD parts; MCHex's cut cells are the candidate for organic shapes.
**Implemented (`fieldcraft-mesh::octree`, `mesh.hex.octree`, 2026-10).** B1.7 templates on a
27-tree refined by curvature, thickness and narrow faces; a core cleared by the B1.9 rule; the
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.
---
@@ -880,7 +927,7 @@ input points), Steiner points kept half the local spacing from the boundary.
### B8. Mapped and swept hexahedral meshing (implemented in `fieldcraft-mesh::hex`)
`mesh.hex.generate` (and `mesh.hex.sweep`, `mesh.hex.mapped`). Every method below is implemented from
`mesh.hex.generate` (and `mesh.hex.sweep`, `mesh.hex.mapped`, `mesh.hex.blocks`). Every method below is implemented from
its paper; no code was read. DOIs marked "checked" were confirmed on 2026-10-13 through a repository
record or the publisher; the others are cited by venue only.
@@ -932,7 +979,17 @@ record or the publisher; the others are cited by venue only.
Applied Mathematics and Computation 10–11:171–233, 1982.
- **Use.** Interior nodes of a logical cube from its six face grids (`hex::mapped`).
**B8.8 Support.** Generalized winding number for the inside test (A. Jacobson, L. Kavan, O. Sorkine-Hornung,
**B8.8 Multi-block meshing of partitioned parts.** **Verdict: BUILD ON (conforming cells).**
- **Citations.** J. F. Thompson, B. K. Soni, N. P. Weatherill (eds.). *Handbook of Grid Generation.* CRC Press,
1999, ch. 3 (transfinite and algebraic grids) and ch. 13 (multiblock grids). A. Sheffer, M. Bercovier,
T. Blacker, J. Clements. *Virtual topology operators for meshing.* IJCGA 10(3):309–331, 2000.
- **Use.** `hex::cells` (`mesh.hex.blocks`): cells that meet face to face, each a logical cube after smoothly
meeting faces are joined into composite faces (virtual topology); vertices, edges and faces shared by
identity; interval assignment over all cells (B8.5); each edge divided once, each face's transfinite grid
made once and used by both cells; edges that leave a curved face graded from the spacing along it with a
growth of 1.2 per element; interiors by B8.7. The merged mesh is checked for free internal faces.
**B8.9 Support.** Generalized winding number for the inside test (A. Jacobson, L. Kavan, O. Sorkine-Hornung,
*Robust inside-outside segmentation using generalized winding numbers*, ACM TOG 32(4):33, 2013; solid angle
of a triangle: Van Oosterom and Strackee 1983); closest point on a triangle (C. Ericson, *Real-Time
Collision Detection*, 2005, §5.1.5).
+39 -2
View File
@@ -2,7 +2,7 @@
Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the policy is [verification.md](verification.md). Every case is our own model, built with FieldCraft commands from the problem's published description, and compared with the reference value its `expected.json` cites, within the stated tolerance and for the stated reason. Accepted results are frozen in each case's `fixture.json`: a later change that moves one beyond its band (10⁻⁹ of its size) fails CI even while the reference still passes. No reference value was produced with a commercial product.
**142 of 148 quantities pass** (6 known failure(s)) in 40 active cases: 31 theory, 9 published benchmark, 0 experiment; 10 pending cases wait for capabilities still being built.
**170 of 176 quantities pass** (6 known failure(s)) in 50 active cases: 41 theory, 9 published benchmark, 0 experiment; 9 pending cases wait for capabilities still being built.
## Theory: closed-form solutions, patch tests and mesh invariants
@@ -31,6 +31,10 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
| [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 |
| [free-beam-frequencies](../tests/oracle/free-beam-frequencies/) | total mass | 20 | 20 | < 1e-7% | 1e-12 rel. | pass | matches |
| [j2-bauschinger](../tests/oracle/j2-bauschinger/) | axial stress back at zero strain after a pull to ε_a = 0.01 | -247.52 | -247.52 | < 1e-7% | 1e-6 rel. | pass | matches |
| [j2-bauschinger](../tests/oracle/j2-bauschinger/) | accumulated equivalent plastic strain | 0.016089 | 0.016089 | < 1e-7% | 1e-6 rel. | pass | matches |
| [j2-heated-bar](../tests/oracle/j2-heated-bar/) | axial stress, ΔT = 300 (α ΔT = 3.6·10⁻³, 2.9 times the yield strain) | -254.65 | -254.65 | < 1e-7% | 1e-8 rel. | pass | matches |
| [j2-heated-bar](../tests/oracle/j2-heated-bar/) | equivalent plastic strain | 0.0023267 | 0.0023267 | < 1e-7% | 1e-7 rel. | pass | matches |
| [j2-uniaxial-explicit](../tests/oracle/j2-uniaxial-explicit/) | axial Cauchy stress at a logarithmic strain of 0.2 | 643.56 | 643.57 | 0.000174% | 1% | pass | matches |
| [j2-uniaxial-explicit](../tests/oracle/j2-uniaxial-explicit/) | equivalent plastic strain | 0.19678 | 0.19678 | -0.00000113% | 1% | pass | matches |
| [kirsch-plate](../tests/oracle/kirsch-plate/) | σ_xx at the top of the hole (0, 1, 0) over the remote stress | 3 | 3.0121 | 0.402% | 2% | pass | matches |
@@ -57,6 +61,15 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
| [nl-elastica-cantilever](../tests/oracle/nl-elastica-cantilever/) | tip shortening δ_h/L at P L²/(E I) = 5 | 0.38763 | 0.38955 | 0.495% | 2% | pass | matches |
| [nl-elastica-cantilever](../tests/oracle/nl-elastica-cantilever/) | tip deflection δ_v/L at P L²/(E I) = 10 | 0.81061 | 0.81421 | 0.444% | 1% | pass | matches |
| [nl-elastica-cantilever](../tests/oracle/nl-elastica-cantilever/) | tip shortening δ_h/L at P L²/(E I) = 10 | 0.555 | 0.558 | 0.541% | 2% | pass | matches |
| [nl-j2-uniaxial](../tests/oracle/nl-j2-uniaxial/) | axial Cauchy stress at a logarithmic strain of 0.2 | 642.74 | 642.74 | < 1e-7% | 1e-6 rel. | pass | matches |
| [nl-j2-uniaxial](../tests/oracle/nl-j2-uniaxial/) | equivalent plastic strain | 0.19678 | 0.19678 | < 1e-7% | 1e-6 rel. | pass | matches |
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | axial Cauchy stress, uniaxial stretch λ = 2 | 2.7366 | 2.7366 | < 1e-7% | 1e-6 rel. | pass | matches |
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | lateral displacement at (1, 1, 1), uniaxial: l − 1 | -0.28647 | -0.28647 | < 1e-7% | 1e-6 rel. | pass | matches |
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | in-plane Cauchy stress, equibiaxial stretch λ = 1.5 | 2.8427 | 2.8427 | < 1e-7% | 1e-6 rel. | pass | matches |
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | Cauchy stress along the stretch, pure shear λ = 2 | 3.5261 | 3.5261 | < 1e-7% | 1e-6 rel. | pass | matches |
| [nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/) | Cauchy stress across, in the held direction, pure shear λ = 2 | 1.5067 | 1.5067 | < 1e-7% | 1e-6 rel. | pass | matches |
| [nl-mooney-rivlin-tube](../tests/oracle/nl-mooney-rivlin-tube/) | radial displacement of the bore (10, 0, 0) at the pressure that inflates it to a = 15 at λ_z = 1.5 | 5 | 5.0311 | 0.622% | 1% | pass | matches |
| [nl-mooney-rivlin-tube](../tests/oracle/nl-mooney-rivlin-tube/) | radial displacement of the outer wall (20, 0, 0): b − B, a small difference of radii | 0.61553 | 0.64339 | 2.8e-2 | ± 0.04 | pass | matches |
| [nl-neo-hookean-uniaxial](../tests/oracle/nl-neo-hookean-uniaxial/) | axial Cauchy stress at the corner (1, 1, 1), stretch λ = 2 | 8.0601 | 8.0601 | < 1e-7% | 1e-6 rel. | pass | matches |
| [nl-neo-hookean-uniaxial](../tests/oracle/nl-neo-hookean-uniaxial/) | lateral displacement u_y at the corner (1, 1, 1), l − 1 | -0.18686 | -0.18686 | < 1e-7% | 1e-6 rel. | pass | matches |
| [nl-patch-hex8](../tests/oracle/nl-patch-hex8/) | u_x at inner node 22 | 0.13583 | 0.13583 | < 1e-7% | 1e-8 rel. | pass | matches |
@@ -72,6 +85,18 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
| [nl-rigid-rotation](../tests/oracle/nl-rigid-rotation/) | u_y at inner node 22: the rotation's | 0.078591 | 0.078591 | -5.6e-17 | ± 1.0000e-9 | pass | matches |
| [nl-rigid-rotation](../tests/oracle/nl-rigid-rotation/) | u_z at inner node 22: the rotation's | -0.067441 | -0.067441 | -1.2e-16 | ± 1.0000e-9 | pass | matches |
| [nl-rigid-rotation](../tests/oracle/nl-rigid-rotation/) | von Mises stress at inner node 22 | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-8 | pass | matches |
| [nl-thermal-expansion](../tests/oracle/nl-thermal-expansion/) | displacement of the corner (1, 1, 1) along x, free, α ΔT = 0.2 | 0.2 | 0.2 | < 1e-7% | 1e-8 rel. | pass | matches |
| [nl-thermal-expansion](../tests/oracle/nl-thermal-expansion/) | stress of the free body (σ_xx at the corner) | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-8 | pass | matches |
| [nl-thermal-expansion](../tests/oracle/nl-thermal-expansion/) | stress of the held block, σ_xx (hydrostatic) | -3.5108 | -3.5108 | < 1e-7% | 1e-8 rel. | pass | matches |
| [nl-yeoh-uniaxial](../tests/oracle/nl-yeoh-uniaxial/) | axial Cauchy stress, stretch λ = 2 | 3.1743 | 3.1743 | < 1e-7% | 1e-6 rel. | pass | matches |
| [nl-yeoh-uniaxial](../tests/oracle/nl-yeoh-uniaxial/) | lateral displacement at (1, 1, 1): l − 1 | -0.28545 | -0.28545 | < 1e-7% | 1e-6 rel. | pass | matches |
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | x reaction on the clamped face (5 mm mesh) | -2000 | -2000 | < 1e-7% | 1e-6 rel. | pass | matches |
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | elements the certified Jacobian check does not prove valid (5 mm mesh) | 0 | 0 | ≈ 0 | exact | pass | matches |
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | mesh volume against the CAD volume (5 mm mesh) | 32145 | 32177 | 0.1% | 0.3% | pass | matches |
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | σ_yy on the outer face of the upright leg at y = 19, z = 6 (5 mm mesh) | 145.31 | 149.26 | 2.72% | 5% | pass | matches |
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | σ_yy on the inner face of the upright leg at y = 19, z = 6 (5 mm mesh) | -145.31 | -147.28 | -1.35% | 5% | pass | matches |
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | von Mises stress at the middle of the fillet, 7 mm mesh | 143.21 | 138.53 | -3.27% | 5% | pass | matches |
| [octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/) | von Mises stress at the middle of the fillet, 5 mm mesh | 143.21 | 144.53 | 0.921% | 2% | pass | matches |
| [patch-hex20](../tests/oracle/patch-hex20/) | u_x at the corner (1, 1, 1) | -2.5000e-4 | -2.5000e-4 | < 1e-7% | 1e-9 rel. | pass | matches |
| [patch-hex20](../tests/oracle/patch-hex20/) | u_y at the corner (1, 1, 1) | 0.001 | 0.001 | < 1e-7% | 1e-9 rel. | pass | matches |
| [patch-hex20](../tests/oracle/patch-hex20/) | u_z at the corner (1, 1, 1) | 0.00225 | 0.00225 | < 1e-7% | 1e-9 rel. | pass | matches |
@@ -121,6 +146,9 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
| [patch-wedge6](../tests/oracle/patch-wedge6/) | σ_yy at the inner node (0.46, 0.55, 0.53) | 2 | 2 | < 1e-7% | 1e-9 rel. | pass | matches |
| [patch-wedge6](../tests/oracle/patch-wedge6/) | σ_zz at the inner node (0.46, 0.55, 0.53) | 3 | 3 | < 1e-7% | 1e-9 rel. | pass | matches |
| [patch-wedge6](../tests/oracle/patch-wedge6/) | σ_xy at the inner node | 0 | ≈ 0 | ≈ 0 | ± 1.0000e-9 | pass | matches |
| [plastic-hinge-cantilever](../tests/oracle/plastic-hinge-cantilever/) | tip force at a tip deflection of 15 (4.5 times the first yield's) | -312.5 | -322.61 | -3.23% | 5% | pass | matches |
| [plastic-thick-cylinder](../tests/oracle/plastic-thick-cylinder/) | bore displacement at the pressure that takes the plastic zone to c = 15 (a = 10, b = 20, σ_y = 250) | 0.024355 | 0.024358 | 0.0089% | 0.2% | pass | matches |
| [plastic-thick-cylinder](../tests/oracle/plastic-thick-cylinder/) | radial force on the bore (per radian times 2π, height 1) at a bore displacement of 0.3, 24 times the first yield's: the collapse pressure 2k ln 2 = 200.094 times 2πa | 12572 | 12572 | 0.00000178% | 0.1% | pass | matches |
| [pure-bending-coupling](../tests/oracle/pure-bending-coupling/) | bending stress σ_xx at mid-span, top fibre (10, 0.5, 1) | 15 | 15 | -0.0000103% | 0.5% | pass | matches |
| [pure-bending-coupling](../tests/oracle/pure-bending-coupling/) | rotation of the reference point about y | 0.3 | 0.30355 | 1.18% | 1% | known failure: 1.2% high: the distributing coupling weights every node of the end face equally, corner and mid-side nodes of hex20 alike, which is not the consistent linear traction; the end face deforms locally and the fitted rotation includes it. Area-consistent weights would remove it | — |
| [rotating-cylinder-axisymmetric](../tests/oracle/rotating-cylinder-axisymmetric/) | radial stress σ_r on the axis (r = 0) | 0.38571 | 0.38608 | 0.0958% | 1% | pass | matches |
@@ -173,6 +201,8 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
- **[cdt-cylinder](../tests/oracle/cdt-cylinder/)** (theory): Constrained Delaunay mesh of a cylinder: volume within the chordal bound, valid elements. Source: mesh invariants: an inscribed polygon of sagitta s loses at most 2s/R of a circle's area (elementary geometry); constrained Delaunay tetrahedralization after Diazzi et al., ACM TOG 42(6) 2023. Tolerances — volume: the tessellation inscribes the cylinder within the chordal tolerance s = 0.05: the polygon section loses between 0 and 2s/R = 0.5% of the area; the reference is the middle of that band; invalid: every element of a valid mesh has positive volume
- **[cdt-stepped-block](../tests/oracle/cdt-stepped-block/)** (theory): Constrained Delaunay mesh of a stepped block: exact volume, valid elements. Source: mesh invariants: a tetrahedralization of a polyhedron fills its volume exactly (Si, TetGen paper ACM TOMS 41(2) 2015 for the definition of a CDT; Diazzi et al., ACM TOG 42(6) 2023); the volume is 60·40·10 + 40·40·10 + 20·40·10 − 10·10·10. Tolerances — volume: a planar boundary is tessellated exactly and the constrained Delaunay mesh fills it exactly: only rounding; invalid: every element of a valid mesh has positive volume; undetermined: a linear tetrahedron's sign is exact
- **[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
- **[j2-uniaxial-explicit](../tests/oracle/j2-uniaxial-explicit/)** (theory): Uniaxial tension of a J2 bilinear-hardening cube to large strain (explicit). Source: von Mises plasticity with linear isotropic hardening under uniaxial stress (Simo and Hughes, Computational Inelasticity, Springer 1998, §2.3); logarithmic strain for a hypoelastic–plastic law under coaxial stretching. Tolerances — sigma: uniaxial J2 with linear hardening: σ = σ_y + E_t (ε − σ_y/E) in logarithmic strain; the stretch takes 10⁴ wave transits, so inertia and the ringing of the start (ρcv ≈ 0.1% of σ) stay well under 1%; plastic_strain: ε_p = ε − σ/E, as for the stress
- **[kirsch-plate](../tests/oracle/kirsch-plate/)** (theory): Plate with a circular hole in tension (Kirsch). Source: Kirsch, Zeitschrift des VDI 42 (1898) 797–807; Timoshenko and Goodier, Theory of Elasticity, 3rd ed., 1970, §35. Tolerances — kt: the reference is the infinite plate; the finite plate (W = 60a, Heywood's width correction about +0.1%, and the loaded ends 30a away) raises the peak by a few tenths of a percent, and the stress extrapolated to the hole surface adds up to about 1%; side: infinite-plate value −S; finite width and extrapolation as for Kt, on a smaller value
- **[kirsch-plate-plane-stress](../tests/oracle/kirsch-plate-plane-stress/)** (theory): Plate with a circular hole in tension, 2D plane stress (Kirsch). Source: Kirsch, Zeitschrift des VDI 42 (1898) 797–807; Timoshenko and Goodier, Theory of Elasticity, 3rd ed., 1970, §35. Tolerances — kt: the reference is the infinite plate; the finite plate (W = 60a, loaded ends 30a away) raises the peak by a few tenths of a percent, and the stress extrapolated to the hole surface adds up to about 1%; side: infinite-plate value −S; finite width and extrapolation as for Kt, on a smaller value
@@ -189,9 +219,15 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
- **[nafems-le10](../tests/oracle/nafems-le10/)** (benchmark): NAFEMS LE10 thick plate under pressure (hex20). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE10 (target σ_yy = −5.38 MPa at D); as reproduced in the Abaqus 2017 Benchmarks Guide, 'LE10: Thick plate under pressure'. Tolerances — syy_D: the target is published to three figures; at D the stress is extrapolated to a corner where the hole edge meets the loaded face, and published converged solutions scatter by 1–2% (Abaqus Benchmarks Guide fine meshes: −5.51 to −5.89)
- **[nl-cylinder-inflation](../tests/oracle/nl-cylinder-inflation/)** (theory): Inflation of a thick neo-Hookean cylinder by a follower pressure (plane strain). Source: Finite inflation of an incompressible neo-Hookean thick-walled tube in plane strain: r² = R² + a² − A², σ_θ − σ_r = μ (λ² − λ⁻²) with λ = r/R, integrated through the wall: p = (μ/2)[(ln λ_a² − λ_a⁻²) − (ln λ_b² − λ_b⁻²)] (R. S. Rivlin, Large elastic deformations of isotropic materials VI, Phil. Trans. R. Soc. A 242:173–195, 1949; R. W. Ogden, Non-Linear Elastic Deformations, Dover 1997, §5.3.2). Tolerances — ua: the closed form is incompressible and our material nearly so (κ/μ = 1000, a volume change of order p/κ ≈ 4·10⁻⁴); hybrid hex20, 6 × 16, follow the wall's strain gradient well inside; ub: as at the bore
- **[nl-elastica-cantilever](../tests/oracle/nl-elastica-cantilever/)** (theory): Large-deflection cantilever under a dead tip load against the elastica (solids). Source: The elastica of a cantilever under a vertical end load: K. E. Bisshopp, D. C. Drucker, Large deflection of cantilever beams, Quarterly of Applied Mathematics 3(3):272–275, 1945 (elliptic-integral solution); values computed by integrating θ″ = −(P/EI) cos θ by shooting (our own RK4, 20000 steps), agreeing with the table of K. Mattiasson, Numerical results from large deflection beam and frame problems analysed by means of elliptic integrals, IJNME 17(1):145–153, 1981, to all six digits. Tolerances — v1: hex20, 40 × 2 through the depth, L/h = 20: shear deformation (0.6 (h/L)² ≈ 0.15 % of the deflection) and the clamp's local restraint, both absent from the elastica; u1: as δ_v; the shortening is the smaller, second-order quantity, so its relative error is about twice the deflection's; v2: hex20, 40 × 2 through the depth, L/h = 20: shear deformation (0.6 (h/L)² ≈ 0.15 % of the deflection) and the clamp's local restraint, both absent from the elastica; u2: as δ_v; the shortening is the smaller, second-order quantity, so its relative error is about twice the deflection's; v5: hex20, 40 × 2 through the depth, L/h = 20: shear deformation (0.6 (h/L)² ≈ 0.15 % of the deflection) and the clamp's local restraint, both absent from the elastica; u5: as δ_v; the shortening is the smaller, second-order quantity, so its relative error is about twice the deflection's; v10: hex20, 40 × 2 through the depth, L/h = 20: shear deformation (0.6 (h/L)² ≈ 0.15 % of the deflection) and the clamp's local restraint, both absent from the elastica; u10: as δ_v; the shortening is the smaller, second-order quantity, so its relative error is about twice the deflection's
- **[nl-j2-uniaxial](../tests/oracle/nl-j2-uniaxial/)** (theory): Uniaxial tension of a J2 bilinear-hardening cube to large strain (implicit, finite strain). Source: J2 plasticity with linear isotropic hardening in uniaxial stress (Simo and Hughes, Computational Inelasticity, Springer 1998, §2.3) in the logarithmic strain, which Simo's exponential return map (CMAME 99:61–112, 1992) integrates exactly on a proportional path: Kirchhoff stress τ = σ_y + E_t (ε − σ_y/E), Cauchy stress σ = τ/J with ln J = τ/(3K) (Hencky's volume law). Tolerances — sigma: a homogeneous deformation the element represents exactly, reached on a proportional path: the tolerance only admits the Newton tolerance and rounding; plastic_strain: ε_p = (τ − σ_y)/H, as for the stress
- **[nl-mooney-rivlin-modes](../tests/oracle/nl-mooney-rivlin-modes/)** (theory): Mooney–Rivlin block in uniaxial, equibiaxial and pure-shear tension. Source: Treloar's homogeneous modes of rubber testing (L. R. G. Treloar, The Physics of Rubber Elasticity, 3rd ed., Oxford 1975, ch. 10) for the decoupled compressible energy W = C₁₀ (Ī₁ − 3) + C₀₁ (Ī₂ − 3) + κ/2 (J − 1)²: principal Cauchy stresses σᵢ = (λᵢ/J) ∂W/∂λᵢ with the free stretch found so the free faces carry no stress (bisection; E = 3, ν = 0.49, C₀₁ = 0.2, C₁₀ = μ/2 − C₀₁). Tolerances — uniaxial: a homogeneous deformation the hybrid hex20 represents exactly: the tolerance admits the Newton tolerance and rounding; uniaxial_lateral: a homogeneous deformation the hybrid hex20 represents exactly: the tolerance admits the Newton tolerance and rounding; equibiaxial: a homogeneous deformation the hybrid hex20 represents exactly: the tolerance admits the Newton tolerance and rounding; shear_xx: a homogeneous deformation the hybrid hex20 represents exactly: the tolerance admits the Newton tolerance and rounding; shear_yy: a homogeneous deformation the hybrid hex20 represents exactly: the tolerance admits the Newton tolerance and rounding
- **[nl-mooney-rivlin-tube](../tests/oracle/nl-mooney-rivlin-tube/)** (theory): Inflation of a stretched Mooney–Rivlin tube (follower pressure). Source: Finite inflation and extension of an incompressible Mooney–Rivlin tube (R. S. Rivlin, Large elastic deformations of isotropic materials VI, Phil. Trans. R. Soc. A 242:173–195, 1949; R. W. Ogden, Non-Linear Elastic Deformations, Dover 1997, §5.3): at axial stretch λ_z, r² = a² + (R² − A²)/λ_z, σ_θ − σ_r = 2 (λ_θ² − λ_r²)(C₁₀ + C₀₁ λ_z²) with λ_θ = r/R, λ_r = 1/(λ_θ λ_z), integrated through the wall: p = ∫ (σ_θ − σ_r) dr/r (Simpson, 2·10⁵ intervals). Tolerances — ua: 16 × 6 hybrid hex20 around the quarter; ν = 0.4995 stands in for incompressibility (a volume change of order p/κ ≈ 10⁻³ shifts the radii by about 0.1 %), and the tube is stretched axially by its ends, which a nearly incompressible solid takes with larger pressures than the plane-strain neo-Hookean tube; ub: 0.2 % of the outer radius b = 20.616: the same volume change of order 10⁻³ moves b by about 0.03, which is 4.5 % of b − B; the incompressible kinematics r² = a² + (R² − A²)/λ_z amplify it there
- **[nl-neo-hookean-uniaxial](../tests/oracle/nl-neo-hookean-uniaxial/)** (theory): Uniaxial stretch of a neo-Hookean block to twice its length. Source: Closed form of the compressible neo-Hookean model W = μ/2 (Ī₁ − 3) + κ/2 (J − 1)² in uniaxial stress (G. A. Holzapfel, Nonlinear Solid Mechanics, Wiley 2000, §6.5, eq. 6.148 with the volumetric term of §6.4): σ_yy = μ J^(−5/3)(l² − Ī/3) + κ(J − 1) = 0 fixes the lateral stretch l, then σ_xx = μ J^(−5/3)(λ² − l²). Tolerances — sigma: a homogeneous deformation every element represents exactly: the tolerance only admits rounding and the Newton tolerance; lateral: a homogeneous deformation every element represents exactly: the tolerance only admits rounding and the Newton tolerance
- **[nl-patch-hex8](../tests/oracle/nl-patch-hex8/)** (theory): Nonlinear patch test, hex8: a homogeneous finite deformation of a distorted mesh. Source: The patch test (B. M. Irons, A. Razzaque, in A. K. Aziz (ed.), The Mathematical Foundations of the Finite Element Method, Academic Press 1972, 557–587) at finite strain: a boundary moved by x = F X makes F the solution everywhere (J. Bonet, R. D. Wood, Nonlinear Continuum Mechanics for Finite Element Analysis, 2nd ed., Cambridge 2008, §8); the neo-Hookean Cauchy stress σ = μ J^(−5/3) dev b + κ (J − 1) I of that F. Tolerances — ux: a patch test is passed exactly: the tolerance admits rounding and the Newton tolerance; uy: a patch test is passed exactly: the tolerance admits rounding and the Newton tolerance; uz: a patch test is passed exactly: the tolerance admits rounding and the Newton tolerance; sxx: a patch test is passed exactly: the tolerance admits rounding and the Newton tolerance; syy: a patch test is passed exactly: the tolerance admits rounding and the Newton tolerance; szz: a patch test is passed exactly: the tolerance admits rounding and the Newton tolerance; sxy: a patch test is passed exactly: the tolerance admits rounding and the Newton tolerance; sxz: a patch test is passed exactly: the tolerance admits rounding and the Newton tolerance; syz: a patch test is passed exactly: the tolerance admits rounding and the Newton tolerance
- **[nl-rigid-rotation](../tests/oracle/nl-rigid-rotation/)** (theory): A quarter turn strains and stresses nothing (objectivity). Source: Objectivity of the total Lagrangian formulation and of the hyperelastic stress: a rigid motion x = R X + c gives C = I and σ = 0 whatever the rotation (Bonet & Wood 2008, §3.4 and §5.2; T. Belytschko, W. K. Liu, B. Moran, Nonlinear Finite Elements for Continua and Structures, 2nd ed., Wiley 2014, §5.5). Tolerances — ux: the inner nodes follow the rigid rotation exactly; uy: the inner nodes follow the rigid rotation exactly; uz: the inner nodes follow the rigid rotation exactly; von_mises: zero up to rounding of stresses of order E = 1000: a 90° turn leaves no strain
- **[nl-thermal-expansion](../tests/oracle/nl-thermal-expansion/)** (theory): Free and fully constrained thermal expansion at finite strain. Source: The multiplicative split F = F_m F_θ with F_θ = (1 + α ΔT) I (V. A. Lubarda, Appl. Mech. Rev. 57(2):95–108, 2004): a free body follows F = F_θ stress-free; held on every face, F = I and F_m = ϑ⁻¹ I, so the compressible neo-Hookean material carries the pressure of its volume ratio, σ = κ (ϑ⁻³ − 1). Tolerances — free_u: a homogeneous stretch every element represents exactly; free_stress: rounding only; held_stress: a homogeneous state; E = 10, ν = 0.3 (κ = 8.3333), ϑ = 1.2
- **[nl-yeoh-uniaxial](../tests/oracle/nl-yeoh-uniaxial/)** (theory): Yeoh block in uniaxial tension. Source: Uniaxial tension of the decoupled compressible Yeoh energy W = C₁₀ (Ī₁ − 3) + C₂₀ (Ī₁ − 3)² + C₃₀ (Ī₁ − 3)³ + κ/2 (J − 1)² (O. H. Yeoh, Rubber Chem. Technol. 66(5):754–771, 1993): σ = (λ/J) ∂W/∂λ with the lateral stretch found so σ_yy = 0 (bisection; E = 3, ν = 0.49, C₁₀ = μ/2, C₂₀ = −0.02, C₃₀ = 0.004). Tolerances — sigma: a homogeneous deformation the hybrid hex20 represents exactly: the tolerance admits the Newton tolerance and rounding; lateral: a homogeneous deformation the hybrid hex20 represents exactly: the tolerance admits the Newton tolerance and rounding
- **[octree-filleted-bracket](../tests/oracle/octree-filleted-bracket/)** (theory): Filleted L-bracket on the octree all-hex mesh: equilibrium, beam bending, fillet stress convergence. Source: equilibrium; Euler–Bernoulli bending of the 8 × 40 mm leg, σ = M c / I with M = F (50 − y) (S. P. Timoshenko, J. N. Goodier, Theory of Elasticity, 3rd ed., 1970, §102); grid convergence of the fillet stress against the 4 mm mesh of this case (P. J. Roache, Verification and Validation in Computational Science and Engineering, 1998, ch. 5). Tolerances — reaction_x: the reactions balance the 2 kN load to solver precision; invalid: the octree mesher returns only elements proven valid; volume: bilinear boundary faces on the R6 fillet and the two Ø8 holes, and the 1e-3 chordal tessellation they are fitted to; syy_outer: beam theory ignores the corner 5 mm below and the hole 9 mm above; the probe takes the nearest node's extrapolated stress; syy_inner: as for the outer face; fillet_7: the coarsest mesh, within 5% of the 4 mm mesh; fillet_5: converging: within 2% of the 4 mm mesh
- **[patch-hex20](../tests/oracle/patch-hex20/)** (theory): Constant-stress patch test, hex20. Source: Irons and Razzaque, 'Experience with the patch test for convergence of finite elements', in Aziz (ed.), The Mathematical Foundations of the Finite Element Method, Academic Press 1972, pp. 557–587; MacNeal and Harder, Finite Elements in Analysis and Design 1 (1985) 3–20, constant-stress loading, p. 14 Table 7. Tolerances — ux: a patch test is passed exactly: the tolerance only admits rounding; uy: a patch test is passed exactly: the tolerance only admits rounding; uz: a patch test is passed exactly: the tolerance only admits rounding; sxx: a patch test is passed exactly: the tolerance only admits rounding; syy: a patch test is passed exactly: the tolerance only admits rounding; szz: a patch test is passed exactly: the tolerance only admits rounding; sxy: a patch test is passed exactly: the tolerance only admits rounding (stresses of order 1)
- **[patch-hex20-reduced](../tests/oracle/patch-hex20-reduced/)** (theory): Constant-stress patch test, hex20-reduced. Source: Irons and Razzaque, 'Experience with the patch test for convergence of finite elements', in Aziz (ed.), The Mathematical Foundations of the Finite Element Method, Academic Press 1972, pp. 557–587; MacNeal and Harder, Finite Elements in Analysis and Design 1 (1985) 3–20, constant-stress loading, p. 14 Table 7. Tolerances — ux: a patch test is passed exactly: the tolerance only admits rounding; uy: a patch test is passed exactly: the tolerance only admits rounding; uz: a patch test is passed exactly: the tolerance only admits rounding; sxx: a patch test is passed exactly: the tolerance only admits rounding; syy: a patch test is passed exactly: the tolerance only admits rounding; szz: a patch test is passed exactly: the tolerance only admits rounding; sxy: a patch test is passed exactly: the tolerance only admits rounding (stresses of order 1)
- **[patch-hex8](../tests/oracle/patch-hex8/)** (theory): Constant-stress patch test, hex8. Source: Irons and Razzaque, 'Experience with the patch test for convergence of finite elements', in Aziz (ed.), The Mathematical Foundations of the Finite Element Method, Academic Press 1972, pp. 557–587; MacNeal and Harder, Finite Elements in Analysis and Design 1 (1985) 3–20, constant-stress loading, p. 14 Table 7. Tolerances — ux: a patch test is passed exactly: the tolerance only admits rounding; uy: a patch test is passed exactly: the tolerance only admits rounding; uz: a patch test is passed exactly: the tolerance only admits rounding; sxx: a patch test is passed exactly: the tolerance only admits rounding; syy: a patch test is passed exactly: the tolerance only admits rounding; szz: a patch test is passed exactly: the tolerance only admits rounding; sxy: a patch test is passed exactly: the tolerance only admits rounding (stresses of order 1)
@@ -199,6 +235,8 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
- **[patch-tet10](../tests/oracle/patch-tet10/)** (theory): Constant-stress patch test, tet10. Source: Irons and Razzaque, 'Experience with the patch test for convergence of finite elements', in Aziz (ed.), The Mathematical Foundations of the Finite Element Method, Academic Press 1972, pp. 557–587; MacNeal and Harder, Finite Elements in Analysis and Design 1 (1985) 3–20, constant-stress loading, p. 14 Table 7. Tolerances — ux: a patch test is passed exactly: the tolerance only admits rounding; uy: a patch test is passed exactly: the tolerance only admits rounding; uz: a patch test is passed exactly: the tolerance only admits rounding; sxx: a patch test is passed exactly: the tolerance only admits rounding; syy: a patch test is passed exactly: the tolerance only admits rounding; szz: a patch test is passed exactly: the tolerance only admits rounding; sxy: a patch test is passed exactly: the tolerance only admits rounding (stresses of order 1)
- **[patch-tet4](../tests/oracle/patch-tet4/)** (theory): Constant-stress patch test, tet4. Source: Irons and Razzaque, 'Experience with the patch test for convergence of finite elements', in Aziz (ed.), The Mathematical Foundations of the Finite Element Method, Academic Press 1972, pp. 557–587; MacNeal and Harder, Finite Elements in Analysis and Design 1 (1985) 3–20, constant-stress loading, p. 14 Table 7. Tolerances — ux: a patch test is passed exactly: the tolerance only admits rounding; uy: a patch test is passed exactly: the tolerance only admits rounding; uz: a patch test is passed exactly: the tolerance only admits rounding; sxx: a patch test is passed exactly: the tolerance only admits rounding; syy: a patch test is passed exactly: the tolerance only admits rounding; szz: a patch test is passed exactly: the tolerance only admits rounding; sxy: a patch test is passed exactly: the tolerance only admits rounding (stresses of order 1)
- **[patch-wedge6](../tests/oracle/patch-wedge6/)** (theory): Constant-stress patch test, wedge6. Source: Irons and Razzaque, 'Experience with the patch test for convergence of finite elements', in Aziz (ed.), The Mathematical Foundations of the Finite Element Method, Academic Press 1972, pp. 557–587; MacNeal and Harder, Finite Elements in Analysis and Design 1 (1985) 3–20, constant-stress loading, p. 14 Table 7. Tolerances — ux: a patch test is passed exactly: the tolerance only admits rounding; uy: a patch test is passed exactly: the tolerance only admits rounding; uz: a patch test is passed exactly: the tolerance only admits rounding; sxx: a patch test is passed exactly: the tolerance only admits rounding; syy: a patch test is passed exactly: the tolerance only admits rounding; szz: a patch test is passed exactly: the tolerance only admits rounding; sxy: a patch test is passed exactly: the tolerance only admits rounding (stresses of order 1)
- **[plastic-hinge-cantilever](../tests/oracle/plastic-hinge-cantilever/)** (theory): Plastic collapse of a cantilever: the fully plastic moment at the root. Source: Limit analysis (B. G. Neal, The Plastic Methods of Structural Analysis, 3rd ed., Chapman & Hall 1977, ch. 1–2): beam theory's plastic hinge at the root, the fully plastic moment M_p = σ_y b h²/4 of a rectangular section, collapses a cantilever of length L at P_c = M_p/L = 312.5 (b = h = 10, L = 200, σ_y = 250); a bound for the solid, whose collapse lies above it (W. Prager, P. G. Hodge, Theory of Perfectly Plastic Solids, Wiley 1951: a continuum mechanism allows no normal velocity jump, which the beam's hinge rotation is). Tolerances — collapse_force: the solid's collapse lies above beam theory's hinge: refined through the depth (4 to 8 elements) and along the length (40 to 160) the force settles 3.1 % above M_p/L (322.3), so the band is that bound's gap; the root is a symmetry plane (no rotation, free lateral contraction) held on average through a coupling, the tip pushed through another; reduced hex20, 20 × 2 × 4
- **[plastic-thick-cylinder](../tests/oracle/plastic-thick-cylinder/)** (theory): Elastic–perfectly plastic thick cylinder: plastic zone and collapse pressure. Source: Hill, The Mathematical Theory of Plasticity, Oxford 1950, ch. V §2: a von Mises cylinder in plane strain, elastically incompressible, with the plastic zone a ≤ r ≤ c under the pressure p = k (2 ln(c/a) + 1 − c²/b²), k = σ_y/√3, and the bore displacement u_a = k c²/(2 G a); collapse when c = b at p = 2k ln(b/a). Tolerances — u_bore: 20 reduced quad8 elements through the wall resolve the logarithmic plastic field and ν = 0.4999 stands in for Hill's incompressible elasticity (0.009 % apart); u_a ∝ c², so 0.2 % on u_a is 0.1 % on the plastic radius; collapse_force: once the wall is plastic through, perfect plasticity holds the force at the collapse value (σ_θ − σ_r = 2k everywhere, σ_z the mean of the others as plastic flow dominates); the band admits the quadrature of the logarithmic field
- **[pure-bending-coupling](../tests/oracle/pure-bending-coupling/)** (theory): Pure bending through a distributing coupling. Source: Euler–Bernoulli pure bending, σ = M_y z/I, θ = ML/(EI) (exact in elasticity for ν = 0); Timoshenko and Goodier, Theory of Elasticity, 3rd ed., 1970, §102. Tolerances — sigma_top: with ν = 0 pure bending is a quadratic displacement field that hex20 represents exactly; the coupling's nodal force pattern differs from the linear traction only within a depth or two of the end (Saint-Venant), five depths from the probe; rotation: the reference point follows the best-fit rigid motion of the end section, which the end zone's local deformation disturbs by well under 1%
- **[rotating-cylinder-axisymmetric](../tests/oracle/rotating-cylinder-axisymmetric/)** (theory): Long rotating solid cylinder, axisymmetric. Source: Timoshenko and Goodier, Theory of Elasticity, 3rd ed., McGraw-Hill 1970, §32 (long rotating cylinder, ε_z = 0). Tolerances — radial_axis: stress extrapolated to the axis, where σ_r = σ_θ: under 1% with sixteen quadratic elements across; hoop_rim: stress extrapolated to the surface: under 1%
- **[rotating-disc-plane-stress](../tests/oracle/rotating-disc-plane-stress/)** (theory): Rotating disc with a central hole, plane stress. Source: Timoshenko and Goodier, Theory of Elasticity, 3rd ed., McGraw-Hill 1970, §32 (rotating disc of uniform thickness with a hole). Tolerances — hoop_bore: stress extrapolated to the bore surface, where the a²b²/r² term is steepest: 16 radial quadratic elements graded towards the bore leave about 0.2% (8 uniform: −2.4%; 32 graded: −0.05%); radial_max: the nearest node to √(ab) is within half an element of it, where σ_r is stationary (an error of order (h/r)², under 0.5%), and nodal averaging adds a few tenths of a percent; hoop_rim: as at the bore
@@ -218,6 +256,5 @@ Reference values already cited; each case gets its recipe when the capability it
| [nl-cook-membrane](../tests/oracle/nl-cook-membrane/) | benchmark | Cook's membrane, nearly incompressible neo-Hookean, large load (plane strain) | the published finite-strain reference value, transcribed from the paper with its load and material constants | vertical displacement of the top corner C (48, 60) = to be transcribed | R. D. Cook, Improved two-dimensional finite element, J. Struct. Div. ASCE 100(ST9):1851–1863, 1974 (the tapered panel (0, 0), (48, 44), (48, 60), (0, 44), clamped on x = 0, shear on x = 48); finite-strain versions with a neo-Hookean material are published by J. C. Simo, F. Armero, IJNME 33:1413–1449, 1992; the values to compare with are to be transcribed from the paper |
| [pinched-cylinder](../tests/oracle/pinched-cylinder/) | benchmark | Pinched cylinder with rigid diaphragms | shell elements (M8) | radial displacement under the load = -1.8248e-5 | Belytschko, Stolarski, Liu, Carpenter and Ong, 'Stress projection for membrane and shear locking in shell finite elements', Computer Methods in Applied Mechanics and Engineering 51 (1985) 221–258 (the shell obstacle course); reference 1.8248 × 10⁻⁵ under the load |
| [pinched-hemisphere](../tests/oracle/pinched-hemisphere/) | benchmark | Hemispherical shell with an 18° hole under alternating point loads | shell elements (M8), or mapped blocks that follow doubly curved faces | displacement under the load along the load = 0.094 | MacNeal and Harder, 'A proposed standard set of problems to test finite element accuracy', Finite Elements in Analysis and Design 1 (1985) 3–20, Fig. 9 (p. 9), Table 5(b) (p. 11): 0.0940 (lower bound 0.0924 from their [7]) |
| [plastic-thick-cylinder](../tests/oracle/plastic-thick-cylinder/) | theory | Elastic–perfectly plastic thick cylinder: collapse pressure | implicit (static) J2 plasticity: explicit J2 is in, a collapse load needs the static solver | collapse pressure for a = 10, b = 20, σ_y = 250 = 200.09 | Hill, The Mathematical Theory of Plasticity, Oxford 1950, ch. V §2: plane strain von Mises collapse pressure p = (2/√3)σ_y ln(b/a) |
| [scordelis-lo-roof-shell](../tests/oracle/scordelis-lo-roof-shell/) | benchmark | Scordelis–Lo roof with shell elements | shell elements (MITC4, M8) | vertical displacement at the midpoint of the free edge = -0.3024 | MacNeal and Harder, 'A proposed standard set of problems to test finite element accuracy', Finite Elements in Analysis and Design 1 (1985) 3–20, Fig. 8 (p. 8), Table 5(a) (p. 11); Belytschko, Stolarski, Liu, Carpenter and Ong, 'Stress projection for membrane and shear locking in shell finite elements', Computer Methods in Applied Mechanics and Engineering 51 (1985) 221–258 (the shell obstacle course) |
| [taylor-bar](../tests/oracle/taylor-bar/) | experiment | Taylor bar impact (copper) | the measured final shapes, to be transcribed from the paper (explicit Johnson–Cook plasticity is in) | final over initial length = to be transcribed | Wilkins and Guinan, 'Impact of cylinders on a rigid boundary', Journal of Applied Physics 44 (1973) 1200–1206 (final length and mushroom diameter vs impact speed) |
+3 -3
View File
@@ -44,7 +44,7 @@ products are run, no documentation text is copied). Status: **done**, **started*
| Mesh | Mesh editing (move, merge, split, collapse, remesh region), imported orphan meshes | M6 | yes | started (node/element add/remove) |
| Model | Sets and surfaces bound to geometry names | M3 | yes | done |
| Model | Materials: elastic (isotropic, orthotropic), density, unit-system check | M3 | | done |
| Model | Materials: plasticity, hyperelasticity, damage, thermal | M8 | | started (von Mises plasticity: bilinear, table, Johnson–Cook; Johnson–Cook damage, erosion; written to Abaqus `*PLASTIC`, Nastran `MATS1`, LS-DYNA plastic materials where expressible; compressible neo-Hookean for nonlinear steps, Abaqus `*HYPERELASTIC, NEO HOOKE`) |
| Model | Materials: plasticity, hyperelasticity, damage, thermal | M8 | | started (von Mises plasticity: bilinear, table, Johnson–Cook; Johnson–Cook damage, erosion; plasticity in explicit and nonlinear static steps; written to Abaqus `*PLASTIC`, Nastran `MATS1`, LS-DYNA plastic materials where expressible; compressible neo-Hookean, Mooney–Rivlin and Yeoh for nonlinear steps, Abaqus `*HYPERELASTIC, NEO HOOKE`, `MOONEY-RIVLIN`, `YEOH`; thermal strain in nonlinear steps) |
| Model | Sections: solid, shell, beam profiles; assignment by set | M3 | | done |
| Model | 2D sections: plane stress, plane strain, axisymmetric (XY plane, Y the axis); element edges in sets; centrifugal load | M4 | | done |
| Model | Composite layups, material orientations, continuum shells | M8 | yes | planned |
@@ -53,7 +53,7 @@ products are run, no documentation text is copied). Status: **done**, **started*
| Model | Loads: force and moment, pressure, gravity, body force | M3 | | done |
| Model | Reference points, kinematic and distributing coupling | M3 | | done |
| Model | Steps: linear static, frequency; output presets | M3 | | done |
| Model | Steps: nonlinear static, explicit dynamics, heat transfer; amplitudes | M7–M8, M15 | | started (nonlinear static with nonlinear geometry, increments and tolerances, written to Abaqus `*STEP, NLGEOM` and LS-DYNA implicit, refused by Nastran; explicit dynamics; heat transfer; amplitudes planned) |
| Model | Steps: nonlinear static, explicit dynamics, heat transfer; amplitudes | M7–M8, M15 | | started (nonlinear static with or without nonlinear geometry, increments, tolerances and a load path (cyclic loading), written to Abaqus `*STEP, NLGEOM` and LS-DYNA implicit (the load path as LS-DYNA's curve; refused by Abaqus for now), refused by Nastran; explicit dynamics; heat transfer; amplitudes per load planned) |
| Interaction | Tie between non-matching meshes | M7 | | planned |
| Interaction | Contact (general and pairs), friction, automatic pair detection | M7 | | planned |
| Interaction | Springs and dashpots (ground or two-point; DOF, vector or line), point masses with rotary inertia | M4 | | done (implicit; explicit translational; all three decks with what each cannot take reported) |
@@ -63,7 +63,7 @@ products are run, no documentation text is copied). Status: **done**, **started*
| Decks | LS-DYNA keyword writer (implicit; explicit in M8) | M3 | | done (implicit static and eigenvalue; beam sections planned) |
| Decks | Readers keeping unknown keywords | M9 | | planned |
| Solve | Own linear static and modal solver, verified against analytic solutions | M4 | yes | started (solid elements: tet4, tet10, wedge6, hex8, hex20; 2D plane stress, plane strain and axisymmetric tri3, tri6, quad4 (full, incompatible modes), quad8 (full, reduced); quad4/tri3 MITC shells and Timoshenko beams with profiles, mixed with solids; reference points, kinematic and distributing couplings, moments; centrifugal loads) |
| Solve | Own nonlinear implicit solver: Newton–Raphson with line search and automatic increments, total Lagrangian solids and 2D elements, mixed u–p at finite strain, follower pressure | M15 | yes | started (solids, plane strain, axisymmetric; St Venant–Kirchhoff and neo-Hookean; verified by the elastica, tube inflation, patch and objectivity tests; plasticity, shells, contact, arc-length next) |
| Solve | Own nonlinear implicit solver: Newton–Raphson with line search and automatic increments, total Lagrangian solids and 2D elements, mixed u–p at finite strain, follower pressure | M15 | yes | started (solids, plane strain, axisymmetric; St Venant–Kirchhoff, neo-Hookean and J2 plasticity (Simo's exponential return map at finite strain, radial return at small strain, consistent tangents, the return shared with explicit dynamics); verified by the elastica, tube inflation, patch and objectivity tests, uniaxial tension to large strain, Hill's thick cylinder, a cantilever's collapse and the Bauschinger effect; Mooney–Rivlin and Yeoh; thermal strain at finite and small strain; shells, contact, arc-length next) |
| Solve | Own explicit dynamics solver (central differences, one-point hex8 with hourglass control, nodal-pressure tet4, bulk viscosity, energy balance), verified against analytic wave and impact solutions | M10 | yes | started (X1 core and X2: large deformation, objective rates, J2 and Johnson–Cook plasticity, damage, erosion; contact planned) |
| Results | Deformed shape, contours, mode shapes, probes, reaction sums | M4 | | started (results in the session, `solve.probe`, reaction sums; display planned) |
| Results | XY and path plots, cuts, display groups, free-body forces, animation | M9 | | planned |
+42
View File
@@ -1186,3 +1186,45 @@ update are written from (cited again in the module docs):
transcribed finite-strain values), R. D. Cook, J. Struct. Div. ASCE 100(ST9):1851–1863, 1974,
and J. C. Simo, F. Armero, IJNME 33:1413–1449, 1992; tube inflation, R. S. Rivlin, Phil. Trans. R. Soc. A
242:173–195, 1949, and R. W. Ogden, *Non-Linear Elastic Deformations*, Dover 1997.
## Part N: Implicit plasticity (finite and small strain), as implemented
- Multiplicative J2 plasticity with a Hencky elastic energy and the exponential return map in
the principal logarithmic strains, which keeps the classical radial return of the
infinitesimal theory: J. C. Simo, *Algorithms for static and dynamic multiplicative plasticity
that preserve the classical return mapping schemes of the infinitesimal theory*, CMAME
99(1):61–112, 1992, DOI [10.1016/0045-7825(92)90123-2](https://doi.org/10.1016/0045-7825(92)90123-2);
J. C. Simo, T. J. R. Hughes, *Computational Inelasticity*, Springer 1998, ch. 9.
- The radial return (shared with explicit dynamics) and its consistent tangent
K I⊗I + 2Gθ ℙ − 2Gθ̄ n⊗n: Simo & Hughes 1998, §3.3, Box 3.2 (A4.1); J. C. Simo, R. L. Taylor,
*Consistent tangent operators for rate-independent elastoplasticity*, CMAME 48(1):101–118,
1985, DOI [10.1016/0045-7825(85)90070-2](https://doi.org/10.1016/0045-7825(85)90070-2).
- The derivative of the matrix logarithm by divided differences (Daleckii–Krein), smooth through
equal stretches: R. Bhatia, *Matrix Analysis*, Springer 1997, §V.3.
- Kinematic hardening at finite strain (refused for now; the route when it comes): additive
plasticity in the Lagrangian logarithmic strain space, C. Miehe, N. Apel, M. Lambrecht, CMAME
191(47–48):5383–5425, 2002, DOI [10.1016/S0045-7825(02)00438-3](https://doi.org/10.1016/S0045-7825(02)00438-3).
- Prager's linear kinematic hardening: W. Prager, *The theory of plasticity: a survey of recent
achievements*, Proc. Inst. Mech. Eng. 169:41–57, 1955.
- Verification references: the thick cylinder's plastic zone and collapse pressure, R. Hill,
*The Mathematical Theory of Plasticity*, Oxford 1950, ch. V; the cantilever's plastic hinge,
B. G. Neal, *The Plastic Methods of Structural Analysis*, 3rd ed., Chapman & Hall 1977, and the
continuum's velocity fields, W. Prager, P. G. Hodge, *Theory of Perfectly Plastic Solids*,
Wiley 1951.
## Part O: Polynomial hyperelasticity and thermal strain in nonlinear steps, as implemented
- Decoupled invariant-based hyperelasticity, the fictitious stress and elasticity and their
projection: G. A. Holzapfel, *Nonlinear Solid Mechanics*, Wiley 2000, §6.4–6.6 (eqs. 6.168,
6.193).
- Mooney–Rivlin: M. Mooney, *A theory of large elastic deformation*, J. Appl. Phys.
11(9):582–592, 1940; R. S. Rivlin, *Large elastic deformations of isotropic materials IV*,
Phil. Trans. R. Soc. A 241:379–397, 1948. Yeoh: O. H. Yeoh, *Some forms of the strain energy
function for rubber*, Rubber Chem. Technol. 66(5):754–771, 1993.
- The homogeneous modes of rubber testing: L. R. G. Treloar, *The Physics of Rubber
Elasticity*, 3rd ed., Oxford 1975, ch. 10; the inflation and extension of a tube: R. S. Rivlin,
Phil. Trans. R. Soc. A 242:173–195, 1949, and R. W. Ogden, *Non-Linear Elastic Deformations*,
Dover 1997, §5.3.
- Thermal strain by the multiplicative split F = F_m F_θ: V. A. Lubarda, *Constitutive
theories based on the multiplicative decomposition of deformation gradient: thermoelasticity,
elastoplasticity, and biomechanics*, Appl. Mech. Rev. 57(2):95–108, 2004.
@@ -0,0 +1 @@
The filleted bracket split into 30 cells with SolidCraft's Split Body (three slabs across its width, the corner halved along its diagonal, an O-grid of four cells round each hole) so that every cell is a mapped block: conforming multi-block hex meshing.
@@ -0,0 +1,39 @@
{"corpus": {"categories": ["fillet", "genus", "partitioned"], "volume_mm3": 32144.778925},
"commands": [
{"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}},
{"command": "solid.split_body", "params": {"body": "Bracket", "plane": {"origin": [0, 0, 30], "normal": [0, 0, 1]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1)", "plane": {"origin": [0, 0, 10], "normal": [0, 0, 1]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1)", "plane": {"origin": [16, 0, 0], "normal": [1, 0, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1) (1)", "plane": {"origin": [0, 16, 0], "normal": [0, 1, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1) (1) (1)", "plane": {"origin": [0, 0, 0], "normal": [1, -1, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1)", "plane": {"origin": [30, 0, 0], "normal": [1, 0, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1)", "plane": {"origin": [50, 0, 0], "normal": [1, 0, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1) (1)", "plane": {"origin": [0, 22, 0], "normal": [0, 1, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1) (1)", "plane": {"origin": [0, 42, 0], "normal": [0, 1, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1)", "plane": {"origin": [16, 0, 0], "normal": [1, 0, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1)'", "plane": {"origin": [0, 16, 0], "normal": [0, 1, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1)' (1)", "plane": {"origin": [0, 0, 0], "normal": [1, -1, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1)", "plane": {"origin": [30, 0, 0], "normal": [1, 0, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1)", "plane": {"origin": [50, 0, 0], "normal": [1, 0, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1)'", "plane": {"origin": [0, 22, 0], "normal": [0, 1, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1)'", "plane": {"origin": [0, 42, 0], "normal": [0, 1, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket", "plane": {"origin": [16, 0, 0], "normal": [1, 0, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1)'", "plane": {"origin": [0, 16, 0], "normal": [0, 1, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1)' (1)", "plane": {"origin": [0, 0, 0], "normal": [1, -1, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket", "plane": {"origin": [30, 0, 0], "normal": [1, 0, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket", "plane": {"origin": [50, 0, 0], "normal": [1, 0, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1)'", "plane": {"origin": [0, 22, 0], "normal": [0, 1, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1)'", "plane": {"origin": [0, 42, 0], "normal": [0, 1, 0]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1)'''", "plane": {"origin": [40, 0, 20], "normal": [1, 0, 1]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1)'''", "plane": {"origin": [40, 0, 20], "normal": [1, 0, -1]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1)''' (1)", "plane": {"origin": [40, 0, 20], "normal": [1, 0, -1]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1)' (1)''", "plane": {"origin": [0, 32, 20], "normal": [0, 1, 1]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1)' (1)''", "plane": {"origin": [0, 32, 20], "normal": [0, 1, -1]}}},
{"command": "solid.split_body", "params": {"body": "Bracket (1) (1)' (1)'' (1)", "plane": {"origin": [0, 32, 20], "normal": [0, 1, -1]}}}
]}
+12
View File
@@ -0,0 +1,12 @@
# Bauschinger effect of linear kinematic hardening
One hex8 cube (E = 200000, ν = 0.3, σ_y = 250, H = 2000, Prager's linear kinematic hardening)
held by symmetry on three faces, its fourth face pulled to a strain of 0.01 and brought back to
zero within one nonlinear static step without nonlinear geometry, by the load path
[[0, 0], [0.5, 1], [1, 0]] in increments of 0.05.
At ε_a the stress is σ_a = σ_y + E_t (ε_a − σ_y/E) = 267.327. The yield surface keeps its size
and moves with the back stress, so unloading is elastic only until σ_a − 2σ_y and then hardens
with E_t: back at zero strain σ = σ_a − 2σ_y − E_t (ε_a − 2σ_y/E) = −247.525. Isotropic hardening
would yield again only at −σ_a and end at −281.835. The equivalent plastic strain accumulates
(σ_a − σ_y)/H forward and E/(E + H)(ε_a − 2σ_y/E) in reverse, 0.0160891.
+45
View File
@@ -0,0 +1,45 @@
{
"title": "Bauschinger effect of linear kinematic hardening: tension, then back to zero strain",
"kind": "theory",
"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)",
"quantities": [
{
"id": "residual_stress",
"quantity": "axial stress back at zero strain after a pull to ε_a = 0.01",
"measure": {
"command": "solve.probe",
"params": {
"step": "Pull",
"elements": [
1
]
},
"pointer": "/elements/0/stress/0/0"
},
"value": -247.52475247524754,
"tolerance": {
"relative": 1e-06
},
"reason": "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)"
},
{
"id": "plastic_strain",
"quantity": "accumulated equivalent plastic strain",
"measure": {
"command": "solve.probe",
"params": {
"step": "Pull",
"elements": [
1
]
},
"pointer": "/elements/0/plastic_strain/0"
},
"value": 0.016089108910891083,
"tolerance": {
"relative": 1e-06
},
"reason": "(σ_a − σ_y)/H forward plus E/(E + H) (ε_a − 2σ_y/E) in reverse"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"plastic_strain": {
"band": 1.6089108910891082e-11,
"value": 0.01608910891089074
},
"residual_stress": {
"band": 2.4752475247524754e-7,
"value": -247.52475247484173
}
}
}
+16
View File
@@ -0,0 +1,16 @@
[
{"command": "mesh.block", "params": {"body": "Cube", "kind": "hex8", "corners": [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], "divisions": [1, 1, 1], "faces": {"6": "x0", "3": "y0", "1": "z0", "4": "x1"}}},
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "Cube"}]}},
{"command": "model.set.create", "params": {"name": "x0", "geometry": [{"body": "Cube", "face": "x0"}]}},
{"command": "model.set.create", "params": {"name": "y0", "geometry": [{"body": "Cube", "face": "y0"}]}},
{"command": "model.set.create", "params": {"name": "z0", "geometry": [{"body": "Cube", "face": "z0"}]}},
{"command": "model.set.create", "params": {"name": "x1", "geometry": [{"body": "Cube", "face": "x1"}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 200000.0, "poisson": 0.3}, "plastic": {"type": "bilinear", "yield_stress": 250.0, "hardening_modulus": 2000.0, "hardening": "kinematic"}}},
{"command": "model.section.create", "params": {"name": "cube", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "sx", "type": "symmetry", "target": {"set": "x0"}, "plane": "x"}},
{"command": "model.boundary_condition.create", "params": {"name": "sy", "type": "symmetry", "target": {"set": "y0"}, "plane": "y"}},
{"command": "model.boundary_condition.create", "params": {"name": "sz", "type": "symmetry", "target": {"set": "z0"}, "plane": "z"}},
{"command": "model.boundary_condition.create", "params": {"name": "pull", "type": "displacement", "target": {"set": "x1"}, "values": [0.01, null, null, null, null, null]}},
{"command": "model.step.create", "params": {"name": "Pull", "procedure": {"type": "nonlinear_static", "nonlinear_geometry": false, "initial_increment": 0.05, "max_increment": 0.05, "load_path": [[0, 0], [0.5, 1], [1, 0]]}, "boundary_conditions": ["sx", "sy", "sz", "pull"], "loads": []}},
{"command": "solve.nonlinear", "params": {"step": "Pull"}, "id": "solve"}
]
+8
View File
@@ -0,0 +1,8 @@
# A heated bar held at its ends past yield
A steel bar 4 × 1 × 1 of 4 hex8 elements (E = 200000, ν = 0.3, σ_y = 250, H = 2000 isotropic,
α = 1.2·10⁻⁵ from 20 degrees) on symmetry planes at x = 0, y = 0, z = 0 and held at x = 4 (its
axial strain is zero; the lateral faces are free), heated uniformly to 320 by a temperature load
in a nonlinear static step without nonlinear geometry. The mechanical strain is −α ΔT = −0.0036,
2.9 times the yield strain, so the bar yields in compression: σ = −(σ_y + E_t (α ΔT − σ_y/E)) =
−254.653 with E_t = E H/(E + H), and ε_p = (|σ| − σ_y)/H.
+45
View File
@@ -0,0 +1,45 @@
{
"title": "A steel bar held at its ends and heated past yield (small strain)",
"kind": "theory",
"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",
"quantities": [
{
"id": "sigma",
"quantity": "axial stress, ΔT = 300 (α ΔT = 3.6·10⁻³, 2.9 times the yield strain)",
"measure": {
"command": "solve.probe",
"params": {
"step": "Heat",
"elements": [
2
]
},
"pointer": "/elements/0/stress/0/0"
},
"value": -254.65346534653466,
"tolerance": {
"relative": 1e-08
},
"reason": "a homogeneous uniaxial state the elements represent exactly"
},
{
"id": "plastic_strain",
"quantity": "equivalent plastic strain",
"measure": {
"command": "solve.probe",
"params": {
"step": "Heat",
"elements": [
2
]
},
"pointer": "/elements/0/plastic_strain/0"
},
"value": 0.0023267326732673297,
"tolerance": {
"relative": 1e-07
},
"reason": "(|σ| − σ_y)/H, as for the stress"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"plastic_strain": {
"band": 2.3267326732675587e-12,
"value": 0.0023267326732675587
},
"sigma": {
"band": 2.5465346534653467e-7,
"value": -254.6534653464418
}
}
}
+17
View File
@@ -0,0 +1,17 @@
[
{"command": "mesh.block", "params": {"body": "Bar", "kind": "hex8", "corners": [[0, 0, 0], [4, 0, 0], [4, 1, 0], [0, 1, 0], [0, 0, 1], [4, 0, 1], [4, 1, 1], [0, 1, 1]], "divisions": [4, 1, 1], "faces": {"6": "x0", "3": "y0", "1": "z0", "4": "x1"}}},
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "Bar"}]}},
{"command": "model.set.create", "params": {"name": "x0", "geometry": [{"body": "Bar", "face": "x0"}]}},
{"command": "model.set.create", "params": {"name": "y0", "geometry": [{"body": "Bar", "face": "y0"}]}},
{"command": "model.set.create", "params": {"name": "z0", "geometry": [{"body": "Bar", "face": "z0"}]}},
{"command": "model.set.create", "params": {"name": "x1", "geometry": [{"body": "Bar", "face": "x1"}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 200000.0, "poisson": 0.3}, "plastic": {"type": "bilinear", "yield_stress": 250.0, "hardening_modulus": 2000.0}, "thermal": {"expansion": 1.2e-05, "reference_temperature": 20.0}}},
{"command": "model.section.create", "params": {"name": "bar", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "sx0", "type": "symmetry", "target": {"set": "x0"}, "plane": "x"}},
{"command": "model.boundary_condition.create", "params": {"name": "sy0", "type": "symmetry", "target": {"set": "y0"}, "plane": "y"}},
{"command": "model.boundary_condition.create", "params": {"name": "sz0", "type": "symmetry", "target": {"set": "z0"}, "plane": "z"}},
{"command": "model.boundary_condition.create", "params": {"name": "sx1", "type": "symmetry", "target": {"set": "x1"}, "plane": "x"}},
{"command": "model.load.create", "params": {"name": "heat", "type": "temperature", "target": {"set": "all"}, "value": 320.0}},
{"command": "model.step.create", "params": {"name": "Heat", "procedure": {"type": "nonlinear_static", "nonlinear_geometry": false}, "boundary_conditions": ["sx0", "sy0", "sz0", "sx1"], "loads": ["heat"]}},
{"command": "solve.nonlinear", "params": {"step": "Heat"}, "id": "solve"}
]
+16
View File
@@ -0,0 +1,16 @@
# Uniaxial tension of a J2 bilinear-hardening cube to large strain (implicit)
The cube of `j2-uniaxial-explicit` solved by the implicit nonlinear solver: one fully integrated
hex8, E = 200000, ν = 0.3, σ_y = 250, H = 2000 (isotropic), held by symmetry on three faces and
stretched on the fourth to λ = e^0.2 in a nonlinear static step, lateral faces free.
Simo's exponential return map integrates this proportional path exactly in the logarithmic
strain, so the Kirchhoff stress is the small-strain curve τ = σ_y + E_t (ε − σ_y/E) = 643.564 at
ε = 0.2 and the equivalent plastic strain (τ − σ_y)/H = 0.196782. The Cauchy stress is
σ = τ/J with Hencky's volume law ln J = τ/(3K): 642.737.
Cross-check with the explicit solver: `j2-uniaxial-explicit` reaches 643.57 for the Cauchy stress
with the same plastic strain. Its hypoelastic update takes the logarithmic strain rate against
the Cauchy stress, while the implicit model is hyperelastic in the Kirchhoff stress: the two
laws differ by the factor J = 1.0013 (the elastic volume change), which is the 0.13 % between
them; both meet the closed form of their own law.
+45
View File
@@ -0,0 +1,45 @@
{
"title": "Uniaxial tension of a J2 bilinear-hardening cube to large strain (implicit, finite strain)",
"kind": "theory",
"source": "J2 plasticity with linear isotropic hardening in uniaxial stress (Simo and Hughes, Computational Inelasticity, Springer 1998, §2.3) in the logarithmic strain, which Simo's exponential return map (CMAME 99:61–112, 1992) integrates exactly on a proportional path: Kirchhoff stress τ = σ_y + E_t (ε − σ_y/E), Cauchy stress σ = τ/J with ln J = τ/(3K) (Hencky's volume law)",
"quantities": [
{
"id": "sigma",
"quantity": "axial Cauchy stress at a logarithmic strain of 0.2",
"measure": {
"command": "solve.probe",
"params": {
"step": "Pull",
"elements": [
1
]
},
"pointer": "/elements/0/stress/0/0"
},
"value": 642.7365391418857,
"tolerance": {
"relative": 1e-06
},
"reason": "a homogeneous deformation the element represents exactly, reached on a proportional path: the tolerance only admits the Newton tolerance and rounding"
},
{
"id": "plastic_strain",
"quantity": "equivalent plastic strain",
"measure": {
"command": "solve.probe",
"params": {
"step": "Pull",
"elements": [
1
]
},
"pointer": "/elements/0/plastic_strain/0"
},
"value": 0.19678217821782182,
"tolerance": {
"relative": 1e-06
},
"reason": "ε_p = (τ − σ_y)/H, as for the stress"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"plastic_strain": {
"band": 1.9678217821795454e-10,
"value": 0.19678217821795452
},
"sigma": {
"band": 6.427365391418857e-7,
"value": 642.7365390754569
}
}
}
+16
View File
@@ -0,0 +1,16 @@
[
{"command": "mesh.block", "params": {"body": "Cube", "kind": "hex8", "corners": [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], "divisions": [1, 1, 1], "faces": {"6": "x0", "3": "y0", "1": "z0", "4": "x1"}}},
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "Cube"}]}},
{"command": "model.set.create", "params": {"name": "x0", "geometry": [{"body": "Cube", "face": "x0"}]}},
{"command": "model.set.create", "params": {"name": "y0", "geometry": [{"body": "Cube", "face": "y0"}]}},
{"command": "model.set.create", "params": {"name": "z0", "geometry": [{"body": "Cube", "face": "z0"}]}},
{"command": "model.set.create", "params": {"name": "x1", "geometry": [{"body": "Cube", "face": "x1"}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 200000.0, "poisson": 0.3}, "plastic": {"type": "bilinear", "yield_stress": 250.0, "hardening_modulus": 2000.0, "hardening": "isotropic"}}},
{"command": "model.section.create", "params": {"name": "cube", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "sx", "type": "symmetry", "target": {"set": "x0"}, "plane": "x"}},
{"command": "model.boundary_condition.create", "params": {"name": "sy", "type": "symmetry", "target": {"set": "y0"}, "plane": "y"}},
{"command": "model.boundary_condition.create", "params": {"name": "sz", "type": "symmetry", "target": {"set": "z0"}, "plane": "z"}},
{"command": "model.boundary_condition.create", "params": {"name": "pull", "type": "displacement", "target": {"set": "x1"}, "values": [0.22140275816016985, null, null, null, null, null]}},
{"command": "model.step.create", "params": {"name": "Pull", "procedure": {"type": "nonlinear_static"}, "boundary_conditions": ["sx", "sy", "sz", "pull"], "loads": []}},
{"command": "solve.nonlinear", "params": {"step": "Pull"}, "id": "solve"}
]
@@ -0,0 +1,14 @@
# Mooney–Rivlin block in Treloar's three modes
One hybrid hex20 unit cube on three symmetry planes, E = 3, ν = 0.49 (μ = 1.006711,
κ = 50), Mooney–Rivlin with C₀₁ = 0.2 (C₁₀ = μ/2 − C₀₁ = 0.303356), in three nonlinear
static steps:
- **Uniaxial**: the face x = 1 pulled to λ = 2, the others free.
- **Equibiaxial**: x = 1 and y = 1 pulled to λ = 1.5, z free.
- **Pure shear**: x = 1 pulled to λ = 2, y = 1 held (λ_y = 1), z free.
The references are the compressible decoupled energy's principal Cauchy stresses
σᵢ = (λᵢ/J) ∂W/∂λᵢ, by differences of W alone, with the free stretch found by bisection so the
free faces carry no stress. The deformation is homogeneous, so the element represents it
exactly.
@@ -0,0 +1,122 @@
{
"title": "Mooney–Rivlin block in uniaxial, equibiaxial and pure-shear tension",
"kind": "theory",
"source": "Treloar's homogeneous modes of rubber testing (L. R. G. Treloar, The Physics of Rubber Elasticity, 3rd ed., Oxford 1975, ch. 10) for the decoupled compressible energy W = C₁₀ (Ī₁ − 3) + C₀₁ (Ī₂ − 3) + κ/2 (J − 1)²: principal Cauchy stresses σᵢ = (λᵢ/J) ∂W/∂λᵢ with the free stretch found so the free faces carry no stress (bisection; E = 3, ν = 0.49, C₀₁ = 0.2, C₁₀ = μ/2 − C₀₁)",
"quantities": [
{
"id": "uniaxial",
"quantity": "axial Cauchy stress, uniaxial stretch λ = 2",
"measure": {
"command": "solve.probe",
"params": {
"step": "Uniaxial",
"at": [
[
1,
1,
1
]
]
},
"pointer": "/nodes/0/stress/0"
},
"value": 2.736633402084229,
"tolerance": {
"relative": 1e-06
},
"reason": "a homogeneous deformation the hybrid hex20 represents exactly: the tolerance admits the Newton tolerance and rounding"
},
{
"id": "uniaxial_lateral",
"quantity": "lateral displacement at (1, 1, 1), uniaxial: l − 1",
"measure": {
"command": "solve.probe",
"params": {
"step": "Uniaxial",
"at": [
[
1,
1,
1
]
]
},
"pointer": "/nodes/0/displacement/1"
},
"value": -0.28647206688129256,
"tolerance": {
"relative": 1e-06
},
"reason": "a homogeneous deformation the hybrid hex20 represents exactly: the tolerance admits the Newton tolerance and rounding"
},
{
"id": "equibiaxial",
"quantity": "in-plane Cauchy stress, equibiaxial stretch λ = 1.5",
"measure": {
"command": "solve.probe",
"params": {
"step": "Equibiaxial",
"at": [
[
1,
1,
1
]
]
},
"pointer": "/nodes/0/stress/0"
},
"value": 2.842734818108988,
"tolerance": {
"relative": 1e-06
},
"reason": "a homogeneous deformation the hybrid hex20 represents exactly: the tolerance admits the Newton tolerance and rounding"
},
{
"id": "shear_xx",
"quantity": "Cauchy stress along the stretch, pure shear λ = 2",
"measure": {
"command": "solve.probe",
"params": {
"step": "Shear",
"at": [
[
1,
1,
1
]
]
},
"pointer": "/nodes/0/stress/0"
},
"value": 3.526124960232999,
"tolerance": {
"relative": 1e-06
},
"reason": "a homogeneous deformation the hybrid hex20 represents exactly: the tolerance admits the Newton tolerance and rounding"
},
{
"id": "shear_yy",
"quantity": "Cauchy stress across, in the held direction, pure shear λ = 2",
"measure": {
"command": "solve.probe",
"params": {
"step": "Shear",
"at": [
[
1,
1,
1
]
]
},
"pointer": "/nodes/0/stress/1"
},
"value": 1.506681177907714,
"tolerance": {
"relative": 1e-06
},
"reason": "a homogeneous deformation the hybrid hex20 represents exactly: the tolerance admits the Newton tolerance and rounding"
}
]
}
@@ -0,0 +1,25 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"equibiaxial": {
"band": 2.8427348183579755e-9,
"value": 2.8427348183579753
},
"shear_xx": {
"band": 3.5261249603786212e-9,
"value": 3.526124960378621
},
"shear_yy": {
"band": 1.5066811779077142e-9,
"value": 1.5066811778673217
},
"uniaxial": {
"band": 2.736633402109491e-9,
"value": 2.736633402109491
},
"uniaxial_lateral": {
"band": 2.8647206688145424e-10,
"value": -0.2864720668814542
}
}
}
@@ -0,0 +1,25 @@
[
{"command": "mesh.block", "params": {"body": "Cube", "kind": "hex20", "corners": [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], "divisions": [1, 1, 1], "faces": {"6": "x0", "3": "y0", "1": "z0", "4": "x1", "5": "y1"}}},
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "Cube"}]}},
{"command": "model.set.create", "params": {"name": "x0", "geometry": [{"body": "Cube", "face": "x0"}]}},
{"command": "model.set.create", "params": {"name": "y0", "geometry": [{"body": "Cube", "face": "y0"}]}},
{"command": "model.set.create", "params": {"name": "z0", "geometry": [{"body": "Cube", "face": "z0"}]}},
{"command": "model.set.create", "params": {"name": "x1", "geometry": [{"body": "Cube", "face": "x1"}]}},
{"command": "model.set.create", "params": {"name": "y1", "geometry": [{"body": "Cube", "face": "y1"}]}},
{"command": "model.material.create", "params": {"name": "Rubber", "elastic": {"type": "isotropic", "young": 3.0, "poisson": 0.49}, "hyperelastic": {"type": "mooney_rivlin", "c01": 0.2}}},
{"command": "model.section.create", "params": {"name": "cube", "set": "all", "material": "Rubber", "kind": {"type": "solid"}, "formulation": {"hybrid": true}}},
{"command": "model.boundary_condition.create", "params": {"name": "sx", "type": "symmetry", "target": {"set": "x0"}, "plane": "x"}},
{"command": "model.boundary_condition.create", "params": {"name": "sy", "type": "symmetry", "target": {"set": "y0"}, "plane": "y"}},
{"command": "model.boundary_condition.create", "params": {"name": "sz", "type": "symmetry", "target": {"set": "z0"}, "plane": "z"}},
{"command": "model.boundary_condition.create", "params": {"name": "pull_u", "type": "displacement", "target": {"set": "x1"}, "values": [1.0, null, null, null, null, null]}},
{"command": "model.boundary_condition.create", "params": {"name": "pull_e", "type": "displacement", "target": {"set": "x1"}, "values": [0.5, null, null, null, null, null]}},
{"command": "model.boundary_condition.create", "params": {"name": "pull_ey", "type": "displacement", "target": {"set": "y1"}, "values": [null, 0.5, null, null, null, null]}},
{"command": "model.boundary_condition.create", "params": {"name": "pull_s", "type": "displacement", "target": {"set": "x1"}, "values": [1.0, null, null, null, null, null]}},
{"command": "model.boundary_condition.create", "params": {"name": "hold_y", "type": "displacement", "target": {"set": "y1"}, "values": [null, 0.0, null, null, null, null]}},
{"command": "model.step.create", "params": {"name": "Uniaxial", "procedure": {"type": "nonlinear_static"}, "boundary_conditions": ["sx", "sy", "sz", "pull_u"], "loads": []}},
{"command": "model.step.create", "params": {"name": "Equibiaxial", "procedure": {"type": "nonlinear_static"}, "boundary_conditions": ["sx", "sy", "sz", "pull_e", "pull_ey"], "loads": []}},
{"command": "model.step.create", "params": {"name": "Shear", "procedure": {"type": "nonlinear_static"}, "boundary_conditions": ["sx", "sy", "sz", "pull_s", "hold_y"], "loads": []}},
{"command": "solve.nonlinear", "params": {"step": "Uniaxial"}, "id": "uniaxial"},
{"command": "solve.nonlinear", "params": {"step": "Equibiaxial"}, "id": "equibiaxial"},
{"command": "solve.nonlinear", "params": {"step": "Shear"}, "id": "shear"}
]
@@ -0,0 +1,10 @@
# Inflation of a stretched Mooney–Rivlin tube
The quarter tube of `nl-cylinder-inflation` (A = 10, B = 20, one unit long, 6 × 16 hybrid
hex20), now Mooney–Rivlin (E = 3, ν = 0.4995, so μ = 1.000333; C₀₁ = 0.2, C₁₀ = μ/2 − C₀₁) and
stretched axially by its end face (u_z = 0.5 at z = 1: λ_z = 1.5) while a follower pressure
p = 0.540850 inflates the bore. In plane strain (λ_z = 1) Mooney–Rivlin is neo-Hookean with
μ = 2(C₁₀ + C₀₁), since Ī₁ = Ī₂ there; the axial stretch makes the second invariant's share
count: σ_θ − σ_r = 2(λ_θ² − λ_r²)(C₁₀ + C₀₁ λ_z²). Integrated through the incompressible wall,
r² = a² + (R² − A²)/λ_z, that pressure puts the bore at a = 15 and the outer wall at
b = 20.615528.
@@ -0,0 +1,53 @@
{
"title": "Inflation of a stretched Mooney–Rivlin tube (follower pressure)",
"kind": "theory",
"source": "Finite inflation and extension of an incompressible Mooney–Rivlin tube (R. S. Rivlin, Large elastic deformations of isotropic materials VI, Phil. Trans. R. Soc. A 242:173–195, 1949; R. W. Ogden, Non-Linear Elastic Deformations, Dover 1997, §5.3): at axial stretch λ_z, r² = a² + (R² − A²)/λ_z, σ_θ − σ_r = 2 (λ_θ² − λ_r²)(C₁₀ + C₀₁ λ_z²) with λ_θ = r/R, λ_r = 1/(λ_θ λ_z), integrated through the wall: p = ∫ (σ_θ − σ_r) dr/r (Simpson, 2·10⁵ intervals)",
"quantities": [
{
"id": "ua",
"quantity": "radial displacement of the bore (10, 0, 0) at the pressure that inflates it to a = 15 at λ_z = 1.5",
"measure": {
"command": "solve.probe",
"params": {
"step": "Inflate",
"at": [
[
10.0,
0,
0
]
]
},
"pointer": "/nodes/0/displacement/0"
},
"value": 5.0,
"tolerance": {
"relative": 0.01
},
"reason": "16 × 6 hybrid hex20 around the quarter; ν = 0.4995 stands in for incompressibility (a volume change of order p/κ ≈ 10⁻³ shifts the radii by about 0.1 %), and the tube is stretched axially by its ends, which a nearly incompressible solid takes with larger pressures than the plane-strain neo-Hookean tube"
},
{
"id": "ub",
"quantity": "radial displacement of the outer wall (20, 0, 0): b − B, a small difference of radii",
"measure": {
"command": "solve.probe",
"params": {
"step": "Inflate",
"at": [
[
20.0,
0,
0
]
]
},
"pointer": "/nodes/0/displacement/0"
},
"value": 0.6155281280883038,
"tolerance": {
"absolute": 0.04
},
"reason": "0.2 % of the outer radius b = 20.616: the same volume change of order 10⁻³ moves b by about 0.03, which is 4.5 % of b − B; the incompressible kinematics r² = a² + (R² − A²)/λ_z amplify it there"
}
]
}
@@ -0,0 +1,13 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"ua": {
"band": 5.031118164303424e-9,
"value": 5.0311181643034235
},
"ub": {
"band": 0.00004000064339064634,
"value": 0.64339064633861
}
}
}
@@ -0,0 +1,18 @@
[
{"command": "mesh.block", "params": {"body": "Pipe", "kind": "hex20", "corners": [[10.0, 0, 0], [20.0, 0, 0], [0, 20.0, 0], [0, 10.0, 0], [10.0, 0, 1], [20.0, 0, 1], [0, 20.0, 1], [0, 10.0, 1]], "divisions": [6, 16, 1], "edges": [{"corners": [2, 3], "arc": {"center": [0, 0, 0]}}, {"corners": [1, 4], "arc": {"center": [0, 0, 0]}}, {"corners": [6, 7], "arc": {"center": [0, 0, 1]}}, {"corners": [5, 8], "arc": {"center": [0, 0, 1]}}], "faces": {"6": "bore", "3": "y0", "5": "x0", "1": "z0", "2": "z1"}}},
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "Pipe"}]}},
{"command": "model.set.create", "params": {"name": "bore", "geometry": [{"body": "Pipe", "face": "bore"}]}},
{"command": "model.set.create", "params": {"name": "x0", "geometry": [{"body": "Pipe", "face": "x0"}]}},
{"command": "model.set.create", "params": {"name": "y0", "geometry": [{"body": "Pipe", "face": "y0"}]}},
{"command": "model.set.create", "params": {"name": "z0", "geometry": [{"body": "Pipe", "face": "z0"}]}},
{"command": "model.set.create", "params": {"name": "z1", "geometry": [{"body": "Pipe", "face": "z1"}]}},
{"command": "model.material.create", "params": {"name": "Rubber", "elastic": {"type": "isotropic", "young": 3.0, "poisson": 0.4995}, "hyperelastic": {"type": "mooney_rivlin", "c01": 0.2}}},
{"command": "model.section.create", "params": {"name": "pipe", "set": "all", "material": "Rubber", "kind": {"type": "solid"}, "formulation": {"hybrid": true}}},
{"command": "model.boundary_condition.create", "params": {"name": "sx", "type": "symmetry", "target": {"set": "x0"}, "plane": "x"}},
{"command": "model.boundary_condition.create", "params": {"name": "sy", "type": "symmetry", "target": {"set": "y0"}, "plane": "y"}},
{"command": "model.boundary_condition.create", "params": {"name": "sz0", "type": "symmetry", "target": {"set": "z0"}, "plane": "z"}},
{"command": "model.boundary_condition.create", "params": {"name": "stretch", "type": "displacement", "target": {"set": "z1"}, "values": [null, null, 0.5, null, null, null]}},
{"command": "model.load.create", "params": {"name": "p", "type": "pressure", "set": "bore", "magnitude": 0.5408503415684837}},
{"command": "model.step.create", "params": {"name": "Inflate", "procedure": {"type": "nonlinear_static"}, "boundary_conditions": ["sx", "sy", "sz0", "stretch"], "loads": ["p"]}},
{"command": "solve.nonlinear", "params": {"step": "Inflate"}, "id": "solve"}
]
@@ -0,0 +1,8 @@
# Free and constrained thermal expansion at finite strain
A unit cube of 2 × 2 × 2 hex20, compressible neo-Hookean (E = 10, ν = 0.3) with an expansion
coefficient α = 10⁻³ from 20 degrees, heated to 220 by a temperature load in two nonlinear static
steps: **Free** (three symmetry planes) and **Held** (symmetry planes on all six faces). The
thermal stretch is ϑ = 1 + α ΔT = 1.2. Free, the cube grows to 1.2 along every edge without
stress; held, F = I, the material's mechanical part is F_m = ϑ⁻¹ I and its stress the neo-Hookean
pressure κ (J_m − 1) with J_m = ϑ⁻³: −3.510802.
@@ -0,0 +1,76 @@
{
"title": "Free and fully constrained thermal expansion at finite strain",
"kind": "theory",
"source": "The multiplicative split F = F_m F_θ with F_θ = (1 + α ΔT) I (V. A. Lubarda, Appl. Mech. Rev. 57(2):95–108, 2004): a free body follows F = F_θ stress-free; held on every face, F = I and F_m = ϑ⁻¹ I, so the compressible neo-Hookean material carries the pressure of its volume ratio, σ = κ (ϑ⁻³ − 1)",
"quantities": [
{
"id": "free_u",
"quantity": "displacement of the corner (1, 1, 1) along x, free, α ΔT = 0.2",
"measure": {
"command": "solve.probe",
"params": {
"step": "Free",
"at": [
[
1,
1,
1
]
]
},
"pointer": "/nodes/0/displacement/0"
},
"value": 0.2,
"tolerance": {
"relative": 1e-08
},
"reason": "a homogeneous stretch every element represents exactly"
},
{
"id": "free_stress",
"quantity": "stress of the free body (σ_xx at the corner)",
"measure": {
"command": "solve.probe",
"params": {
"step": "Free",
"at": [
[
1,
1,
1
]
]
},
"pointer": "/nodes/0/stress/0"
},
"value": 0.0,
"tolerance": {
"absolute": 1e-08
},
"reason": "rounding only"
},
{
"id": "held_stress",
"quantity": "stress of the held block, σ_xx (hydrostatic)",
"measure": {
"command": "solve.probe",
"params": {
"step": "Held",
"at": [
[
0.5,
0.5,
0.5
]
]
},
"pointer": "/nodes/0/stress/0"
},
"value": -3.5108024691358017,
"tolerance": {
"relative": 1e-08
},
"reason": "a homogeneous state; E = 10, ν = 0.3 (κ = 8.3333), ϑ = 1.2"
}
]
}
@@ -0,0 +1,17 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"free_stress": {
"band": 1e-8,
"value": -1.0882345665298319e-14
},
"free_u": {
"band": 2.0000000000000037e-10,
"value": 0.20000000000000034
},
"held_stress": {
"band": 3.510802469135803e-9,
"value": -3.5108024691358026
}
}
}
@@ -0,0 +1,23 @@
[
{"command": "mesh.block", "params": {"body": "Cube", "kind": "hex20", "corners": [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], "divisions": [2, 2, 2], "faces": {"6": "x0", "3": "y0", "1": "z0", "4": "x1", "5": "y1", "2": "z1"}}},
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "Cube"}]}},
{"command": "model.set.create", "params": {"name": "x0", "geometry": [{"body": "Cube", "face": "x0"}]}},
{"command": "model.set.create", "params": {"name": "y0", "geometry": [{"body": "Cube", "face": "y0"}]}},
{"command": "model.set.create", "params": {"name": "z0", "geometry": [{"body": "Cube", "face": "z0"}]}},
{"command": "model.set.create", "params": {"name": "x1", "geometry": [{"body": "Cube", "face": "x1"}]}},
{"command": "model.set.create", "params": {"name": "y1", "geometry": [{"body": "Cube", "face": "y1"}]}},
{"command": "model.set.create", "params": {"name": "z1", "geometry": [{"body": "Cube", "face": "z1"}]}},
{"command": "model.material.create", "params": {"name": "Rubber", "elastic": {"type": "isotropic", "young": 10.0, "poisson": 0.3}, "hyperelastic": {"type": "neo_hookean"}, "thermal": {"expansion": 0.001, "reference_temperature": 20.0}}},
{"command": "model.section.create", "params": {"name": "cube", "set": "all", "material": "Rubber", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "sx0", "type": "symmetry", "target": {"set": "x0"}, "plane": "x"}},
{"command": "model.boundary_condition.create", "params": {"name": "sy0", "type": "symmetry", "target": {"set": "y0"}, "plane": "y"}},
{"command": "model.boundary_condition.create", "params": {"name": "sz0", "type": "symmetry", "target": {"set": "z0"}, "plane": "z"}},
{"command": "model.boundary_condition.create", "params": {"name": "sx1", "type": "symmetry", "target": {"set": "x1"}, "plane": "x"}},
{"command": "model.boundary_condition.create", "params": {"name": "sy1", "type": "symmetry", "target": {"set": "y1"}, "plane": "y"}},
{"command": "model.boundary_condition.create", "params": {"name": "sz1", "type": "symmetry", "target": {"set": "z1"}, "plane": "z"}},
{"command": "model.load.create", "params": {"name": "heat", "type": "temperature", "target": {"set": "all"}, "value": 220.0}},
{"command": "model.step.create", "params": {"name": "Free", "procedure": {"type": "nonlinear_static"}, "boundary_conditions": ["sx0", "sy0", "sz0"], "loads": ["heat"]}},
{"command": "model.step.create", "params": {"name": "Held", "procedure": {"type": "nonlinear_static"}, "boundary_conditions": ["sx0", "sy0", "sz0", "sx1", "sy1", "sz1"], "loads": ["heat"]}},
{"command": "solve.nonlinear", "params": {"step": "Free"}, "id": "free"},
{"command": "solve.nonlinear", "params": {"step": "Held"}, "id": "held"}
]
+6
View File
@@ -0,0 +1,6 @@
# Yeoh block in uniaxial tension
One hybrid hex20 unit cube on three symmetry planes, E = 3, ν = 0.49, Yeoh with C₁₀ = μ/2,
C₂₀ = −0.02 and C₃₀ = 0.004, pulled to λ = 2 in a nonlinear static step. The reference is the
compressible decoupled energy's axial Cauchy stress σ = (λ/J) ∂W/∂λ with the lateral stretch
found by bisection so σ_yy = 0.
@@ -0,0 +1,53 @@
{
"title": "Yeoh block in uniaxial tension",
"kind": "theory",
"source": "Uniaxial tension of the decoupled compressible Yeoh energy W = C₁₀ (Ī₁ − 3) + C₂₀ (Ī₁ − 3)² + C₃₀ (Ī₁ − 3)³ + κ/2 (J − 1)² (O. H. Yeoh, Rubber Chem. Technol. 66(5):754–771, 1993): σ = (λ/J) ∂W/∂λ with the lateral stretch found so σ_yy = 0 (bisection; E = 3, ν = 0.49, C₁₀ = μ/2, C₂₀ = −0.02, C₃₀ = 0.004)",
"quantities": [
{
"id": "sigma",
"quantity": "axial Cauchy stress, stretch λ = 2",
"measure": {
"command": "solve.probe",
"params": {
"step": "Uniaxial",
"at": [
[
1,
1,
1
]
]
},
"pointer": "/nodes/0/stress/0"
},
"value": 3.174263919393508,
"tolerance": {
"relative": 1e-06
},
"reason": "a homogeneous deformation the hybrid hex20 represents exactly: the tolerance admits the Newton tolerance and rounding"
},
{
"id": "lateral",
"quantity": "lateral displacement at (1, 1, 1): l − 1",
"measure": {
"command": "solve.probe",
"params": {
"step": "Uniaxial",
"at": [
[
1,
1,
1
]
]
},
"pointer": "/nodes/0/displacement/1"
},
"value": -0.2854505757255106,
"tolerance": {
"relative": 1e-06
},
"reason": "a homogeneous deformation the hybrid hex20 represents exactly: the tolerance admits the Newton tolerance and rounding"
}
]
}
@@ -0,0 +1,13 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"lateral": {
"band": 2.854505757255106e-10,
"value": -0.285450575725269
},
"sigma": {
"band": 3.174263919404631e-9,
"value": 3.1742639194046305
}
}
}
+16
View File
@@ -0,0 +1,16 @@
[
{"command": "mesh.block", "params": {"body": "Cube", "kind": "hex20", "corners": [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], "divisions": [1, 1, 1], "faces": {"6": "x0", "3": "y0", "1": "z0", "4": "x1", "5": "y1"}}},
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "Cube"}]}},
{"command": "model.set.create", "params": {"name": "x0", "geometry": [{"body": "Cube", "face": "x0"}]}},
{"command": "model.set.create", "params": {"name": "y0", "geometry": [{"body": "Cube", "face": "y0"}]}},
{"command": "model.set.create", "params": {"name": "z0", "geometry": [{"body": "Cube", "face": "z0"}]}},
{"command": "model.set.create", "params": {"name": "x1", "geometry": [{"body": "Cube", "face": "x1"}]}},
{"command": "model.material.create", "params": {"name": "Rubber", "elastic": {"type": "isotropic", "young": 3.0, "poisson": 0.49}, "hyperelastic": {"type": "yeoh", "c20": -0.02, "c30": 0.004}}},
{"command": "model.section.create", "params": {"name": "cube", "set": "all", "material": "Rubber", "kind": {"type": "solid"}, "formulation": {"hybrid": true}}},
{"command": "model.boundary_condition.create", "params": {"name": "sx", "type": "symmetry", "target": {"set": "x0"}, "plane": "x"}},
{"command": "model.boundary_condition.create", "params": {"name": "sy", "type": "symmetry", "target": {"set": "y0"}, "plane": "y"}},
{"command": "model.boundary_condition.create", "params": {"name": "sz", "type": "symmetry", "target": {"set": "z0"}, "plane": "z"}},
{"command": "model.boundary_condition.create", "params": {"name": "pull", "type": "displacement", "target": {"set": "x1"}, "values": [1.0, null, null, null, null, null]}},
{"command": "model.step.create", "params": {"name": "Uniaxial", "procedure": {"type": "nonlinear_static"}, "boundary_conditions": ["sx", "sy", "sz", "pull"], "loads": []}},
{"command": "solve.nonlinear", "params": {"step": "Uniaxial"}, "id": "solve"}
]
@@ -0,0 +1,21 @@
# Filleted L-bracket on the octree all-hex mesh
The corpus part `filleted-bracket` (an 8 mm thick L, 60 × 50 × 40 mm, an R6 fillet inside the
corner and a Ø8 hole through each leg), built with SolidCraft from `design.json` and meshed by
`mesh.hex.octree` (hex8 with incompatible modes, steel E = 210 GPa, ν = 0.3). The bottom face of
the horizontal leg (`F2:side:l1`) is clamped and the top face of the upright leg (`F2:side:l5`)
carries 2 kN along +x.
Checked on the 5 mm mesh: equilibrium; the certified validity of every element; the mesh volume
against the CAD volume; the bending stress of the upright leg at y = 19 on both faces,
σ_yy = M c / I = 2000 · 31 · 4 / 1706.7 = 145.3 MPa (M is exact by statics, the plane-section
stress is the reference, the corner and a hole are near). The von Mises stress at the middle of
the fillet, which has no closed form, is checked for convergence against the 4 mm mesh:
| mesh size | hexahedra | fillet von Mises (MPa) | σ_yy outer / inner at y = 19 (MPa) |
|---|---|---|---|
| 7 mm | 4291 | 138.5 | 149.4 / −146.9 |
| 5 mm | 13273 | 144.5 | 149.3 / −147.3 |
| 4 mm (reference) | 21508 | 143.2 | 147.4 / −145.5 |
A 3 mm mesh needs more than the direct solver's factor cap.
@@ -0,0 +1,89 @@
{
"commands": [
{
"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
}
}
]
}
@@ -0,0 +1,99 @@
{
"title": "Filleted L-bracket on the octree all-hex mesh: equilibrium, beam bending, fillet stress convergence",
"kind": "theory",
"source": "equilibrium; Euler–Bernoulli bending of the 8 × 40 mm leg, σ = M c / I with M = F (50 − y) (S. P. Timoshenko, J. N. Goodier, Theory of Elasticity, 3rd ed., 1970, §102); grid convergence of the fillet stress against the 4 mm mesh of this case (P. J. Roache, Verification and Validation in Computational Science and Engineering, 1998, ch. 5)",
"slow": true,
"quantities": [
{
"id": "reaction_x",
"quantity": "x reaction on the clamped face (5 mm mesh)",
"measure": {
"from": "solve5",
"pointer": "/reaction_total/0"
},
"value": -2000.0,
"tolerance": {
"relative": 1e-06
},
"reason": "the reactions balance the 2 kN load to solver precision"
},
{
"id": "invalid",
"quantity": "elements the certified Jacobian check does not prove valid (5 mm mesh)",
"measure": {
"from": "quality5",
"pointer": "/invalid/count"
},
"value": 0.0,
"tolerance": {
"exact": true
},
"reason": "the octree mesher returns only elements proven valid"
},
{
"id": "volume",
"quantity": "mesh volume against the CAD volume (5 mm mesh)",
"measure": {
"from": "mesh5",
"pointer": "/bodies/0/volume"
},
"value": 32144.778925,
"tolerance": {
"relative": 0.003
},
"reason": "bilinear boundary faces on the R6 fillet and the two Ø8 holes, and the 1e-3 chordal tessellation they are fitted to"
},
{
"id": "syy_outer",
"quantity": "σ_yy on the outer face of the upright leg at y = 19, z = 6 (5 mm mesh)",
"measure": {
"from": "probe5",
"pointer": "/nodes/1/stress/1"
},
"value": 145.3125,
"tolerance": {
"relative": 0.05
},
"reason": "beam theory ignores the corner 5 mm below and the hole 9 mm above; the probe takes the nearest node's extrapolated stress"
},
{
"id": "syy_inner",
"quantity": "σ_yy on the inner face of the upright leg at y = 19, z = 6 (5 mm mesh)",
"measure": {
"from": "probe5",
"pointer": "/nodes/2/stress/1"
},
"value": -145.3125,
"tolerance": {
"relative": 0.05
},
"reason": "as for the outer face"
},
{
"id": "fillet_7",
"quantity": "von Mises stress at the middle of the fillet, 7 mm mesh",
"measure": {
"from": "probe7",
"pointer": "/nodes/0/von_mises"
},
"value": 143.21,
"tolerance": {
"relative": 0.05
},
"reason": "the coarsest mesh, within 5% of the 4 mm mesh"
},
{
"id": "fillet_5",
"quantity": "von Mises stress at the middle of the fillet, 5 mm mesh",
"measure": {
"from": "probe5",
"pointer": "/nodes/0/von_mises"
},
"value": 143.21,
"tolerance": {
"relative": 0.02
},
"reason": "converging: within 2% of the 4 mm mesh"
}
]
}
@@ -0,0 +1,33 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"fillet_5": {
"band": 1.4452906175564113e-7,
"value": 144.52906175564112
},
"fillet_7": {
"band": 1.4321000000000002e-7,
"value": 138.5286423070889
},
"invalid": {
"band": 0.0,
"value": 0.0
},
"reaction_x": {
"band": 2.0000000000000003e-6,
"value": -1999.999999998785
},
"syy_inner": {
"band": 1.472793063397077e-7,
"value": -147.2793063397077
},
"syy_outer": {
"band": 1.4926027331710374e-7,
"value": 149.26027331710372
},
"volume": {
"band": 0.00003217691091849411,
"value": 32176.910918494108
}
}
}
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more