Compare commits

..
26 Commits
Author SHA1 Message Date
jyvikoandClaude Opus 5.5 9469ac0853 Tet mesh quality: one validity criterion, Delaunay refinement, ODT, Klingner–Shewchuk, tet10
The end-to-end demo met a raw-CDT sliver that the validity gate accepted (exactly positive) and
the solver refused. Now:

- One validity criterion, `fieldcraft_model::admissible_jacobian` (normalized Jacobian above
  1e-12), used by the implicit solver (fem `positive_inverse`), the explicit tet4/hex8
  kinematics and the mesh gate, which proves it on the whole element (tet4: the solvers' own
  Jacobian; curved kinds: a Bernstein floor), so whatever passes the gate passes the solvers.
- CDT rounding repaired by flips on the rounded mesh (Diazzi et al. 2023 §5.1) instead of the
  ±2-ulp nudge.
- `fieldcraft-mesh::tet`: a mutable tetrahedral complex whose every change is a checked
  replacement (positive and admissible tetrahedra, faces matched with opposite orientation) with
  an exact undo log; edge removal (Klincsek), multi-face removal, Delaunay insertion with
  boundary faces, edge collapse; a curvature- and thickness-graded size field; constrained
  Delaunay refinement with encroachment splits; ODT smoothing; the Klingner–Shewchuk schedule;
  boundary vertices moved onto the exact B-rep surfaces at the end; tet10 with mid-edge nodes on
  the geometry.
- `mesh.tet.generate` gains size, order, quality, quality_target, curvature_angle, grading,
  max_passes, max_points and reports the CDT's and the mesh's quality; `solve.probe` takes a set
  and reports its largest von Mises stress.
- Oracle cases filleted-bracket-fillet-stress(-coarse): the fillet's peak von Mises stress on
  tet10 meshes of 2k and 13.5k elements, 213.3 MPa (a four-level study stays within 1.8%); the
  CDT cases keep the raw tetrahedralization.
- Corpus quality columns: all 20 valid parts mesh and pass the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:28:49 -04:00
jyvikoandClaude Opus 5.5 fed27d816d docs: the literature survey names FieldCraft
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:20:45 -04:00
jyvikoandClaude Opus 5.5 e6688ef014 mcp: select and selection tools for the app's viewport selection
Bridged to a running app, select and selection call ui.select and
ui.selection; headless they are errors (selection belongs to the
window).

Closes #73

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:16:26 -04:00
jyvikoandClaude Opus 5.5 f11be61ff3 control: ui.select on the viewport and ui.selection
ui.select now also drives the viewport's selection: the filter, box
mode, items by name and label as model.set.create takes them, a pick at
a window point or the visible items in a box, changed by a mode
(replace, add, remove, toggle). ui.selection reads it back with its set
members and the hover; ui.dialog takes use_selection (the form's Target
row). Selection stays in the window: no engine command. Documented in
docs/control-protocol.md, with a test that picks the cantilever's free
end by a simulated click and a box, makes sets of it and resolves them,
and hostile-input tests (bad points and boxes, labels, huge lists).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:16:26 -04:00
jyvikoandClaude Opus 5.5 9e269a9f45 ui-egui: select faces, edges, vertices, bodies, nodes and elements by picking
A click in the viewport picks what is under the pointer through the
selection filter (the selection bar, top centre): geometry faces, edges,
vertices and bodies, or mesh nodes, element faces and elements. Shift
adds, Cmd/Ctrl adds or removes, a click on nothing clears, Escape
clears; with Box on, a left drag selects the visible nodes, element
faces or elements inside the box. What a click would pick is
highlighted under the pointer with its name beside it.

The selection is kept in the model's persistent terms: geometry by name
(GeometryRef, named through the mesh's geometry tags or the drawn
bodies' face names), so it survives a remesh, and the mesh by label
(labels the model loses drop out). It reads as a set's members.

Picking runs on the CPU against the triangles both renderers draw. The
selection and the hover are drawn as an overlay in the theme's new
pick_selected and pick_hover colours (light and dark), outlined; the GPU
draws it as a second small layer, so a hover change uploads only that.

Command forms that apply to part of the model (sets, boundary
conditions, loads, sections, couplings) get a Target row whose Use
selection fills the members, or makes a set of the selection and names
it. A right click offers Create set from selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:16:26 -04:00
jyvikoandClaude Opus 5.5 32c67d1988 render: watertight ray picking, element face ids and an overlay layer
The pick module tests rays against triangles with the watertight
ray-triangle intersection of Woop, Benthin and Wald (JCGT 2013): a ray
through an edge or vertex two triangles share hits one of them, the
nearest hit wins and ties go to the lower index, so picks are
deterministic. A Projector holds the camera's projection for
screen-space picks (Camera::to_screen and the CPU renderer use it too),
and a DepthMap of the drawn faces, rasterised by the renderer's own
triangle routine, tells which points are in front (box selection).

Display triangles now carry their element and face number (numbered as
ElementKind::faces numbers them; seen_face gives a shell's bottom side
from behind), line elements their element, and geometry surface
triangles their face tag, so a hit names what a set refers to.
DisplayMesh::pick (results probing) uses the new test.

render_layers draws a second scene over the first: its faces over the
scene faces they repeat (a small depth allowance), then its lines and
points. The viewport draws selection and hover highlights with it
without rebuilding the model's scene.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:16:26 -04:00
jyvikoandClaude Opus 5.5 bf42d182ff ci: release workflow drafting a GitHub Release, and docs/releasing.md
A push to the release branch (or workflow_dispatch) builds the macOS universal dmg and CLI zip,
the Linux tar.gz and AppImage and the Windows zip, with the version from Cargo.toml. The last
job checks the set, writes SHA256SUMS.txt and notes saying how the macOS app was signed, and
creates or updates a draft release; it never publishes. Off the release branch it only uploads
the files as a run artifact (dry run). macOS signing and notarization use the APPLE_* secrets
when they exist and fall back to ad-hoc signing (#72).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:56:06 -04:00
jyvikoandClaude Opus 5.5 0d7c881eea packaging: macOS app and dmg, Linux tar.gz and AppImage, Windows zip
packaging/macos/package.sh builds FieldCraft.app (io.github.jyviko.fieldcraft, with icon.icns
and the layered Assets.car), signs it with a Developer ID and notarizes it when the
credentials are there and ad-hoc otherwise, and puts it on a drag-to-Applications dmg that
is mounted and checked; fieldcraft-cli goes in its own zip. packaging/linux/package.sh makes
an FHS tar.gz (binaries, .desktop entry, hicolor icons, licences) and an AppImage made with
appimagetool and the type 2 runtime at pinned, hash-checked releases.
packaging/windows/package.ps1 zips fieldcraft.exe and fieldcraft-cli.exe (static CRT,
checked PE headers and icon). Every package carries README.md, LICENSE-MIT, LICENSE-APACHE,
NOTICE and THIRD-PARTY-LICENSES.txt (#72).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:56:06 -04:00
jyvikoandClaude Opus 5.5 858b6e9dee app: reverse-DNS app id and the Windows executable icon
The window's Wayland app_id / X11 class is io.github.jyviko.fieldcraft, the id the release
packages use for the .desktop entry and the macOS bundle (#72). Windows builds embed
assets/app-icon/icon.ico in fieldcraft.exe through embed-resource (MIT, a build dependency only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:56:06 -04:00
jyvikoandClaude Opus 5.5 8172af67e7 cli: fieldcraft-cli --version
The release packaging runs it to check each build (#72).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:56:06 -04:00
jyvikoandClaude Opus 5.5 8caa0d15cb issue forms: observed failure, reproduction, evidence, expected behaviour, investigation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:52:59 -04:00
jyvikoandClaude Opus 5.5 ff6daa7cd6 README: the partitioned all-hex bracket with images, the nonlinear solver and materials, setup symbols, how to help
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:51:14 -04:00
jyvikoandClaude Opus 5.5 f1253b4010 AGENTS.md: main is protected; the core team merges with --admin, contributors wait for review
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:47:56 -04:00
jyvikoandClaude Opus 5.5 152092cfb4 community: CODEOWNERS, issue and PR templates, code of conduct, claiming issues; CI on pull requests from forks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:45:14 -04:00
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
183 changed files with 17480 additions and 783 deletions

No files matched your search

+6
View File
@@ -1,2 +1,8 @@
[alias]
xtask = "run --package xtask --"
# Windows release binaries link the C runtime statically, so they run without the VC++
# redistributable (packaging/windows/package.ps1 builds with --target, which keeps the flag
# off build scripts and proc-macros).
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
+2
View File
@@ -0,0 +1,2 @@
# Every change to FieldCraft needs the maintainer's review before it merges.
* @jyviko
+46
View File
@@ -0,0 +1,46 @@
name: Bug report
description: Something FieldCraft does wrong, a wrong result, or a crash
labels: [bug]
body:
- type: textarea
id: observed
attributes:
label: Observed failure
description: Exactly what happened. Quote error messages and wrong numbers as they appear.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction
description: Numbered steps, or a script (`fieldcraft-cli run script.json`, control-channel JSON lines), with the sample, corpus part or attached `.fieldcraft` / `.solidcraft` file that shows it.
placeholder: |
1. fieldcraft --sample cantilever
2. Mesh ▸ Tet ▸ OK
3. ...
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Evidence
description: Screenshots, log output, numbers. For a crash, attach the crash report (`fieldcraft-cli crashes`, then `crashes show <id>`).
- type: textarea
id: expected
attributes:
label: Expected behaviour
description: What should happen instead, and why — a closed form, a published benchmark, the documentation, or how the rest of the app behaves. Never output from a commercial CAE product.
validations:
required: true
- type: textarea
id: investigation
attributes:
label: Investigation
description: Anything you found about the cause — code you suspect, what you ruled out. Leave empty if you have not looked.
- type: input
id: version
attributes:
label: Version and platform
placeholder: "v0.1.0 (or commit), macOS 15 arm64"
validations:
required: true
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+28
View File
@@ -0,0 +1,28 @@
name: Feature request
description: A capability FieldCraft should have
labels: [enhancement]
body:
- type: textarea
id: current
attributes:
label: Current behaviour
description: What you can and cannot do today, and what happens when you try.
validations:
required: true
- type: textarea
id: need
attributes:
label: What you need and why
description: The analysis or workflow you are trying to do.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behaviour
description: What FieldCraft should do, as precisely as you can.
- type: textarea
id: reference
attributes:
label: References
description: Papers, textbooks, public specifications or published benchmarks that define the behaviour. Do not paste text, files or results from commercial CAE products.
+11
View File
@@ -0,0 +1,11 @@
Closes #
## What and why
## How it was verified
<!-- Tests added or changed; oracle cases (tests/oracle) for solver and meshing results, with their cited references. -->
## Checklist
- [ ] The issue was assigned to me before I started (see CONTRIBUTING.md)
- [ ] `cargo fmt --all`, `cargo clippy -p <crates I touched> --all-targets -- -D warnings` and their tests pass
- [ ] No code, text or data from GPL-family projects or commercial CAE products
+27
View File
@@ -0,0 +1,27 @@
FieldCraft is open-source CAE pre-processing in Rust: it turns geometry into analysis-ready
finite element models, writes Abaqus, Nastran and LS-DYNA decks, and solves linear static and
modal problems with its own solver. AI agents can drive it over MCP (`fieldcraft-cli mcp`).
[README](https://github.com/jyviko/fieldcraft#readme) ·
[roadmap](https://github.com/jyviko/fieldcraft/blob/main/ROADMAP.md) ·
[issues](https://github.com/jyviko/fieldcraft/issues)
**Which file?**
- **macOS 11+** (Apple silicon and Intel): open `fieldcraft-{version}-macos-universal.dmg` and
drag `FieldCraft.app` onto Applications. The command-line tool is in
`fieldcraft-cli-{version}-macos-universal.zip`.
- **Linux** (x86_64, glibc 2.35+: Ubuntu 22.04, Debian 12, Fedora 36 or newer):
`fieldcraft-{version}-linux-x86_64.AppImage` (`chmod +x`, then run it), or unpack
`fieldcraft-{version}-linux-x86_64.tar.gz` and run `bin/fieldcraft` (`bin/fieldcraft-cli` is the
command-line tool).
- **Windows 10+** (x64): unzip `fieldcraft-{version}-windows-x64.zip` and run `fieldcraft.exe`
(`fieldcraft-cli.exe` is the command-line tool).
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing` (macOS:
`shasum -a 256 -c SHA256SUMS.txt --ignore-missing`).
To start with a sample model, run the app with `--sample cantilever` (macOS:
`/Applications/FieldCraft.app/Contents/MacOS/FieldCraft --sample cantilever`).
**Signing.** {macos_signing} The Linux and Windows builds are not signed; on Windows, SmartScreen
may say "Windows protected your PC": choose **More info**, then **Run anyway**.
Every package carries `LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE` and `THIRD-PARTY-LICENSES.txt`.
+16 -3
View File
@@ -1,8 +1,17 @@
name: CI
# CI runs once per merge, on main (AGENTS.md, "Workflow for every change"): pull requests are
# checked locally on the crates they touch and merged; a red main is fixed forward.
# CI runs once per merge, on main (AGENTS.md, "Workflow for every change"): the core team's pull
# requests are checked locally on the crates they touch and merged; a red main is fixed forward.
# Pull requests from forks (outside contributors) run the full gate, once the maintainer approves
# the run, so the review sees it.
on:
pull_request:
paths:
- '**'
- '!**/*.md'
- '!docs/**'
- 'docs/features.md'
- 'docs/oracle.md'
push:
branches: [main]
# A merge that only touches prose starts no run. The generated pages are checked by the jobs
@@ -26,7 +35,7 @@ permissions:
# once, at the tip. Nightly and manual runs are grouped apart and never cancelled by a merge.
concurrency:
group: ci-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: ${{ github.event_name == 'push' }}
cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
@@ -34,6 +43,7 @@ env:
jobs:
ci:
name: cargo xtask ci
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
@@ -50,6 +60,7 @@ jobs:
heavy:
# The heavy verification and corpus tests (#[ignore]d) and the doc tests.
name: cargo xtask test --heavy
if: github.event_name != 'pull_request'
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
@@ -65,6 +76,7 @@ jobs:
# its fixture, and docs/oracle.md must be the table the cases produce. Cases marked `slow`
# run here too while the whole set stays within a few minutes; `--quick` skips them.
name: cargo xtask oracle
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
@@ -80,6 +92,7 @@ jobs:
advisories:
name: Dependency advisories and sources
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
+252
View File
@@ -0,0 +1,252 @@
# Release builds (docs/releasing.md), after SolidCraft's and VectorCraft's release.yml
# (MIT OR Apache-2.0; see ATTRIBUTION.md).
#
# A push to `release` (fast-forwarded from main) builds every package and creates or updates the
# DRAFT GitHub Release "FieldCraft v<version>", with SHA256SUMS.txt and generated notes. The
# version is [workspace.package] version in Cargo.toml; a suffix (0.2.0-rc.1) makes a
# pre-release. A person verifies the draft and publishes it; this workflow never publishes,
# and pushes to main or tags never start it.
#
# macOS universal (arm64 + x86_64) .dmg with FieldCraft.app, the CLI as a .zip packaging/macos
# Linux x86_64 (glibc 2.35+) .tar.gz and .AppImage packaging/linux
# Windows x64 .zip with fieldcraft.exe and fieldcraft-cli.exe packaging/windows
#
# macOS: signed with a Developer ID and notarized when the APPLE_* secrets exist, ad-hoc
# otherwise (the release notes say which). Linux and Windows builds are not signed.
#
# Dry run: `gh workflow run release.yml --ref <branch>` builds everything and uploads the
# `release-assets` artifact without creating a release. `-f draft=true` on `release` retries
# the draft.
name: Release
on:
push:
branches: [release]
workflow_dispatch:
inputs:
draft:
description: "Create/update the draft release (release branch only; off: build artifacts only)"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
version:
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.v.outputs.version }}
prerelease: ${{ steps.v.outputs.prerelease }}
draft: ${{ steps.v.outputs.draft }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve the version
env:
REQUEST_DRAFT: ${{ github.event_name == 'push' || inputs.draft }}
run: |
. packaging/env.sh
if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1, not '$VERSION'"
exit 1
fi
if [ "$REQUEST_DRAFT" = true ] && [ "$GITHUB_REF" != refs/heads/release ]; then
echo "::error::Draft releases are built from the release branch only"
exit 1
fi
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
{ echo "version=$VERSION"; echo "prerelease=$pre"; echo "draft=$REQUEST_DRAFT"; } >>"$GITHUB_OUTPUT"
echo "### FieldCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-14
timeout-minutes: 120
outputs:
signing: ${{ steps.package.outputs.signing }}
env:
FIELDCRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/release' }}
- name: Import the signing certificate (skipped without APPLE_CERTIFICATE)
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize and package (the dmg is mounted and checked)
id: package
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove the temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
linux:
name: Linux x86_64
needs: version
# The oldest GitHub-hosted image, so the binaries need only glibc >= 2.35.
runs-on: ubuntu-22.04
timeout-minutes: 90
env:
FIELDCRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends desktop-file-utils
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-x86_64
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/release' }}
- name: Build and package (tar.gz, AppImage)
run: packaging/linux/package.sh
- name: Check the packages
run: |
. packaging/env.sh
list="$(tar -tzf dist/release/*.tar.gz)"
for f in bin/fieldcraft bin/fieldcraft-cli "share/applications/$APP_ID.desktop" \
share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt; do
grep -Fx -- "fieldcraft-$VERSION-linux-x86_64/$f" <<<"$list"
done
ldd target/release/fieldcraft
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
done
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-x86_64
path: dist/release/*
if-no-files-found: error
windows:
name: Windows x64
needs: version
runs-on: windows-latest
timeout-minutes: 90
env:
FIELDCRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-x64
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/release' }}
- name: Build and package (zip)
shell: pwsh
run: ./packaging/windows/package.ps1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-x64
path: dist/release/*
if-no-files-found: error
release:
name: Draft release (or dry-run artifacts)
needs: [version, macos, linux, windows]
runs-on: ubuntu-24.04
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
MACOS_SIGNING: ${{ needs.macos.outputs.signing }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Check the set, checksums and notes
run: |
for f in "fieldcraft-$VERSION-macos-universal.dmg" "fieldcraft-cli-$VERSION-macos-universal.zip" \
"fieldcraft-$VERSION-linux-x86_64.tar.gz" "fieldcraft-$VERSION-linux-x86_64.AppImage" \
"fieldcraft-$VERSION-windows-x64.zip"; do
test -s "artifacts/$f" || { echo "::error::missing $f"; exit 1; }
done
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
# The macOS paragraph says how the app was signed (packaging/macos/package.sh).
case "$MACOS_SIGNING" in
notarized) macos="The macOS app is signed with a Developer ID and notarized by Apple." ;;
signed) macos="The macOS app is signed with a Developer ID but **not notarized**: the first time, right-click \`FieldCraft.app\` in Applications and choose **Open**." ;;
*) macos="The macOS app is **ad-hoc signed and not notarized**: macOS blocks the first launch. Right-click \`FieldCraft.app\` in Applications and choose **Open** (then **Open** again), or run \`xattr -dr com.apple.quarantine /Applications/FieldCraft.app\`." ;;
esac
awk -v v="$VERSION" -v m="$macos" '{ gsub(/\{version\}/, v); gsub(/\{macos_signing\}/, m); print }' \
.github/release-notes.md >notes.md
cat notes.md >>"$GITHUB_STEP_SUMMARY"
- name: Upload the dry-run artifacts
if: needs.version.outputs.draft != 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-assets
path: |
artifacts/*
notes.md
if-no-files-found: error
- name: Create or update the draft release
if: needs.version.outputs.draft == 'true'
working-directory: artifacts
run: |
tag="v$VERSION"
title="FieldCraft v$VERSION"
if gh api "repos/$GH_REPO/git/matching-refs/tags/$tag" --jq '.[].ref' | grep -Fxq "refs/tags/$tag"; then
echo "::error::Tag $tag already exists. Choose a new version; never move a release tag."
exit 1
fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != true ]; then
echo "::error::Release $tag is already published. Bump the version before releasing."
exit 1
fi
# A rebuild of the same version replaces the draft's assets (removing names it no longer
# makes); its notes are kept.
assets="$(gh release view "$tag" --json assets --jq '.assets[].name')"
while IFS= read -r asset; do
if [ -n "$asset" ]; then gh release delete-asset "$tag" "$asset" --yes; fi
done <<<"$assets"
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" -- *
else
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" \
--notes-file ../notes.md --generate-notes --prerelease="$PRERELEASE" -- *
fi
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`: $(gh release view "$tag" --json url --jq .url)" >>"$GITHUB_STEP_SUMMARY"
+6 -3
View File
@@ -111,9 +111,12 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
`git rebase -i --autosquash origin/main` (`GIT_SEQUENCE_EDITOR=true` keeps it
non-interactive). A regenerated page goes into the commit that changed what it lists.
4. Rebase onto the latest `origin/main` (never merge `main` into a branch), rerun the tests of
the touched crates, push, open the PR and merge it yourself with a rebase merge, which keeps
`main` linear and every commit bisectable: `gh pr merge <N> --rebase --delete-branch`, run as
its own command. Never squash. Then remove your worktree and delete your local branch.
the touched crates, push, open the PR and merge it with a rebase merge, which keeps `main`
linear and every commit bisectable. `main` is protected (CODEOWNERS review, linear history,
rebase merges only); the core team merges as the maintainer with
`gh pr merge <N> --rebase --admin --delete-branch`, run as its own command. Outside
contributors' pull requests wait for the maintainer's review (CONTRIBUTING.md). Never squash.
Then remove your worktree and delete your local branch.
5. Never run the full gate locally: no `cargo xtask ci`, no `cargo clippy --workspace`, no
`cargo xtask wasm` or `oracle` unless step 2 calls for it. Never watch CI. GitHub CI runs
`cargo xtask ci` (fmt, assets, licences, layers, clippy -D warnings, tests, wasm), the
+6
View File
@@ -15,6 +15,7 @@ is committed and listed here with its exports.
| File | Author | Source | Licence |
|---|---|---|---|
| docs/media/ibeam-analysis.mp4, docs/media/ibeam-analysis.gif | FieldCraft contributors | Original: the FieldCraft window captured during an MCP session, generated by `tools/demo-video/make.py` | MIT OR Apache-2.0 |
| docs/media/bracket-partition.png, docs/media/bracket-hex.png, docs/media/bracket-stress.png, docs/media/setup-symbols.png | FieldCraft contributors | Original: FieldCraft renders (headless MCP screenshots and a window capture) of corpus parts and the cantilever sample | MIT OR Apache-2.0 |
| crates/decks/tests/golden/nastran/*.bdf | FieldCraft contributors | Written by our Nastran writer from our own test models (crates/decks/tests/nastran.rs) | MIT OR Apache-2.0 |
| assets/icons/assembly.instance.svg, assets/icons/assembly.instance.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/assembly.instance.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/deck.read.svg, assets/icons/deck.read.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.read.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
@@ -153,6 +154,7 @@ is committed and listed here with its exports.
| assets/app-icon/icon.iconset/icon_256x256@2x.png | FieldCraft contributors | sips export (512 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 |
| assets/app-icon/icon.iconset/icon_512x512.png | FieldCraft contributors | sips export (512 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 |
| assets/app-icon/icon.iconset/icon_512x512@2x.png | FieldCraft contributors | sips export (1024 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 |
| packaging/macos/README.txt, packaging/linux/io.github.jyviko.fieldcraft.desktop, packaging/macos/entitlements.plist | FieldCraft contributors | Original packaging text: the disk image's install note, the Linux desktop entry and the (empty) hardened-runtime entitlements | MIT OR Apache-2.0 |
| crates/ui-egui/fonts/Inter-SemiBold.ttf | The Inter Project Authors | https://github.com/rsms/inter release v4.1 (Inter-4.1.zip, extras/ttf/Inter-SemiBold.ttf), unmodified; licence text in crates/ui-egui/fonts/OFL.txt; the interface's caps labels (theme::LABEL_FAMILY) | SIL OFL 1.1 |
## Adapted code
@@ -170,6 +172,10 @@ is committed and listed here with its exports.
| apps/fieldcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 |
| crates/mcp/src/ (JSON-RPC framing, MCP lifecycle, tool schema checks, the control-channel backend, base64) | SolidCraft `crates/mcp/src/` | MIT OR Apache-2.0 |
| crates/io/src/roots.rs (the rules of read and write roots) | VectorCraft's automation roots (docs/mcp.md there) | MIT OR Apache-2.0 |
| packaging/env.sh, packaging/macos/package.sh, packaging/macos/Info.plist.in, packaging/linux/package.sh, packaging/linux/AppRun, packaging/windows/package.ps1 | SolidCraft `packaging/` and VectorCraft `packaging/` (signing and notarization in macos/package.sh) | MIT OR Apache-2.0 |
| packaging/macos/import-cert.sh | VectorCraft `packaging/macos/import-cert.sh` | MIT OR Apache-2.0 |
| packaging/windows/check-exe.ps1 | SolidCraft `packaging/windows/check-icon.ps1` | MIT OR Apache-2.0 |
| .github/workflows/release.yml, .github/release-notes.md, docs/releasing.md | SolidCraft `.github/workflows/release.yml`, `.github/release-notes.md`, `docs/releasing.md`; VectorCraft `.github/workflows/release.yml` | MIT OR Apache-2.0 |
## Test oracles
+16
View File
@@ -0,0 +1,16 @@
# Code of conduct
FieldCraft is built in the open by people with different backgrounds and levels of experience.
Everyone taking part — in issues, pull requests, reviews and discussions — is expected to:
- be respectful and assume good intent; criticise work, never people;
- keep discussion technical, specific and on topic;
- not harass, insult, demean or discriminate against anyone, publicly or privately;
- respect the project's decisions on scope, licences and clean-room rules.
The maintainer may edit or remove comments, close threads, and block anyone who does not follow
this code. Report problems privately to the maintainer through the contact on the GitHub
profile of [@jyviko](https://github.com/jyviko).
This code is our own, written in the spirit of the
[Contributor Covenant](https://www.contributor-covenant.org/).
+22 -1
View File
@@ -3,8 +3,25 @@
Thanks for helping. Read [AGENTS.md](AGENTS.md) first: it holds the rules every change follows,
whether a person or an agent writes it.
## Before you start: claim an issue
Every change starts from an issue, and only the maintainer merges.
1. Pick an open issue labelled **help wanted** or **good first issue** that is not labelled
**claimed**. If what you want to do has no issue, open one first and wait for a reply.
2. Comment that you would like to take it. The maintainer assigns it to you and labels it
**claimed**; then it is yours. Work on unassigned issues may not be accepted.
3. Open a draft pull request early, linked with `Closes #N`. An issue with no visible progress
for 14 days goes back to the pool.
Issues labelled **in progress** are being worked on by the core team; please don't start on them.
## Making the change
1. Branch from `main`, keep a pull request to one topic.
2. Run `cargo xtask ci` before pushing; CI runs the same gates.
2. Before pushing: `cargo fmt --all`, and `cargo clippy -p <crate> --all-targets -- -D warnings`
and `cargo test -p <crate>` for the crates you touched. CI runs the full gate on your pull
request once the maintainer approves the run.
3. User-visible behaviour is a command (`crates/engine/src/cmd/`) with tests, including hostile
parameters.
4. Meshing and solver algorithms cite the paper they come from in the module docs.
@@ -12,3 +29,7 @@ whether a person or an agent writes it.
(see [docs/adr/0001-libraries.md](docs/adr/0001-libraries.md)).
By contributing you agree your work is licensed under MIT OR Apache-2.0.
`main` is protected: changes land only through pull requests that the maintainer has reviewed,
as rebase merges (keep each commit building and passing its tests). Be kind: see the
[code of conduct](CODE_OF_CONDUCT.md).
Generated
+76
View File
@@ -1035,6 +1035,20 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "embed-resource"
version = "3.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f"
dependencies = [
"cc",
"memchr",
"rustc_version",
"toml",
"vswhom",
"winreg",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1177,6 +1191,7 @@ version = "0.1.0"
dependencies = [
"eframe",
"egui",
"embed-resource",
"fieldcraft-engine",
"fieldcraft-io",
"fieldcraft-ui-egui",
@@ -1338,6 +1353,7 @@ name = "fieldcraft-solver"
version = "0.1.0"
dependencies = [
"fieldcraft-fem",
"fieldcraft-mesh",
"fieldcraft-model",
"fieldcraft-parallel",
"serde",
@@ -3206,6 +3222,15 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "serde_spanned"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
dependencies = [
"serde_core",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3658,6 +3683,21 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "toml"
version = "1.1.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.2+spec-1.1.0"
@@ -3688,6 +3728,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
[[package]]
name = "tracing"
version = "0.1.44"
@@ -3981,6 +4027,26 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vswhom"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
dependencies = [
"libc",
"vswhom-sys",
]
[[package]]
name = "vswhom-sys"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4750,6 +4816,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
+14
View File
@@ -88,6 +88,14 @@ opt-level = 1
[profile.dev.package.fieldcraft-lbm]
opt-level = 3
# Meshing and the sparse solver are compute loops too: the tet quality stage and the
# mesh-convergence oracle cases (tests/oracle) run in tests.
[profile.dev.package.fieldcraft-mesh]
opt-level = 3
[profile.dev.package.fieldcraft-solver]
opt-level = 3
# Tests and lints (`cargo xtask ci`, GitHub CI). Debug assertions are off because truck's
# debug-only checks reject valid geometry (SolidCraft builds its tests the same way).
[profile.ci]
@@ -104,6 +112,12 @@ opt-level = 2
[profile.ci.package.fieldcraft-lbm]
opt-level = 3
[profile.ci.package.fieldcraft-mesh]
opt-level = 3
[profile.ci.package.fieldcraft-solver]
opt-level = 3
[profile.release]
opt-level = 3
lto = "thin"
+36 -9
View File
@@ -6,8 +6,13 @@ explicit, thermal and lattice Boltzmann solvers, and decks for Abaqus, Nastran a
desktop app, the command line and AI agents (over MCP) drive the same commands.
**Status:** early but working end to end — open a part, mesh it, define the analysis, solve,
see the results. Hex meshing is the centre of gravity; the next large step is the nonlinear
implicit solver that contact, elastomers and composites need. See [ROADMAP.md](ROADMAP.md).
see the results — with a nonlinear implicit solver (large deformation, plasticity, rubber) now
in place. Hex meshing is the centre of gravity. Next: buckling and arc-length, contact, then
the wider material and multiphysics library. See [ROADMAP.md](ROADMAP.md).
**Want to help?** Pick an issue labelled
[good first issue](https://github.com/jyviko/fieldcraft/labels/good%20first%20issue) or
[help wanted](https://github.com/jyviko/fieldcraft/labels/help%20wanted) and read
[CONTRIBUTING.md](CONTRIBUTING.md).
![An AI agent analyses an I-beam cantilever in FieldCraft over MCP: open, hex mesh, steel, clamp and tip load, solve, von Mises contours, the deformed shape and an orbit round it](docs/media/ibeam-analysis.gif)
@@ -17,34 +22,55 @@ solves, and shows the von Mises stress and the deformed shape (tip deflection 0.
theory with shear 0.177 mm).
[MP4](docs/media/ibeam-analysis.mp4) · regenerated by [tools/demo-video](tools/demo-video/README.md).
## Hex meshing a part that does not sweep
A filleted L-bracket with a hole through each leg cannot be swept in one piece. Split into 30
cells in SolidCraft, FieldCraft meshes it all-hex as one conforming mesh — O-grids round the holes,
graded through the fillet — and its hex20 tip deflection matches a fine tet10 solution to 0.1 %.
The same part also meshes all-hex automatically with the octree mesher (`mesh.hex.octree`).
| Cells | Conforming hex mesh | Von Mises (clamp and load drawn) |
|---|---|---|
| ![The bracket split into 30 coloured cells](docs/media/bracket-partition.png) | ![The all-hex mesh with O-grids round the holes](docs/media/bracket-hex.png) | ![Von Mises stress peaking at the hole, with the clamp cones and load arrows](docs/media/bracket-stress.png) |
## What it does today
- **Geometry in:** SolidCraft designs (`.solidcraft`) with persistent face, edge and body names,
and face-tagged 3MF meshes; surface checks (weld, manifold, orientation, self-intersection).
- **Meshing:** constrained Delaunay tetrahedra (tet4, tet10) with exact predicates and a
certified validity gate; structured hex by sweeping, revolving and mapping (hex8, hex20; 16 of
the 21 corpus parts mesh all-hex); 2D quad and tri meshes; quality metrics per element and set.
certified validity gate; Delaunay refinement, smoothing and sliver removal; structured hex by
sweeping, revolving and mapping (hex8, hex20), and conforming multi-block hex across a part
split into cells (`mesh.hex.blocks`); unstructured octree all-hex for any part
(`mesh.hex.octree`); 2D quad and tri meshes; quality metrics per element and set.
- **Elements:** solids (tet4, tet10, wedge6, hex8 with incompatible modes, hex20 full and
reduced, hybrid u–p for near-incompressible materials), plane stress, plane strain and
axisymmetric, trusses, Timoshenko beams, MITC3/MITC4 shells, springs, dashpots and point masses.
- **Model:** sets bound to geometry names, so they survive remeshing; isotropic and orthotropic
elasticity, J2 plasticity, Johnson–Cook damage, thermal properties; fixed, displacement,
elasticity, J2 plasticity (isotropic and kinematic hardening, Johnson–Cook), Johnson–Cook
damage, neo-Hookean, Mooney–Rivlin and Yeoh hyperelasticity, thermal properties; fixed, displacement,
symmetry and temperature conditions; forces, moments, pressure, gravity, centrifugal and heat
loads; reference points with kinematic and distributing couplings; undo, redo and a
text-diffable `.fieldcraft` file.
- **Solvers:** linear static and frequency (supernodal Cholesky, block Lanczos); steady and
- **Solvers:** linear static and frequency (supernodal Cholesky, block Lanczos); nonlinear static
by Newton–Raphson with automatic increments — large deformation (total Lagrangian) on every
solid element, follower pressure, finite- and small-strain plasticity sharing one return map
with the explicit solver, rubber and thermal strain, with measured quadratic convergence; steady and
transient heat transfer and coupled thermal stress; explicit dynamics with plasticity, damage,
erosion and rigid walls; D3Q19 lattice Boltzmann flow with forces on the part's faces. Results
never depend on the thread count.
- **Decks:** Abaqus, Nastran and LS-DYNA writers, each refusing by name what it cannot express.
- **Results:** contours of stress, strain and displacement with a legend, deformed shapes, mode
shapes, explicit frames, probes and reaction sums; the analysis setup drawn in the viewport.
shapes, explicit and nonlinear frames, probes and reaction sums; supports, loads, reference
points and couplings drawn in the viewport.
- **Verification:** every capability is checked against closed forms and published benchmarks
(NAFEMS, MacNeal–Harder, Scordelis–Lo) in [`tests/oracle`](tests/oracle), with results frozen
so they cannot drift: [docs/oracle.md](docs/oracle.md).
(NAFEMS, MacNeal–Harder, Scordelis–Lo, the elastica, Hill's plastic cylinder, Rivlin's tube) in [`tests/oracle`](tests/oracle), with results frozen
so they cannot drift: 170 of 176 quantities pass in 50 cases, the 6 others documented known
failures ([docs/oracle.md](docs/oracle.md)).
- **Driving it:** a desktop app in the Craft family's layout, a CLI, a JSON control channel and an
MCP server; every capability is a command ([docs/features.md](docs/features.md)).
![The FieldCraft window: a clamped cantilever with its supports, pressure, gravity and a coupled reference point drawn in the viewport](docs/media/setup-symbols.png)
## Build
```sh
@@ -65,6 +91,7 @@ Rust 1.95 or later, with the `wasm32-unknown-unknown` target for `cargo xtask ci
- [Libraries and licences](docs/adr/0001-libraries.md)
- [AI agents (MCP)](docs/mcp.md): `claude mcp add fieldcraft -- /path/to/fieldcraft-cli mcp`
- [Control protocol](docs/control-protocol.md): driving the desktop app
- [Releasing](docs/releasing.md): how a release is built, verified and published
- [Contributing](CONTRIBUTING.md) and [agent instructions](AGENTS.md)
## License
+8 -8
View File
@@ -28,11 +28,11 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|---|---|---|
| M0 Foundation | Workspace, quality gates, FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft | done |
| M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`fieldcraft-mesh::surface`, `mesh.surface.check`, done) | in progress |
| M2 Tet meshing and size field | Size field (global, 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 |
| 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, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress |
| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started |
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); 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-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 |
| 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 |
@@ -52,7 +52,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M23 Wave propagation and ultrasound | Elastic, acoustic and coupled waves from the explicit solver's 1D bar today to guided and ultrasonic waves, by two methods. Finite differences first, for fast 2D (then 3D) ultrasound on regular grids: staggered-grid velocity–stress FDTD (Virieux) and the elastodynamic finite integration technique for solids, the rotated staggered grid for anisotropic media, a k-space pseudospectral scheme for weakly heterogeneous tissue and water, all on grids voxelised from the face-tagged geometry by the LBM's exact voxeliser, with heterogeneous, layered and fluid–solid media, CPML boundaries, threads and the GPU backend; staircased curved boundaries scatter spuriously, so the FD grid is checked against the finite elements on curved parts. Finite elements for curved geometry and coupling with structures: spectral elements (high-order Gauss–Lobatto–Legendre hexes with diagonal mass) on the hex meshes, and a mesh check in elements per wavelength with the dispersion error it implies; absorbing boundaries (convolutional PML, absorbing layers with increasing damping) and infinite media; tone-burst sources (Hann-windowed cycles), point, line and surface excitation, and piezoelectric transducers (M19); anisotropic media (composites, crystals) and viscoelastic, power-law-in-frequency attenuation; fluid–solid interfaces and fluid-loaded plates; ultrasound in water and tissue, nonlinear acoustics (Westervelt, KZK) for focused ultrasound. Guided waves: dispersion curves by semi-analytical finite elements (SAFE) for plates, pipes, rails and layered and anisotropic laminates — Lamb, Rayleigh, Love and SH modes — with phase and group velocities and mode shapes; mode-selective excitation. Output: A-scans and B-scans at sensors, wavefield snapshots, 2D FFT (frequency–wavenumber) for mode identification, and time–frequency diagrams of any signal — short-time Fourier spectrograms, continuous wavelet transforms (Morlet), reassigned spectrograms and smoothed pseudo Wigner–Ville — with the SAFE or Rayleigh–Lamb group-velocity curves overlaid as arrival times, and group velocities extracted from the ridges. GPU paths (M12's backend) for long runs. Verified by Lamb's problem (point load on a half-space), Rayleigh-wave speed, Rayleigh–Lamb dispersion, Love waves in a layer over a half-space, reflection and transmission at interfaces, and published guided-wave measurements | planned |
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 26 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 9 published benchmarks (MacNeal–Harder solid tests, Scordelis–Lo with solids, NAFEMS LE1 in 3D and 2D, LE10, FV32) and 9 pending cases with citations (shells, thermal, static plasticity, Taylor bar, CFD); `mesh.block` mapped blocks and 2D patches build their meshes | started |
| Continuous: performance | CPU first, deterministic: results never depend on the thread count (fixed partitions, sums in a fixed order; checked by hashing results on 1, 2 and 7 threads). Supernodal left-looking Cholesky (11–15× faster than the column solver on one thread), parallel element matrices, stresses and Lanczos products, explicit element updates and LBM cell blocks on rayon threads (done — `fieldcraft-parallel`, `threads` on the solve commands, `FIELDCRAFT_THREADS`); parallel forward/back substitution and explicit tet4 assembly; GPU paths later, within a stated tolerance of the CPU | started |
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve: done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress), browser build, command schema as the public API | started |
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve: done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress), browser build, command schema as the public API | started |
## Next
@@ -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) |
+6
View File
@@ -2,6 +2,7 @@
//! fieldcraft-cli run <script.json>
//! fieldcraft-cli features
//! fieldcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! fieldcraft-cli --version
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -26,6 +27,7 @@ usage:
--automation-read-root DIR files may be read only inside DIR
--automation-write-root DIR files may be written only inside DIR (with either root,
the access a root left out is not granted)
fieldcraft-cli --version print the version
";
/// Largest script accepted (bytes).
@@ -46,6 +48,10 @@ fn main() -> ExitCode {
}
Some("mcp") => mcp(args.get(1..).unwrap_or(&[])),
Some("run") => args.get(1).ok_or_else(|| format!("run needs a script\n\n{USAGE}")).and_then(|p| run(p)),
Some("-V" | "--version") => {
println!("fieldcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
Ok(())
+12
View File
@@ -0,0 +1,12 @@
//! `fieldcraft-cli --version`, which the release packaging runs to check each build.
use std::process::Command;
#[test]
fn version_prints_the_workspace_version() {
for flag in ["--version", "-V"] {
let out = Command::new(env!("CARGO_BIN_EXE_fieldcraft-cli")).arg(flag).output().unwrap();
assert!(out.status.success(), "{flag}: {out:?}");
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), format!("fieldcraft-cli {}", env!("CARGO_PKG_VERSION")));
}
}
+4
View File
@@ -18,3 +18,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[build-dependencies]
# The Windows executable's icon resource (build.rs).
embed-resource = "3.0.12"
+20
View File
@@ -0,0 +1,20 @@
//! Windows builds embed the application icon (assets/app-icon/icon.ico) in `fieldcraft.exe`, so
//! Explorer, the taskbar and shortcuts show it. Other targets need nothing here.
#![forbid(unsafe_code)]
use std::path::PathBuf;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let icon = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?).join("../../assets/app-icon/icon.ico");
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed={}", icon.display());
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
return Ok(());
}
let out = PathBuf::from(std::env::var("OUT_DIR")?);
let rc = out.join("fieldcraft.rc");
std::fs::write(&rc, format!("1 ICON \"{}\"\n", icon.display().to_string().replace('\\', "/")))?;
// Missing resource tools are a build error, rather than silently shipping a generic icon.
embed_resource::compile_for(&rc, ["fieldcraft"], embed_resource::NONE).manifest_required()?;
Ok(())
}
+4
View File
@@ -22,6 +22,9 @@ const DEPTH_BITS: u8 = 24;
/// The window draws its own caption buttons where the OS ones would sit apart from the app bar.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
const MSAA: u16 = 4;
/// The reverse-DNS application id: the Wayland app_id / X11 class, matched to the packaged
/// `.desktop` entry (packaging/linux), and the macOS bundle id (packaging/macos/Info.plist.in).
const APP_ID: &str = "io.github.jyviko.fieldcraft";
/// The window and Dock icon: the 256 px export of the application icon (assets/app-icon).
const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/icon.iconset/icon_128x128@2x.png");
@@ -148,6 +151,7 @@ fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FieldCraft")
.with_app_id(APP_ID)
.with_icon(app_icon())
.with_inner_size([1400.0, 900.0])
.with_min_inner_size([960.0, 600.0])
+5 -3
View File
@@ -22,6 +22,8 @@ hand. It runs on macOS (sips, iconutil, `xcrun actool`) with ImageMagick (`magic
| `Assets.car` | `actool` compilation of `FieldCraft.icon` for macOS 26 and later (needs Xcode 26 or later; the task skips it with a note otherwise) |
The desktop app embeds `icon.iconset/icon_128x128@2x.png` as its window and Dock icon
(`apps/fieldcraft/src/main.rs`). There is no macOS bundle yet; packaging will copy
`Assets.car` and `icon.icns` into `Contents/Resources` and name `FieldCraft` in
`CFBundleIconName` / `CFBundleIconFile`.
(`apps/fieldcraft/src/main.rs`). The release packages (docs/releasing.md) use the rest:
`packaging/macos/package.sh` copies `Assets.car` and `icon.icns` (as `FieldCraft.icns`) into
`FieldCraft.app/Contents/Resources`, named by `CFBundleIconName` / `CFBundleIconFile`;
`apps/fieldcraft/build.rs` embeds `icon.ico` in `fieldcraft.exe`; and
`packaging/linux/package.sh` installs the iconset PNGs as the hicolor icons.
+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();
+8 -2
View File
@@ -244,7 +244,7 @@ pub static CATALOGUE: &[Capability] = &[
"Tet Mesh",
Mesh,
"Tet",
"Constrained Delaunay tet mesh of a body: every boundary triangle kept, faces tagged (refinement comes next)",
"Quality tet4 or tet10 mesh of a body to a size: refined, smoothed and improved, boundary on the B-rep faces, faces tagged",
),
soon("mesh.order.quadratic", "Quadratic", Mesh, "Tet", "M2", "Second-order elements with mid-side nodes on the geometry"),
ready("mesh.delaunay.points", "Delaunay Points", Mesh, "Tet", "Delaunay tetrahedralization of a point list (debugging aid)"),
@@ -281,7 +281,13 @@ pub static CATALOGUE: &[Capability] = &[
),
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,
};
+18 -12
View File
@@ -3,7 +3,7 @@
use std::borrow::Cow;
use fieldcraft_geometry::{FaceSurface, Shape, SurfaceMesh};
use fieldcraft_mesh::hex::{Cell, HexMesh, HexMethod, HexOptions, MAX_CELLS, Surface, hex_mesh, hex_mesh_cells};
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) => {
@@ -220,7 +220,7 @@ fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
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, 1)?;
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();
@@ -318,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, std::slice::from_ref(b), |_| 0, 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(),
@@ -338,9 +338,15 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
/// Tag every boundary element face with the B-rep face it lies on, of the body
/// (`bodies[cell(element)]`) its element meshes.
fn tag_faces(model: &mut Model, bodies: &[Input<'_>], cell: impl Fn(u32) -> usize, m: &HexMesh, first_element: u32) -> Result<()> {
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 &m.boundary {
for f in boundary {
let c = cell(f.element);
let tag = match tags.get(&(c, f.brep_face)) {
Some(&t) => t,
+174 -25
View File
@@ -6,6 +6,9 @@ use fieldcraft_mesh::cdt::{CdtOptions, tetrahedralize};
use fieldcraft_mesh::delaunay::Triangulation;
use fieldcraft_mesh::quality::{self, QualityReport};
use fieldcraft_mesh::surface::{BoundarySurface, Defect, FeatureKind, SurfaceOptions};
use fieldcraft_mesh::tet::geometry::AnalyticSurface;
use fieldcraft_mesh::tet::improve::ImproveOptions;
use fieldcraft_mesh::tet::{Order, QualityOptions, TetQuality, quality_mesh, raw_mesh};
use fieldcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS};
use serde_json::{Value, json};
@@ -83,7 +86,7 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec {
pub const TET_GENERATE: CommandSpec = CommandSpec {
id: "mesh.tet.generate",
label: "Generate Tet Mesh",
params: r#"{"body": "Body1", "tolerance": 0.05} or {"surface": 0}, the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "max_steiner": 50000 (0..=10000000), "max_tets": 20000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary: every boundary triangle is a union of tet faces, Steiner points only on boundary edges, no quality refinement yet. Adds tet4 elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if the surface is invalid or the mesh rounded to doubles does not pass the validity gate"#,
params: r#"{"body": "Body1", "tolerance": 0.05} or {"surface": 0}, the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if the surface is invalid or the mesh does not pass the validity gate"#,
kind: CommandKind::Model,
run: tet_generate,
};
@@ -91,9 +94,11 @@ pub const TET_GENERATE: CommandSpec = CommandSpec {
/// The most Steiner points and tetrahedra `mesh.tet.generate` may be asked for.
const TET_MAX_STEINER: u64 = 10_000_000;
const TET_MAX_TETS: u64 = 50_000_000;
const TET_MAX_POINTS: u64 = 20_000_000;
const TET_MAX_PASSES: u64 = 200;
/// 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
@@ -114,37 +119,116 @@ pub(super) fn surface_options(p: &Value, mesh: &SurfaceMesh) -> Result<SurfaceOp
})
}
fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
let cdt_options = CdtOptions {
max_steiner: optional_count(p, "max_steiner", s.limits.max_steiner as u64, 0..=TET_MAX_STEINER)?,
max_tets: optional_count(p, "max_tets", s.limits.max_tets as u64, 1..=TET_MAX_TETS)?,
/// An optional number in `range`.
fn number_in(p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
let v = optional_number(p, key, default)?;
if range.contains(&v) { Ok(v) } else { Err(EngineError::Params(format!("`{key}` must be a number in {}..={}", range.start(), range.end()))) }
}
/// The quality options of `mesh.tet.generate`, and whether the quality stage runs at all.
fn quality_options(p: &Value, max_tets: u64) -> Result<(QualityOptions, bool)> {
let d = QualityOptions::default();
let size = match p.get("size") {
None => None,
Some(v) => Some(
v.as_f64()
.filter(|h| *h > 0.0 && h.is_finite() && *h <= fieldcraft_model::MAX_COORD)
.ok_or_else(|| EngineError::Params("`size` must be a positive number (mm)".into()))?,
),
};
let (surface, body, face_names) = {
let order = match p.get("order") {
None => Order::Linear,
Some(v) => match v.as_str() {
Some("linear") => Order::Linear,
Some("quadratic") => Order::Quadratic,
_ => return Err(EngineError::Params("`order` must be \"linear\" or \"quadratic\"".into())),
},
};
let target = number_in(p, "quality_target", 40.0, 5.0..=60.0)?;
let improve_options = ImproveOptions {
max_passes: optional_count(p, "max_passes", d.improve_options.max_passes as u64, 0..=TET_MAX_PASSES)?,
good: fieldcraft_mesh::tet::geometry::sin_cos(target.to_radians()).0,
..d.improve_options
};
let quality = super::opt::<bool>(p, "quality")?.unwrap_or(true);
if !quality && order == Order::Quadratic {
return Err(EngineError::Params("quadratic elements need the quality stage (`quality`: true)".into()));
}
let options = QualityOptions {
size,
curvature_angle: number_in(p, "curvature_angle", d.curvature_angle.to_degrees(), 0.0..=90.0)?.to_radians(),
grading: number_in(p, "grading", d.grading, 0.05..=2.0)?,
improve_options,
order,
max_tets: optional_count(p, "max_tets", max_tets, 1..=TET_MAX_TETS)?,
max_points: optional_count(p, "max_points", d.max_points as u64, 1..=TET_MAX_POINTS)?,
..d
};
Ok((options, quality))
}
/// The exact surfaces of a body's faces, for the quality stage's projections (none when the
/// kernel cannot give them: the stage then keeps to the tessellation).
pub fn analytic_surfaces(shape: &fieldcraft_geometry::Shape) -> Vec<Option<AnalyticSurface>> {
use fieldcraft_geometry::FaceSurface as F;
shape
.face_surfaces()
.unwrap_or_default()
.into_iter()
.map(|f| {
f.map(|f| match f {
F::Plane { normal, point } => AnalyticSurface::Plane { normal, point },
F::Cylinder { origin, axis, radius } => AnalyticSurface::Cylinder { origin, axis, radius },
F::Cone { apex, axis, half_angle } => AnalyticSurface::Cone { apex, axis, half_angle },
F::Sphere { center, radius } => AnalyticSurface::Sphere { center, radius },
F::Torus { center, axis, major, minor } => AnalyticSurface::Torus { center, axis, major, minor },
})
})
.collect()
}
fn quality_json(q: &TetQuality) -> Value {
json!({
"tets": q.tets,
"min_dihedral": q.min_dihedral,
"max_dihedral": q.max_dihedral,
"min_radius_ratio": q.min_radius_ratio,
"mean_radius_ratio": q.mean_radius_ratio,
"slivers": q.slivers,
"inadmissible": q.inadmissible,
})
}
fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
let (options, quality) = quality_options(p, s.limits.max_tets as u64)?;
let cdt_options =
CdtOptions { max_steiner: optional_count(p, "max_steiner", s.limits.max_steiner as u64, 0..=TET_MAX_STEINER)?, max_tets: options.max_tets };
let (surface, body, face_names, analytic) = {
let Input { mesh, body, faces } = surface_input(s, p)?;
let analytic = s.design.as_deref().and_then(|d| d.body(&body)).filter(|_| p.get("body").is_some()).map(analytic_surfaces).unwrap_or_default();
let options = surface_options(p, &mesh)?;
(BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options)?, body, faces)
(BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options)?, body, faces, analytic)
};
let cdt = tetrahedralize(&surface, &cdt_options)?;
let gate = cdt.gate(surface.volume())?.into_result()?;
let mesh = if quality { quality_mesh(&surface, &cdt, analytic, &options)? } else { raw_mesh(&surface, &cdt)? };
let (first_node, first_element) = super::fresh_labels(&s.model, cdt.points.len(), cdt.tets.len())?;
for (i, xyz) in cdt.points.iter().enumerate() {
let (first_node, first_element) = super::fresh_labels(&s.model, mesh.points.len(), mesh.elements.len())?;
for (i, xyz) in mesh.points.iter().enumerate() {
s.model.set_node(first_node + i as u32, *xyz)?;
}
let body_tag = s.model.geometry_tag(&GeometryRef::body(body.clone())?)?;
for (i, t) in cdt.tets.iter().enumerate() {
for (i, e) in mesh.elements.iter().enumerate() {
let label = first_element + i as u32;
s.model.set_element(label, ElementKind::Tet4, t.iter().map(|&v| first_node + v).collect())?;
s.model.set_element(label, mesh.kind, e.iter().map(|&v| first_node + v).collect())?;
s.model.tag_element(label, body_tag)?;
}
let mut face_tags: Vec<Option<u32>> = vec![None; face_names.len()];
for b in &cdt.boundary {
let face = surface.tri_face().get(b.triangle as usize).copied().unwrap_or(u32::MAX) as usize;
for b in &mesh.boundary {
let face = b.face as usize;
let tag = match face_tags.get(face).copied().flatten() {
Some(t) => t,
None => {
let name =
face_names.get(face).ok_or_else(|| EngineError::Params(format!("boundary triangle {} has no geometry face", b.triangle)))?;
let name = face_names.get(face).ok_or_else(|| EngineError::Params(format!("boundary face {} has no geometry face", b.face)))?;
let t = s.model.geometry_tag(&GeometryRef::face(body.clone(), name.clone())?)?;
if let Some(slot) = face_tags.get_mut(face) {
*slot = Some(t);
@@ -152,20 +236,33 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
t
}
};
let number = b.tet4_face().ok_or_else(|| EngineError::Params(format!("boundary face {} has no tet4 face number", b.opposite)))?;
let number = b.element_face().ok_or_else(|| EngineError::Params(format!("boundary face {} has no element face number", b.opposite)))?;
s.model.tag_element_face(first_element + b.tet, number, tag)?;
}
let r = &mesh.report;
Ok(json!({
"body": body,
"nodes": cdt.points.len(),
"elements": cdt.tets.len(),
"kind": mesh.kind.name(),
"nodes": mesh.points.len(),
"elements": mesh.elements.len(),
"first_node": first_node,
"first_element": first_element,
"steiner": cdt.steiner.len(),
"cavities": cdt.stats.cavities,
"boundary_faces": cdt.boundary.len(),
"volume": gate.volume,
"boundary_volume": surface.volume(),
"boundary_faces": mesh.boundary.len(),
"volume": r.volume,
"boundary_volume": r.boundary_volume,
"surface_volume": surface.volume(),
"size": r.size,
"quality": {
"cdt": quality_json(&r.input),
"mesh": quality_json(&r.output),
"refined": r.refine.iter().map(|x| x.interior + x.boundary).sum::<usize>(),
"odt_moves": r.odt_moves,
"improvement": {"passes": r.improve.passes, "smoothed": r.improve.smoothed, "topological": r.improve.topological, "inserted": r.improve.inserted},
"straightened": r.straightened,
"stopped": r.stopped,
},
}))
}
@@ -656,6 +753,53 @@ mod tests {
assert!(r["elements"].as_u64().unwrap() > 0, "{r}");
}
/// The quality stage: a graded, improved tet4 mesh with its report, and tet10 with mid-edge
/// nodes on the bore; the raw tetrahedralization on request.
#[test]
fn tet_generate_quality_report_and_quadratic_elements() {
use std::sync::Arc;
use fieldcraft_geometry::open_design;
use fieldcraft_geometry::sample::{Plate, plate_design};
let plate = Plate::default();
let design = Arc::new(open_design(&plate_design(&plate).unwrap()).unwrap());
let mut s = Session { design: Some(design.clone()), ..Session::default() };
let r = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.2, "size": 6})).unwrap();
let q = &r["quality"];
assert_eq!(q["mesh"]["inadmissible"], json!(0), "{r}");
assert_eq!(q["mesh"]["slivers"], json!(0), "{r}");
assert!(q["mesh"]["min_dihedral"].as_f64().unwrap() > 15.0, "{r}");
assert!(q["mesh"]["max_dihedral"].as_f64().unwrap() < 165.0, "{r}");
assert!(q["cdt"]["slivers"].as_u64().unwrap() > 0, "{r}");
// The mesh fills what its boundary encloses, which is the plate within the chordal error.
let (volume, boundary) = (r["volume"].as_f64().unwrap(), r["boundary_volume"].as_f64().unwrap());
assert!((volume - boundary).abs() <= 1e-9 * boundary, "{r}");
let exact = plate.width * plate.depth * plate.thickness - std::f64::consts::PI * 0.25 * plate.hole_diameter.powi(2) * plate.thickness;
// The bore's chordal error at 0.2 mm is at most 2/3 · 0.2 · its area (≈ 42 mm³).
assert!((volume - exact).abs() < 42.0, "{volume} vs {exact}");
// Quadratic: mid-edge nodes on the bore's cylinder.
let mut s = Session { design: Some(design), ..Session::default() };
let r = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.2, "size": 6, "order": "quadratic"})).unwrap();
assert_eq!(r["kind"], json!("tet10"), "{r}");
assert!(s.model.elements().values().all(|e| e.kind == fieldcraft_model::ElementKind::Tet10));
let report = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(report["valid"].as_u64().unwrap(), r["elements"].as_u64().unwrap(), "{report}");
// Nodes on the bore (within the tessellation's reach of its axis) lie on its radius.
let (cx, cy) = (plate.depth / 2.0, plate.depth / 2.0);
let radius = plate.hole_diameter / 2.0;
let mut on_bore = 0;
for n in s.model.nodes().values() {
let d = ((n.xyz[0] - cx).powi(2) + (n.xyz[1] - cy).powi(2)).sqrt();
if (d - radius).abs() < 0.05 {
on_bore += 1;
assert!((d - radius).abs() < 1e-9, "a bore node at {:?} is {d} from the axis", n.xyz);
}
}
assert!(on_bore > 20, "{on_bore}");
}
#[test]
fn tet_generate_on_an_imported_surface_and_an_open_one() {
use std::sync::Arc;
@@ -671,12 +815,17 @@ mod tests {
for mesh in [tet, open] {
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 }));
}
let r = s.execute("mesh.tet.generate", &json!({"surface": 0})).unwrap();
let r = s.execute("mesh.tet.generate", &json!({"surface": 0, "quality": false})).unwrap();
assert_eq!((r["elements"].clone(), r["nodes"].clone(), r["boundary_faces"].clone()), (json!(1), json!(4), json!(4)));
let element = s.model.element(1).unwrap();
assert_eq!(element.nodes.len(), 4);
// Refined to a size, the flat-faced tetrahedron keeps its volume exactly.
let r = s.execute("mesh.tet.generate", &json!({"surface": 0, "size": 0.3})).unwrap();
assert!(r["elements"].as_u64().unwrap() > 1, "{r}");
assert!((r["volume"].as_f64().unwrap() - 1.0 / 6.0).abs() < 1e-12, "{r}");
let total = s.model.elements().len();
assert!(s.execute("mesh.tet.generate", &json!({"surface": 1})).is_err(), "an open surface bounds no volume");
assert_eq!(s.model.elements().len(), 1);
assert_eq!(s.model.elements().len(), total);
}
#[test]
+4
View File
@@ -8,7 +8,10 @@ pub(crate) mod file;
mod geometry;
mod hex;
pub(crate) mod mesh;
pub use mesh::analytic_surfaces;
mod model;
pub(crate) mod octree;
mod results;
pub(crate) mod solve;
mod tags;
@@ -36,6 +39,7 @@ pub static ALL: &[CommandSpec] = &[
mesh::SURFACE_CHECK,
mesh::PLANAR,
mesh::TET_GENERATE,
octree::HEX_OCTREE,
block::BLOCK,
hex::HEX_GENERATE,
hex::HEX_SWEEP,
+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());
}
}
+27 -4
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] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
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!({
@@ -488,13 +489,21 @@ fn probe(s: &mut Session, p: &Value) -> Result<Value> {
}
let (nodes, located) = match (p.get("nodes"), p.get("at")) {
(Some(_), None) => (labels(p, "nodes", MAX_PROBE)?, None),
(None, None) if p.get("set").is_some() => {
let name = opt::<String>(p, "set")?.unwrap_or_default();
let set = s.model.resolve_set(&name)?;
if set.nodes.len() > MAX_PROBE {
return Err(EngineError::Params(format!("set `{name}` has {} nodes; a probe takes at most {MAX_PROBE}", set.nodes.len())));
}
(set.nodes, None)
}
(None, Some(_)) => {
let at = points(p, "at", MAX_PROBE)?;
let found = nearest_nodes(&s.model, result.nodes(), &at)?;
(found.iter().map(|(l, _, _)| *l).collect(), Some(found))
}
(None, None) if matches!(result.as_ref(), StepResult::Explicit(_)) || p.get("elements").is_some() => (Vec::new(), None),
_ => return Err(EngineError::Params("give either `nodes` or `at`".into())),
_ => return Err(EngineError::Params("give one of `nodes`, `at` or `set`".into())),
};
let mut out = match result.as_ref() {
StepResult::Explicit(r) => {
@@ -504,6 +513,16 @@ fn probe(s: &mut Session, p: &Value) -> Result<Value> {
StepResult::Implicit(r) => probe_implicit(r, &step, &nodes, p)?,
StepResult::Fluid(r) => probe_fluid(r, p)?,
};
// The largest von Mises stress among the probed nodes (static results).
let peak = out.get("nodes").and_then(Value::as_array).and_then(|entries| {
entries
.iter()
.filter_map(|e| Some((e.get("node")?.as_u64()?, e.get("von_mises")?.as_f64()?)))
.fold(None, |best: Option<(u64, f64)>, (n, v)| if best.is_none_or(|b| v > b.1) { Some((n, v)) } else { best })
});
if let (Some((node, value)), Some(obj)) = (peak, out.as_object_mut()) {
obj.insert("max_von_mises".into(), json!({"node": node, "value": value}));
}
if let (Some(found), Some(entries)) = (located, out.get_mut("nodes").and_then(Value::as_array_mut)) {
for (entry, (_, xyz, distance)) in entries.iter_mut().zip(found) {
if let Some(obj) = entry.as_object_mut() {
@@ -613,7 +632,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;
+45 -14
View File
@@ -15,6 +15,8 @@
pub mod autosave;
pub mod catalogue;
pub mod cmd;
pub use cmd::analytic_surfaces;
pub mod history;
pub mod results;
pub mod sample;
@@ -135,6 +137,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 +148,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(),
}
}
}
@@ -358,7 +367,8 @@ mod tests {
json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": vec!["x".repeat(1000); 2000]}),
json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": {}}),
json!({"path": 7}),
json!({"body": "Body1", "tolerance": 0.05}),
// (`max_points` and `max_passes` keep mesh.tet.generate's quality stage small.)
json!({"body": "Body1", "tolerance": 0.05, "max_points": 3000, "max_passes": 1}),
json!({"body": null, "tolerance": "fine"}),
json!({"elements": [u32::MAX, -1, 1.5]}),
json!({"elements": {}, "bins": -5}),
@@ -378,7 +388,7 @@ mod tests {
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "holes": [[[0.1, 0.1], [0.2, 0.1], [0.1, 0.2]], [[0.1, 0.1], [0.2, 0.1], [0.1, 0.2]]], "size": 0.2}),
json!({"polygon": [["a", 0], [1, null], {}], "size": "big", "kind": 7}),
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 1e300, "kind": "tri6", "max_elements": 1e300}),
json!({"body": "Body1", "face": "", "tolerance": 0.05, "size": 1}),
json!({"body": "Body1", "face": "", "tolerance": 0.05, "size": 1, "max_points": 3000, "max_passes": 1}),
// Structured hex meshing.
json!({"size": -1, "order": 9, "method": "x"}),
json!({"size": 1e-300}),
@@ -471,9 +481,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();
@@ -506,15 +518,34 @@ mod tests {
json!({"body": "Body1", "tolerance": 1e-300}),
json!({"body": "Body1", "tolerance": 1e300}),
json!({"body": "Body2", "tolerance": 0.1}),
json!({"body": "Body1", "tolerance": 0.5, "surface": 0}),
json!({"body": "Body1", "tolerance": 0.5, "weld": 0}),
json!({"body": "Body1", "tolerance": 0.5, "weld": 1e-3}),
json!({"body": "Body1", "tolerance": 0.5, "weld": f64::MIN_POSITIVE}),
json!({"body": "Body1", "tolerance": 0.5, "weld": 0, "weld_distance": 0}),
json!({"body": "Body1", "tolerance": 0.5, "weld_distance": 1e300}),
json!({"body": "Body1", "tolerance": 0.5, "weld_distance": 0.05}),
json!({"body": "Body1", "tolerance": 0.5, "feature_angle": 1e-300}),
json!({"body": "Body1", "tolerance": 0.5, "feature_angle": null}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "surface": 0}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": 0}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": 1e-3}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": f64::MIN_POSITIVE}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": 0, "weld_distance": 0}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld_distance": 1e300}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld_distance": 0.05}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "feature_angle": 1e-300}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "feature_angle": null}),
// The quality stage's own parameters, hostile, with small caps so this stays fast.
json!({"body": "Body1", "tolerance": 0.5, "size": 0}),
json!({"body": "Body1", "tolerance": 0.5, "size": -1}),
json!({"body": "Body1", "tolerance": 0.5, "size": 1e300}),
json!({"body": "Body1", "tolerance": 0.5, "size": 1e-300, "max_points": 2000, "max_tets": 5000}),
json!({"body": "Body1", "tolerance": 0.5, "size": "fine"}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "order": "cubic"}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "order": "quadratic", "quality": false}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "quality": "yes"}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "quality_target": 0}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "quality_target": 1e300}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "curvature_angle": -1}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "curvature_angle": 1e-300, "max_points": 2000}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "grading": 0}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "grading": f64::MAX}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1e9}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_points": 0}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_points": 30, "max_passes": 1}),
json!({"body": "Body1", "tolerance": 0.5, "size": 0.5, "max_tets": 3000, "max_passes": 1}),
json!({"body": "Body1", "size": 1e-300}),
json!({"body": "Body1", "size": 1e300, "order": 2}),
json!({"body": "Body1", "size": 4, "method": "sweep", "order": 2}),
+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());
}
}
+6 -4
View File
@@ -106,11 +106,13 @@ pub fn inverse3(m: &[[f64; 3]; 3]) -> Option<([[f64; 3]; 3], f64)> {
}
/// The inverse and determinant of a Jacobian that maps a reference element onto a valid one:
/// `None` unless det J is positive beyond round-off relative to the product of its row norms.
/// `None` unless it is admissible by FieldCraft's one validity criterion
/// ([`fieldcraft_model::admissible_jacobian`], the one the meshers' validity gate proves).
pub fn positive_inverse(j: &[[f64; 3]; 3]) -> Option<([[f64; 3]; 3], f64)> {
let (inv, det) = inverse3(j)?;
let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product();
(det > 1e-12 * norms).then_some((inv, det))
if !fieldcraft_model::admissible_jacobian(j) {
return None;
}
inverse3(j)
}
/// The eigenvalues and unit eigenvectors (the columns of the matrix returned) of a symmetric
+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:?}");
}
}
+2 -2
View File
@@ -35,7 +35,7 @@
use fieldcraft_model::ElementKind;
use crate::basis::Basis;
use crate::dense::{Cholesky, Matrix, inverse3, positive_inverse};
use crate::dense::{Cholesky, Matrix, positive_inverse};
use crate::dof::{DofSet, ElementDofs, Internal};
use crate::material::{Elasticity, stress};
use crate::quadrature::{Point, Rule};
@@ -356,7 +356,7 @@ impl<'a> Solid<'a> {
fn centre_jacobian(&self) -> Result<([[f64; 3]; 3], f64)> {
let s = (self.ty.shape)([0.0; 3]);
inverse3(&self.jacobian(&s)).filter(|(_, d)| *d > 0.0).ok_or_else(|| self.degenerate())
positive_inverse(&self.jacobian(&s)).ok_or_else(|| self.degenerate())
}
/// The parts of the stiffness with the incompatible modes: K_uu (when `with_uu`), K_ua
+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;
+4 -14
View File
@@ -30,7 +30,7 @@
use fieldcraft_model::Elastic;
use crate::dense::inverse3;
use crate::dense::positive_inverse;
use crate::material::Elasticity;
use crate::quadrature::Rule;
use crate::shape::{self, HEX_CORNERS};
@@ -72,11 +72,7 @@ impl UniformStrain<8> {
}
}
}
let (inv, det) = inverse3(&j).ok_or_else(|| degenerate("hex8"))?;
let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product();
if !(det > 1e-12 * norms) {
return Err(degenerate("hex8"));
}
let (inv, det) = positive_inverse(&j).ok_or_else(|| degenerate("hex8"))?;
let w = det * p.weight;
volume += w;
for (((bi, vi), d), n) in b.iter_mut().zip(nodal_volume.iter_mut()).zip(s.derivatives()).zip(s.values()) {
@@ -172,15 +168,9 @@ impl UniformStrain<4> {
/// The linear tetrahedron of corners `xyz` (the model's tet4 node order).
pub fn tet4(xyz: &[[f64; 3]; 4]) -> Result<Tet4> {
fieldcraft_model::check_coordinates(xyz.as_flattened()).map_err(|e| Error::Invalid(e.to_string()))?;
let [x0, x1, x2, x3] = *xyz;
let e = |x: [f64; 3]| [x[0] - x0[0], x[1] - x0[1], x[2] - x0[2]];
// J[a][c] = ∂x_c/∂ξ_a: the edges from corner 1.
let j = [e(x1), e(x2), e(x3)];
let (inv, det) = inverse3(&j).ok_or_else(|| degenerate("tet4"))?;
let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product();
if !(det > 1e-12 * norms) {
return Err(degenerate("tet4"));
}
let j = fieldcraft_model::tet4_jacobian(xyz);
let (inv, det) = positive_inverse(&j).ok_or_else(|| degenerate("tet4"))?;
let volume = det / 6.0;
let s = shape::tet4([0.25; 3]);
let mut b = [[0.0; 3]; 4];
+11 -1
View File
@@ -1,6 +1,6 @@
//! Where tool calls end up: an in-process headless session, or a running app's control channel
//! (docs/control-protocol.md: `engine.execute`, `engine.commands`, `ui.view`, `ui.camera`,
//! `ui.render`, `ui.capture`).
//! `ui.render`, `ui.capture`, `ui.select`, `ui.selection`).
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
@@ -43,6 +43,8 @@ pub trait Backend {
fn commands(&mut self) -> Result<Value, String>;
/// A PNG of the model, or of the app's window when one is attached.
fn picture(&mut self, request: &PictureRequest) -> Result<Picture, String>;
/// A request to the app's window (`ui.select`, `ui.selection`): an error without one.
fn window(&mut self, method: &str, params: Value) -> Result<Value, String>;
/// The folders file access is confined to (checked by the server for the files it writes
/// itself).
fn roots(&self) -> &Roots;
@@ -124,6 +126,10 @@ impl Backend for Headless {
Ok(Picture { png, info })
}
fn window(&mut self, method: &str, _params: Value) -> Result<Value, String> {
Err(format!("{method} needs the app's window: start the server with --connect to a running FieldCraft"))
}
fn roots(&self) -> &Roots {
&self.roots
}
@@ -267,6 +273,10 @@ impl Backend for Remote {
read
}
fn window(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.call(method, params)
}
fn roots(&self) -> &Roots {
&self.roots
}
+5
View File
@@ -78,6 +78,8 @@ fn initialize_lists_tools_and_resources() {
"probe",
"reactions",
"screenshot",
"select",
"selection",
"undo",
"redo",
] {
@@ -181,6 +183,9 @@ fn hostile_tool_arguments_are_tool_errors() {
("screenshot", json!({"camera": {"yaw": 0, "pitch": 0}, "view": "top"})),
("batch", json!({"commands": [{"command": "model.info"}, {"params": {}}, {"command": "model.info"}]})),
("batch", json!({"commands": vec![json!({"command": "model.info"}); crate::tools::MAX_STEPS + 1]})),
("select", json!({"filter": "faces"})),
("select", json!({})),
("selection", json!({})),
("undo", json!({})),
("redo", json!({})),
] {
+43
View File
@@ -290,6 +290,41 @@ pub fn tool_definitions() -> Vec<Value> {
true,
false,
),
tool(
"select",
"Select in the viewport",
"Change the app window's viewport selection (needs --connect; ui.select): `filter` (faces, edges, vertices, \
bodies, nodes, element_faces, elements), `mode` (replace, add, remove, toggle), `clear`, items by name and \
label as model.set.create takes them (`geometry`, `nodes`, `elements`, `faces`), a pick at a window point \
`at` [x, y] or the visible items in a box `box` [x0, y0, x1, y1]. Returns the selection; its `params` are \
a set's members.",
obj(
json!({
"filter": {"type": "string", "enum": ["faces", "edges", "vertices", "bodies", "nodes", "element_faces", "elements"]},
"box_mode": {"type": "boolean", "description": "Box selection on or off in the window"},
"mode": {"type": "string", "enum": ["replace", "add", "remove", "toggle"]},
"clear": {"type": "boolean", "description": "Empty the selection first"},
"geometry": {"type": "array", "items": {"type": "object"}, "description": "[{body, face|edge|vertex}]"},
"nodes": {"type": "array", "items": {"type": "integer"}},
"elements": {"type": "array", "items": {"type": "integer"}},
"faces": {"type": "array", "items": {"type": "object"}, "description": "[{element, face}]"},
"at": {"type": "array", "items": {"type": "number"}, "description": "Window point [x, y] to pick at"},
"box": {"type": "array", "items": {"type": "number"}, "description": "Window box [x0, y0, x1, y1]"},
}),
&[],
),
false,
false,
),
tool(
"selection",
"Read the viewport selection",
"The app window's viewport selection (needs --connect; ui.selection): filter, items, `params` (the \
selection as a set's members, ready for model.set.create) and what is under the pointer.",
obj(json!({}), &[]),
true,
false,
),
tool("undo", "Undo", "Take back the last command that changed the model.", obj(json!({}), &[]), false, false),
tool("redo", "Redo", "Run the last undone change again.", obj(json!({}), &[]), false, false),
]
@@ -440,6 +475,14 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
"show_results" => show_results(b, a),
"probe" => b.execute("solve.probe", pick(a, &["step", "nodes", "elements", "points", "forces", "mode", "frame"])),
"reactions" => b.execute("results.reactions", pick(a, &["step", "sets", "about"])),
"select" => {
let p = pick(a, &["filter", "box_mode", "mode", "clear", "geometry", "nodes", "elements", "faces", "at", "box"]);
if p.as_object().is_none_or(|o| o.is_empty()) {
return Err("select: give `filter`, `mode`, `clear`, items, `at` or `box`".into());
}
b.window("ui.select", p)
}
"selection" => b.window("ui.selection", json!({})),
"undo" => b.execute("edit.undo", json!({})),
"redo" => b.execute("edit.redo", json!({})),
other => Err(format!("unknown tool `{}`", other.chars().take(100).collect::<String>())),
+1 -51
View File
@@ -8,43 +8,12 @@
//! the hull. The hull is the bounding box the recovery adds around the surface, so every Steiner
//! point is strictly inside it and no ghost tetrahedra are needed.
use std::collections::HashMap;
use std::hash::{BuildHasherDefault, Hasher};
pub(super) use crate::tet::complex::{IdMap, NONE, face_toward, sorted3};
use crate::delaunay::Triangulation;
use crate::predicates::{GenericPoint, Sign, generic_insphere_sos, generic_lex, generic_orient3d};
use crate::{Error, Result};
/// A multiplicative hasher for small integer keys (vertex triples). The maps it serves are only
/// looked up, never iterated, so its order cannot leak into the output.
#[derive(Default)]
pub(super) struct IdHasher(u64);
impl Hasher for IdHasher {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.write_u64(u64::from(b));
}
}
fn write_u32(&mut self, x: u32) {
self.write_u64(u64::from(x));
}
fn write_u64(&mut self, x: u64) {
self.0 = (self.0.rotate_left(5) ^ x).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95);
}
}
/// A hash map keyed by vertex ids.
pub(super) type IdMap<K, V> = HashMap<K, V, BuildHasherDefault<IdHasher>>;
/// No tetrahedron / no vertex.
pub(super) const NONE: u32 = u32::MAX;
/// Marks a recycled tetrahedron slot.
const DEAD: u32 = u32::MAX - 1;
@@ -52,25 +21,6 @@ pub(super) fn corrupt(what: &str) -> Error {
Error::Failed(format!("constrained tetrahedralization is inconsistent: {what}"))
}
/// The face of `vs` opposite slot `k`, ordered so that vertex `k` is on its positive side
/// ([`Complex::volume`] of the face plus that vertex is positive).
pub(super) fn face_toward(vs: [u32; 4], k: usize) -> [u32; 3] {
let [a, b, c, d] = vs;
match k {
0 => [b, d, c],
1 => [a, c, d],
2 => [a, d, b],
_ => [a, b, c],
}
}
/// A triangle's vertices sorted: the key of an unoriented face.
pub(super) fn sorted3([a, b, c]: [u32; 3]) -> [u32; 3] {
let mut f = [a, b, c];
f.sort_unstable();
f
}
/// The working tetrahedralization.
#[derive(Debug, Clone)]
pub(super) struct Complex {
+10 -9
View File
@@ -34,9 +34,10 @@
//! then the Steiner points, each with its segment and parameter, rounded to doubles; and for each
//! boundary tetrahedron face the input triangle it lies on (so the B-rep face tag carries over).
//! Rounding a Steiner point to the nearest doubles moves it off its segment by under an ulp and
//! can, on a nearly flat tetrahedron, flip it (§5.1). [`round`] then tries the doubles a few ulps
//! around such a point for one that keeps every incident tetrahedron positive; [`TetMesh::gate`]
//! runs the validity gate on the rounded mesh and any failure left is reported, never hidden.
//! can, on a nearly flat tetrahedron, flip it (§5.1); nearly cospherical input also gives
//! tetrahedra that are positive yet flat to rounding. [`repair`] removes them by flips that keep
//! the boundary (§5.1); [`TetMesh::gate`] runs the validity gate on the rounded mesh and any
//! failure left is reported, never hidden.
//!
//! # Determinism and bounds
//! Same surface → same mesh: the Delaunay step is deterministic, segments and facets are
@@ -46,11 +47,11 @@
mod complex;
mod facets;
mod meet;
mod round;
mod repair;
mod segments;
#[cfg(test)]
mod tests;
pub(crate) mod tests;
use std::collections::HashMap;
@@ -122,9 +123,9 @@ pub struct CdtStats {
pub gift_wrapped_tets: usize,
/// Tetrahedra in the box at the end.
pub box_tets: usize,
/// Steiner points moved by a few ulps from their nearest doubles to keep every incident
/// tetrahedron positive ([`round`]).
pub rounding_adjusted: usize,
/// Tetrahedra that were flat, inverted or inadmissible on the rounded coordinates and were
/// removed by flips ([`repair`]).
pub repaired: usize,
}
/// The constrained Delaunay tetrahedralization of a closed surface (see the module docs).
@@ -355,7 +356,7 @@ impl Recovery {
}
let mut stats = self.stats;
stats.box_tets = self.cx.live_count();
stats.rounding_adjusted = round::repair(&mut points, &tets, n)?;
stats.repaired = repair::repair(&points, &mut tets, &mut boundary, self.options.max_tets)?;
Ok(TetMesh { points, steiner, tets, boundary, stats })
}
}
+96
View File
@@ -0,0 +1,96 @@
//! Repairing the tetrahedra that rounding to doubles leaves flat, inverted or inadmissible.
//!
//! The exact tetrahedralization is valid, but its Steiner points are rounded to doubles, which
//! can flip a nearly flat tetrahedron (Diazzi et al. 2023, §5.1), and nearly cospherical input
//! (a tessellated sphere, a cylinder) gives tetrahedra that are positive yet flat to rounding:
//! their normalized Jacobian is below FieldCraft's validity criterion
//! ([`fieldcraft_model::admissible_jacobian`]), so a solver would refuse them. Following §5.1,
//! such tetrahedra are removed by flips that never touch the boundary, on the rounded
//! coordinates and decided by exact `orient3d`: edge removal (which includes the 3-2 and 4-4
//! flips) around each of their edges and face removal (the 2-3 flip and multi-face removal)
//! across each of their faces, each applied only when it improves the worst tetrahedron it
//! changes ([`crate::tet::ops`]). Every boundary edge is held fixed, so every input triangle
//! stays a union of tetrahedron faces. The passes are capped; whatever is left is reported by the
//! validity gate, never hidden.
use fieldcraft_model::{normalized_jacobian, tet4_jacobian};
use super::BoundaryFace;
use crate::tet::complex::{EDGES, NONE, TetComplex, VertexKind};
use crate::tet::ops::{remove_edge_at, remove_face};
use crate::tet::refine::feature_curves;
use crate::{Error, Result};
/// A tetrahedron is repaired when its normalized Jacobian is at most this (a margin of a
/// thousand above the validity criterion, so rounding differences cannot bring it back).
const REPAIR_BELOW: f64 = 1e3 * fieldcraft_model::MIN_NORMALIZED_JACOBIAN;
/// Passes over the tetrahedra still to repair.
const MAX_PASSES: usize = 8;
fn needs_repair(points: &[[f64; 3]], t: &[u32; 4]) -> bool {
let p = |v: u32| points.get(v as usize).copied().unwrap_or([f64::NAN; 3]);
!(normalized_jacobian(&tet4_jacobian(&[p(t[0]), p(t[1]), p(t[2]), p(t[3])])) > REPAIR_BELOW)
}
/// Repair the rounded mesh in place; the number of tetrahedra that needed it.
pub(super) fn repair(points: &[[f64; 3]], tets: &mut Vec<[u32; 4]>, boundary: &mut Vec<BoundaryFace>, max_tets: usize) -> Result<usize> {
let bad = tets.iter().filter(|t| needs_repair(points, t)).count();
if bad == 0 {
return Ok(0);
}
let tags: Vec<(u32, usize, u32)> = boundary.iter().map(|b| (b.tet, usize::from(b.opposite), b.triangle)).collect();
let kinds = vec![VertexKind::Corner; points.len()];
let mut cx = TetComplex::with_invalid(points, kinds, tets, &tags, &[], points.len(), max_tets.max(tets.len() * 2))?;
// The segments: boundary edges between two different input triangles (never by angle).
// Edges inside one input triangle (between its pieces) may be flipped; the triangle stays
// covered.
let (_, segments) = feature_curves(&cx, -2.0)?;
for ((a, b), c) in segments {
cx.set_curve(a, b, Some(c));
}
for _ in 0..MAX_PASSES {
let targets: Vec<(u32, [u32; 4])> = cx.live_tets().filter(|(_, vs)| needs_repair(points, vs)).collect();
if targets.is_empty() {
break;
}
let mut changed = false;
for (t, vs) in targets {
if !cx.is_live(t) || cx.tet(t)? != vs {
continue;
}
for &(i, j) in &EDGES {
if cx.is_live(t) && cx.tet(t)? == vs && remove_edge_at(&mut cx, t, vs[i], vs[j])? {
changed = true;
break;
}
}
// A flat tetrahedron lying on one input triangle (two of its faces are pieces of
// it): delete it; its two other faces cover the same piece of the triangle.
if cx.is_live(t) && cx.tet(t)? == vs && cx.delete_boundary_tet(t)? {
changed = true;
continue;
}
if cx.is_live(t) && cx.tet(t)? == vs {
for k in 0..4 {
if remove_face(&mut cx, t, k)? {
changed = true;
break;
}
}
}
}
if !changed {
break;
}
}
let c = cx.compact()?;
if c.source.len() != points.len() || c.source.iter().enumerate().any(|(i, &s)| s as usize != i) {
return Err(Error::Failed("repair removed a vertex".into()));
}
*tets = c.tets;
*boundary = c.boundary.into_iter().map(|(tet, k, tag)| BoundaryFace { tet, opposite: k as u8, triangle: tag }).collect();
if boundary.iter().any(|b| b.triangle == NONE) {
return Err(Error::Failed("repair lost a boundary face's triangle".into()));
}
Ok(bad)
}
-126
View File
@@ -1,126 +0,0 @@
//! Rounding the Steiner points to doubles without inverting tetrahedra.
//!
//! The exact tetrahedralization is valid, but a Steiner point rounded to its nearest doubles is
//! off its segment by up to half an ulp per coordinate, which flips a tetrahedron whose volume is
//! below that scale (Diazzi et al. 2023, §5.1, §6.2: the nearly flat ones, typically four Steiner
//! points on nearly parallel boundary edges). For each tetrahedron that is not positive after
//! rounding, each of its Steiner points tries the doubles within [`REACH`] ulps of its rounded
//! coordinates, nearest first, and takes the first with which every tetrahedron around it is
//! positive (exact `orient3d` on the doubles). Surface vertices never move. The moves are a few
//! ulps, so boundary faces stay on their triangles to rounding precision. The number of passes is
//! capped; what is left is for the validity gate to report.
//!
//! This is the interim answer. The paper's own (§5.1) improves the exact mesh by flips that never
//! cross a facet before rounding, which removes most nearly flat tetrahedra instead of nudging
//! their vertices; it belongs with the flips of the quality stage.
use crate::predicates::{Point3, Sign, tet_volume_sign};
use crate::{Error, Result};
/// How far, in ulps per coordinate, a rounded Steiner point may move.
const REACH: i32 = 2;
/// Passes over the tetrahedra that are not positive.
const MAX_PASSES: usize = 4;
fn positive(points: &[[f64; 3]], t: &[u32; 4]) -> Result<bool> {
let p = |v: u32| -> Result<Point3> {
let c = points.get(v as usize).copied().ok_or_else(|| Error::Failed("rounding: vertex id out of range".into()))?;
Point3::try_from(c)
};
Ok(tet_volume_sign(&p(t[0])?, &p(t[1])?, &p(t[2])?, &p(t[3])?) == Sign::Positive)
}
/// `x` moved by `k` ulps.
fn step(mut x: f64, k: i32) -> f64 {
for _ in 0..k.unsigned_abs() {
x = if k > 0 { x.next_up() } else { x.next_down() };
}
x
}
/// Offsets within [`REACH`], nearest (smallest total) first, in a fixed order.
fn offsets() -> Vec<[i32; 3]> {
let r = -REACH..=REACH;
let mut o: Vec<[i32; 3]> = r.clone().flat_map(|a| r.clone().flat_map(move |b| (-REACH..=REACH).map(move |c| [a, b, c]))).collect();
o.retain(|d| d != &[0, 0, 0]);
o.sort_by_key(|d| (d.iter().map(|k| k.abs()).sum::<i32>(), *d));
o
}
/// Move Steiner points (ids `>= surface`) so every tetrahedron is positive where a few-ulp move
/// does it. The number of points moved.
pub(super) fn repair(points: &mut [[f64; 3]], tets: &[[u32; 4]], surface: usize) -> Result<usize> {
let mut bad = Vec::new();
for (i, t) in tets.iter().enumerate() {
if !positive(points, t)? {
bad.push(i);
}
}
if bad.is_empty() {
return Ok(0);
}
let mut around: Vec<Vec<u32>> = vec![Vec::new(); points.len()];
for (i, t) in tets.iter().enumerate() {
for &v in t {
if (v as usize) >= surface
&& let Some(a) = around.get_mut(v as usize)
{
a.push(i as u32);
}
}
}
let offsets = offsets();
let mut moved = vec![false; points.len()];
for _ in 0..MAX_PASSES {
let mut still = Vec::new();
for &i in &bad {
let t = *tets.get(i).ok_or_else(|| Error::Failed("rounding: tetrahedron id".into()))?;
if positive(points, &t)? {
continue;
}
let mut fixed = false;
for v in t.into_iter().filter(|&v| (v as usize) >= surface) {
let Some(origin) = points.get(v as usize).copied() else { continue };
let incident = around.get(v as usize).cloned().unwrap_or_default();
for d in &offsets {
let candidate: [f64; 3] = std::array::from_fn(|k| step(origin[k], d[k]));
if candidate.iter().any(|x| !crate::predicates::is_admissible(*x)) {
continue;
}
if let Some(p) = points.get_mut(v as usize) {
*p = candidate;
}
let mut ok = true;
for &j in &incident {
let u = tets.get(j as usize).ok_or_else(|| Error::Failed("rounding: tetrahedron id".into()))?;
if !positive(points, u)? {
ok = false;
break;
}
}
if ok {
fixed = true;
if let Some(m) = moved.get_mut(v as usize) {
*m = true;
}
break;
}
if let Some(p) = points.get_mut(v as usize) {
*p = origin;
}
}
if fixed {
break;
}
}
if !fixed {
still.push(i);
}
}
if still.is_empty() || still.len() == bad.len() {
break;
}
bad = still;
}
Ok(moved.iter().filter(|m| **m).count())
}
+3 -3
View File
@@ -10,7 +10,7 @@ use crate::vec3::{cross, dot, norm, sub};
/// A closed surface from points and triangles, each triangle oriented outward from `centre`
/// (the shapes here are star-shaped about it).
fn surface(points: &[[f64; 3]], triangles: &[[u32; 3]], centre: [f64; 3]) -> BoundarySurface {
pub(crate) fn surface(points: &[[f64; 3]], triangles: &[[u32; 3]], centre: [f64; 3]) -> BoundarySurface {
let oriented: Vec<[u32; 3]> = triangles
.iter()
.map(|&[a, b, c]| {
@@ -47,7 +47,7 @@ fn check(s: &BoundarySurface, m: &TetMesh) {
}
}
fn cube() -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
pub(crate) fn cube() -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
let p: Vec<[f64; 3]> = (0..8).map(|i| [(i & 1) as f64, (i >> 1 & 1) as f64, (i >> 2 & 1) as f64]).collect();
let quads = [[0, 1, 3, 2], [4, 5, 7, 6], [0, 1, 5, 4], [2, 3, 7, 6], [0, 2, 6, 4], [1, 3, 7, 5]];
let t = quads.iter().flat_map(|q| [[q[0], q[1], q[2]], [q[0], q[2], q[3]]]).collect();
@@ -98,7 +98,7 @@ fn schonhardt_prism_gets_steiner_points_and_conforms() {
}
/// A UV sphere: many cospherical vertices.
fn uv_sphere(rings: usize, sectors: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
pub(crate) fn uv_sphere(rings: usize, sectors: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
let mut p = vec![[0.0, 0.0, 1.0], [0.0, 0.0, -1.0]];
for r in 1..rings {
let th = std::f64::consts::PI * r as f64 / rings as f64;
+2 -2
View File
@@ -37,7 +37,7 @@
mod arrangement;
mod brep;
mod cells;
mod geom;
pub(crate) mod geom;
mod interval;
mod layered;
mod mapped;
@@ -583,7 +583,7 @@ fn finish(cells: &Cells, 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 {
+2
View File
@@ -12,11 +12,13 @@ pub mod cdt;
pub mod delaunay;
pub mod hex;
pub mod join;
pub mod octree;
pub mod planar;
pub mod quad;
pub mod quality;
pub mod size;
pub mod surface;
pub mod tet;
mod vec3;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
+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());
}
}
+6
View File
@@ -296,6 +296,12 @@ impl Bernstein {
Ok((a, b))
}
/// The coefficients of the used slots, in a fixed order shared by every polynomial on the
/// same domain (so the coefficients of a vector-valued polynomial's components line up).
pub fn coefficients(&self) -> Vec<f64> {
self.multis().iter().map(|m| self.get(m)).collect()
}
/// The smallest and largest coefficient: bounds of the polynomial on its domain.
pub fn bounds(&self) -> (f64, f64) {
self.multis().iter().map(|m| self.get(m)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), c| (lo.min(c), hi.max(c)))
+25 -5
View File
@@ -32,7 +32,7 @@ pub(crate) const HEX_CORNERS: [[usize; 3]; 8] = [[0, 0, 0], [1, 0, 0], [1, 1, 0]
/// Hex edges in the order of hex20's mid-side nodes.
pub(crate) const HEX_EDGES: [(usize, usize); 12] = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)];
/// Tet edges in the order of tet10's mid-side nodes.
pub(crate) const TET_EDGES: [(usize, usize); 6] = [(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)];
pub const TET_EDGES: [(usize, usize); 6] = [(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)];
/// A Jacobian determinant is positive when it exceeds this fraction of its largest magnitude.
/// Anything smaller is degenerate to rounding and counts as invalid.
@@ -280,15 +280,35 @@ impl Piece {
}
}
/// Decide the sign of a Jacobian determinant on the whole reference domain.
pub(crate) fn certify(det: Bernstein) -> Result<JacobianCheck> {
/// An upper bound of `|∂x/∂ξ_a|` over the whole element for one Jacobian column (a vector
/// polynomial in Bernstein form): the longest control vector, since the polynomial stays in the
/// convex hull of its control points.
fn column_norm_bound(column: &[Bernstein; 3]) -> f64 {
let [x, y, z] = column.each_ref().map(Bernstein::coefficients);
x.iter().zip(&y).zip(&z).map(|((a, b), c)| (a * a + b * b + c * c).sqrt()).fold(0.0, f64::max)
}
/// The determinant's floor for [`certify`] that proves FieldCraft's validity criterion
/// ([`fieldcraft_model::admissible_jacobian`]) on the whole element: with `N` the product of the
/// columns' norm bounds, `det J(ξ) > 2 · MIN_NORMALIZED_JACOBIAN · N` everywhere gives
/// `det J(ξ) / Π|J_a(ξ)| > MIN_NORMALIZED_JACOBIAN` at every point, so the solvers, which check
/// the same criterion at their integration points, accept every element the gate accepts. The
/// factor 2 covers the rounding of the solvers' own Jacobian evaluation.
pub(crate) fn admissible_floor(columns: &[[Bernstein; 3]; 3]) -> f64 {
let n: f64 = columns.iter().map(column_norm_bound).product();
2.0 * fieldcraft_model::MIN_NORMALIZED_JACOBIAN * n
}
/// Decide whether a Jacobian determinant exceeds `floor` (and [`RELATIVE_TOLERANCE`] of its
/// largest magnitude) on the whole reference domain.
pub(crate) fn certify(det: Bernstein, floor: f64) -> Result<JacobianCheck> {
let (lo, hi) = det.bounds();
let volume = det.integral();
let scale = lo.abs().max(hi.abs());
if !scale.is_finite() || !volume.is_finite() {
if !scale.is_finite() || !volume.is_finite() || !floor.is_finite() {
return Err(Error::Invalid("the element's Jacobian is not a finite number".into()));
}
let tol = RELATIVE_TOLERANCE * scale;
let tol = (RELATIVE_TOLERANCE * scale).max(floor);
let ratio_of = |v: f64| if scale > 0.0 { v / scale } else { 0.0 };
let verts = det.factors().iter().map(|f| (0..=f.dim).map(|k| simplex_vertex(k, 1).map(|x| x as f64)).collect()).collect();
let mut stack = vec![Piece::new(det, verts, 0)];
+17 -7
View File
@@ -14,16 +14,22 @@
//! Inputs are hostile: coordinates must be finite and within [`fieldcraft_model::MAX_COORD`],
//! and every metric has a defined value on degenerate and inverted elements.
//!
//! Linear tetrahedra take their validity from the exact `orient3d` ([`crate::predicates`]); the
//! other kinds from the floating-point Bernstein certificate, which counts a Jacobian within
//! [`jacobian::RELATIVE_TOLERANCE`] of zero as degenerate.
//! Validity is FieldCraft's one criterion, shared with the solvers: an element is admissible when
//! its normalized Jacobian `det J / Π|∂x/∂ξ_a|` exceeds
//! [`fieldcraft_model::MIN_NORMALIZED_JACOBIAN`] ([`fieldcraft_model::admissible_jacobian`]).
//! Linear tetrahedra take their orientation from the exact `orient3d` ([`crate::predicates`]) and
//! must also pass the criterion on the solvers' own Jacobian (the edges of the input coordinates),
//! so the gate and the solvers decide every tet4 alike. The other kinds use the floating-point
//! Bernstein certificate, which proves the criterion on the whole element (and counts a Jacobian
//! within [`jacobian::RELATIVE_TOLERANCE`] of zero as degenerate); the solvers check it only at
//! their integration points, so whatever passes the gate passes the solvers.
mod bernstein;
pub mod jacobian;
mod report;
pub(crate) mod shape;
use fieldcraft_model::{ElementKind, check_coordinates};
use fieldcraft_model::{ElementKind, admissible_jacobian, check_coordinates, tet4_jacobian};
pub use jacobian::{JacobianCheck, Validity};
pub use report::{
@@ -156,15 +162,19 @@ fn checked(kind: ElementKind, xyz: &[[f64; 3]], p: &[[f64; 3]]) -> Result<(Optio
{
let [a, b, c, d]: [[f64; 3]; 4] = corners(p)?;
let volume = shape::det(shape::sub(b, a), shape::sub(c, a), shape::sub(d, a)) / 6.0;
// FieldCraft's one criterion, on the solvers' own Jacobian (edges of the input
// coordinates); the exact sign settles the orientation of the rest.
let admissible = admissible_jacobian(&tet4_jacobian(&corners(xyz)?));
let (validity, ratio) = match sign {
Sign::Positive => (Validity::Valid, 1.0),
Sign::Positive if admissible => (Validity::Valid, 1.0),
Sign::Positive | Sign::Zero => (Validity::Invalid, 0.0),
Sign::Negative => (Validity::Invalid, -1.0),
Sign::Zero => (Validity::Invalid, 0.0),
};
return Ok((None, JacobianCheck { validity, ratio, volume }));
}
let jacobian::Jacobian { columns, det } = jacobian::jacobian(kind, p)?;
Ok((Some(columns), jacobian::certify(det)?))
let floor = jacobian::admissible_floor(&columns);
Ok((Some(columns), jacobian::certify(det, floor)?))
}
/// The exact orientation of a tetrahedron (positive for node 4 on the side of face 1-2-3's
+5 -2
View File
@@ -78,9 +78,12 @@ fn inverted_and_flat_tetrahedra() {
assert_eq!(metric(&q, Metric::AspectRatio), f64::INFINITY);
assert!(q.metrics.iter().all(|(_, v)| !v.is_nan()));
// A sliver with an exactly positive orientation is valid, however thin (exact orient3d).
// A sliver with an exactly positive orientation but a normalized Jacobian below the one
// criterion the solvers share is invalid; a thin one above it is valid.
let sliver = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0.3, 0.3, 1e-30]];
assert_eq!(element_validity(ElementKind::Tet4, &sliver).unwrap().validity, Validity::Valid);
assert_eq!(element_validity(ElementKind::Tet4, &sliver).unwrap().validity, Validity::Invalid);
let thin = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0.3, 0.3, 1e-9]];
assert_eq!(element_validity(ElementKind::Tet4, &thin).unwrap().validity, Validity::Valid);
let below = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0.3, 0.3, -1e-30]];
assert_eq!(element_validity(ElementKind::Tet4, &below).unwrap().validity, Validity::Invalid);
File diff suppressed because it is too large. Load diff
+740
View File
@@ -0,0 +1,740 @@
//! Where boundary vertices may go: projection onto the B-rep faces and the feature curves
//! between them.
//!
//! The quality stage inserts and moves boundary vertices; each one stays on its B-rep face (or
//! feature curve) by projection. [`BoundaryGeometry`] answers the projection. Two sources:
//! - [`AnalyticSurface`]: the exact surface of a face when the geometry kernel knows it (plane,
//! cylinder, cone, sphere, torus), with closed-form closest points;
//! - [`Tessellation`]: the boundary surface's own triangles of that face, the fallback for
//! free-form faces (the projection is then accurate to the tessellation's chordal tolerance).
//!
//! A point on a feature curve between faces `A` and `B` is projected onto the curve's polyline
//! in the tessellation, then refined by alternating projections onto `A` and `B` when both are
//! analytic (von Neumann's alternating projections converge to the intersection for surfaces
//! meeting transversally); the result is kept only when it stays close to the polyline.
use crate::size::SizeField;
use crate::surface::BoundarySurface;
use super::measure::{self, P, add, cross, dot, norm, scale, sub};
/// A face's exact surface. Axes are unit vectors.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AnalyticSurface {
Plane {
normal: P,
point: P,
},
Cylinder {
origin: P,
axis: P,
radius: f64,
},
/// Apex, axis pointing into the cone, half-angle in radians.
Cone {
apex: P,
axis: P,
half_angle: f64,
},
Sphere {
center: P,
radius: f64,
},
Torus {
center: P,
axis: P,
major: f64,
minor: f64,
},
}
fn unit(v: P) -> Option<P> {
let l = norm(v);
(l > 0.0 && l.is_finite()).then(|| scale(v, 1.0 / l))
}
/// `(sin x, cos x)` for `|x| ≤ π` from their Taylor series (to 20 terms, accurate to rounding),
/// so the result is the same on every platform (the C library's `sin` and `cos` are not
/// correctly rounded and differ between platforms).
pub fn sin_cos(x: f64) -> (f64, f64) {
let x = x.clamp(-std::f64::consts::PI, std::f64::consts::PI);
let (mut s, mut c) = (0.0, 0.0);
let (mut ts, mut tc) = (x, 1.0);
for k in 0..20 {
s += ts;
c += tc;
let n = 2.0 * k as f64;
ts *= -x * x / ((n + 2.0) * (n + 3.0));
tc *= -x * x / ((n + 1.0) * (n + 2.0));
}
(s, c)
}
/// Any unit vector perpendicular to `a`.
fn perpendicular(a: P) -> P {
let t = if a[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
unit(cross(a, t)).unwrap_or([0.0, 0.0, 1.0])
}
impl AnalyticSurface {
/// The closest point on the (untrimmed) surface, or `None` where it is not unique (on an
/// axis) or the surface's data is not finite.
pub fn project(&self, p: P) -> Option<P> {
let out = match *self {
AnalyticSurface::Plane { normal, point } => {
let n = unit(normal)?;
sub(p, scale(n, dot(sub(p, point), n)))
}
AnalyticSurface::Cylinder { origin, axis, radius } => {
let a = unit(axis)?;
let d = sub(p, origin);
let foot = add(origin, scale(a, dot(d, a)));
let r = unit(sub(p, foot))?;
add(foot, scale(r, radius))
}
AnalyticSurface::Sphere { center, radius } => add(center, scale(unit(sub(p, center))?, radius)),
AnalyticSurface::Cone { apex, axis, half_angle } => {
let a = unit(axis)?;
let d = sub(p, apex);
let along = dot(d, a);
let radial = sub(d, scale(a, along));
let r = unit(radial).unwrap_or_else(|| perpendicular(a));
// The generator through p's half-plane: direction g = cos·a + sin·r.
let (s, c) = sin_cos(half_angle);
let g = add(scale(a, c), scale(r, s));
let t = dot(d, g).max(0.0);
add(apex, scale(g, t))
}
AnalyticSurface::Torus { center, axis, major, minor } => {
let a = unit(axis)?;
let d = sub(p, center);
let radial = sub(d, scale(a, dot(d, a)));
let r = unit(radial)?;
let ring = add(center, scale(r, major));
add(ring, scale(unit(sub(p, ring))?, minor))
}
};
out.iter().all(|x| x.is_finite()).then_some(out)
}
/// The unit normal (up to sign) at a point on the surface, if defined there.
pub fn normal_at(&self, q: P) -> Option<P> {
let n = match *self {
AnalyticSurface::Plane { normal, .. } => normal,
AnalyticSurface::Cylinder { origin, axis, .. } => {
let a = unit(axis)?;
let d = sub(q, origin);
sub(d, scale(a, dot(d, a)))
}
AnalyticSurface::Sphere { center, .. } => sub(q, center),
AnalyticSurface::Cone { apex, axis, .. } => {
// Normal to both the generator through q and the circle through q.
let a = unit(axis)?;
let g = sub(q, apex);
let tangent = cross(a, g);
cross(g, tangent)
}
AnalyticSurface::Torus { center, axis, major, .. } => {
let a = unit(axis)?;
let d = sub(q, center);
let r = unit(sub(d, scale(a, dot(d, a))))?;
sub(q, add(center, scale(r, major)))
}
};
unit(n)
}
/// The smallest radius of curvature at `p` (a cone's depends on the distance to its axis).
pub fn radius_at(&self, p: P) -> f64 {
match *self {
AnalyticSurface::Cone { apex, axis, half_angle } => {
let Some(a) = unit(axis) else { return f64::INFINITY };
let d = sub(p, apex);
let rho = norm(sub(d, scale(a, dot(d, a))));
// Normal curvature of the circle of radius rho: cos(α)/rho.
rho / sin_cos(half_angle).1.abs().max(1e-12)
}
AnalyticSurface::Plane { .. } => f64::INFINITY,
AnalyticSurface::Cylinder { radius, .. } | AnalyticSurface::Sphere { radius, .. } => radius.abs(),
AnalyticSurface::Torus { minor, .. } => minor.abs(),
}
}
}
/// sin 10°: faces meeting at a smaller angle are treated as tangent along their curve.
const TRANSVERSAL_SIN: f64 = 0.173_648_177_666_930_35;
/// The projections the quality stage needs.
pub trait BoundaryGeometry {
/// The closest point to `p` on B-rep face `face`, near `p` (within a few element sizes).
fn project_face(&self, face: u32, p: P) -> Option<P>;
/// The closest point to `p` on the feature curve between faces `faces` (equal for a sharp
/// edge inside one face).
fn project_curve(&self, faces: [u32; 2], p: P) -> Option<P>;
}
/// A bounding-box tree over the boundary's triangles (or its feature segments) for
/// closest-point queries, filtered by tag.
#[derive(Debug, Clone)]
struct Tree {
nodes: Vec<Node>,
items: Vec<u32>,
}
#[derive(Debug, Clone, Copy)]
struct Node {
lo: P,
hi: P,
/// Children (left; right is left + 1) or a leaf over `items[start..end]`.
left: Option<usize>,
start: usize,
end: usize,
}
const LEAF: usize = 6;
impl Tree {
fn build(boxes: &[(P, P)]) -> Tree {
let mut items: Vec<u32> = (0..boxes.len() as u32).collect();
let empty = Node { lo: [0.0; 3], hi: [0.0; 3], left: 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) = items.get_mut(start..end) else { continue };
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for &i in slice.iter() {
if let Some((a, b)) = boxes.get(i as usize) {
for k in 0..3 {
lo[k] = lo[k].min(a[k]);
hi[k] = hi[k].max(b[k]);
}
}
}
let mut node = Node { lo, hi, left: None, start, end };
if end - start > LEAF {
let ext = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
let axis = if ext[0] >= ext[1] && ext[0] >= ext[2] {
0
} else if ext[1] >= ext[2] {
1
} else {
2
};
let key = |i: &u32| boxes.get(*i as usize).map_or(0.0, |(a, b)| a[axis] + b[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 left = nodes.len();
nodes.push(empty);
nodes.push(empty);
node.left = Some(left);
work.push((left, start, start + mid));
work.push((left + 1, start + mid, end));
}
if let Some(n) = nodes.get_mut(ni) {
*n = node;
}
}
Tree { nodes, items }
}
/// The item nearest to `p` among those `accept` takes, by `distance` (squared, with the
/// point and a fourth value carried along), searching boxes closer than the best so far.
fn nearest4(&self, p: P, accept: &dyn Fn(u32) -> bool, distance: &dyn Fn(u32) -> (f64, [f64; 4])) -> Option<[f64; 4]> {
let box_d2 = |n: &Node| -> f64 {
(0..3)
.map(|k| {
let d = (n.lo[k] - p[k]).max(0.0).max(p[k] - n.hi[k]);
d * d
})
.sum()
};
let mut best: Option<(f64, [f64; 4])> = None;
let mut stack = vec![0usize];
let mut steps = 0usize;
while let Some(ni) = stack.pop() {
steps += 1;
if steps > 4 * self.nodes.len() + 16 {
break;
}
let Some(n) = self.nodes.get(ni) else { continue };
if best.is_some_and(|(d, _)| box_d2(n) > d) {
continue;
}
match n.left {
Some(l) => {
let (a, b) = (self.nodes.get(l), self.nodes.get(l + 1));
let (da, db) = (a.map_or(f64::INFINITY, box_d2), b.map_or(f64::INFINITY, box_d2));
if da <= db {
stack.push(l + 1);
stack.push(l);
} else {
stack.push(l);
stack.push(l + 1);
}
}
None => {
for &i in self.items.get(n.start..n.end).unwrap_or_default() {
if !accept(i) {
continue;
}
let (d, q) = distance(i);
if best.is_none_or(|(b, _)| d < b) {
best = Some((d, q));
}
}
}
}
}
best.map(|(_, q)| q)
}
}
/// The closest point on triangle `abc` (Ericson, *Real-Time Collision Detection*, §5.1.5).
pub fn closest_on_triangle(p: P, a: P, b: P, c: P) -> P {
let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
let (d1, d2) = (dot(ab, ap), dot(ac, ap));
if d1 <= 0.0 && d2 <= 0.0 {
return a;
}
let bp = sub(p, b);
let (d3, d4) = (dot(ab, bp), dot(ac, bp));
if d3 >= 0.0 && d4 <= d3 {
return b;
}
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
let v = d1 / (d1 - d3);
return add(a, scale(ab, v));
}
let cp = sub(p, c);
let (d5, d6) = (dot(ab, cp), dot(ac, cp));
if d6 >= 0.0 && d5 <= d6 {
return c;
}
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
let w = d2 / (d2 - d6);
return add(a, scale(ac, w));
}
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
let w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
return add(b, scale(sub(c, b), w));
}
let den = 1.0 / (va + vb + vc);
let (v, w) = (vb * den, vc * den);
let q = add(a, add(scale(ab, v), scale(ac, w)));
if q.iter().all(|x| x.is_finite()) { q } else { a }
}
/// The closest point on segment `ab`.
pub fn closest_on_segment(p: P, a: P, b: P) -> P {
let ab = sub(b, a);
let l2 = dot(ab, ab);
if !(l2 > 0.0) {
return a;
}
let t = (dot(sub(p, a), ab) / l2).clamp(0.0, 1.0);
measure::lerp(a, b, t)
}
/// The boundary surface as projection target, optionally with exact surfaces per face.
#[derive(Debug, Clone)]
pub struct Tessellation {
points: Vec<P>,
triangles: Vec<[u32; 3]>,
tri_face: Vec<u32>,
tri_tree: Tree,
/// Feature segments: vertices and the (sorted) face pair.
segments: Vec<([u32; 2], [u32; 2])>,
seg_tree: Tree,
analytic: Vec<Option<AnalyticSurface>>,
}
impl Tessellation {
/// The projection target of a boundary surface; `analytic[face]` gives a face's exact
/// surface when known.
pub fn new(surface: &BoundarySurface, analytic: Vec<Option<AnalyticSurface>>) -> Tessellation {
let points = surface.points().to_vec();
let triangles = surface.triangles().to_vec();
let tri_face = surface.tri_face().to_vec();
let pt = |v: u32| points.get(v as usize).copied().unwrap_or([0.0; 3]);
let boxes: Vec<(P, P)> = triangles
.iter()
.map(|t| {
let (a, b, c) = (pt(t[0]), pt(t[1]), pt(t[2]));
([0, 1, 2].map(|k| a[k].min(b[k]).min(c[k])), [0, 1, 2].map(|k| a[k].max(b[k]).max(c[k])))
})
.collect();
let tri_tree = Tree::build(&boxes);
let face_of = |t: u32| tri_face.get(t as usize).copied().unwrap_or(u32::MAX);
let segments: Vec<([u32; 2], [u32; 2])> = surface
.feature_edges()
.iter()
.map(|e| {
let (fa, fb) = (face_of(e.triangles[0]), face_of(e.triangles[1]));
(e.vertices, [fa.min(fb), fa.max(fb)])
})
.collect();
let seg_boxes: Vec<(P, P)> = segments
.iter()
.map(|(v, _)| {
let (a, b) = (pt(v[0]), pt(v[1]));
([0, 1, 2].map(|k| a[k].min(b[k])), [0, 1, 2].map(|k| a[k].max(b[k])))
})
.collect();
let seg_tree = Tree::build(&seg_boxes);
Tessellation { points, triangles, tri_face, tri_tree, segments, seg_tree, analytic }
}
fn pt(&self, v: u32) -> P {
self.points.get(v as usize).copied().unwrap_or([0.0; 3])
}
/// The exact surface of a face, if known.
pub fn analytic(&self, face: u32) -> Option<&AnalyticSurface> {
self.analytic.get(face as usize).and_then(|a| a.as_ref())
}
/// The closest point on the face's triangles, and the longest edge of the triangle it is
/// on (the local scale of the tessellation).
fn on_tessellation(&self, face: u32, p: P) -> Option<(P, f64)> {
let accept = |t: u32| self.tri_face.get(t as usize) == Some(&face);
let dist = |t: u32| {
let tri = self.triangles.get(t as usize).copied().unwrap_or([0; 3]);
let (a, b, c) = (self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2]));
let q = closest_on_triangle(p, a, b, c);
let d = sub(q, p);
let longest = measure::dist(a, b).max(measure::dist(b, c)).max(measure::dist(c, a));
(dot(d, d), [q[0], q[1], q[2], longest])
};
self.tri_tree.nearest4(p, &accept, &dist).map(|r| ([r[0], r[1], r[2]], r[3]))
}
/// The closest point on the curve's polyline, and the length of the segment it is on.
fn on_polyline(&self, faces: [u32; 2], p: P) -> Option<(P, f64)> {
let key = [faces[0].min(faces[1]), faces[0].max(faces[1])];
let accept = |s: u32| self.segments.get(s as usize).is_some_and(|(_, f)| *f == key);
let dist = |s: u32| {
let v = self.segments.get(s as usize).map_or([0; 2], |(v, _)| *v);
let (a, b) = (self.pt(v[0]), self.pt(v[1]));
let q = closest_on_segment(p, a, b);
let d = sub(q, p);
(dot(d, d), [q[0], q[1], q[2], measure::dist(a, b)])
};
self.seg_tree.nearest4(p, &accept, &dist).map(|r| ([r[0], r[1], r[2]], r[3]))
}
}
impl BoundaryGeometry for Tessellation {
fn project_face(&self, face: u32, p: P) -> Option<P> {
let (near, scale_len) = self.on_tessellation(face, p)?;
match self.analytic(face) {
// The exact surface's closest point, kept when it is near the tessellation's: the
// tessellation deviates from its surface by far less than half its triangles' size,
// so a point further away is a projection onto another part of the surface (the far
// side of a cylinder) and the tessellation's point is used.
Some(s) => {
let q = s.project(p)?;
let tol = (0.5 * scale_len).max(2.0 * measure::dist(p, near));
Some(if measure::dist(q, near) <= tol { q } else { near })
}
None => Some(near),
}
}
fn project_curve(&self, faces: [u32; 2], p: P) -> Option<P> {
let (near, seg_len) = self.on_polyline(faces, p)?;
let (Some(a), Some(b)) = (self.analytic(faces[0]), self.analytic(faces[1])) else { return Some(near) };
if faces[0] == faces[1] {
return Some(near);
}
// Faces meeting tangentially (a blend's edge) define their curve badly: alternating
// projections crawl along it. There the polyline lies on both faces to the
// tessellation's accuracy and is kept.
let (Some(na), Some(nb)) = (a.normal_at(near), b.normal_at(near)) else { return Some(near) };
if norm(cross(na, nb)) < TRANSVERSAL_SIN {
return Some(near);
}
let mut q = near;
for _ in 0..40 {
let Some(qa) = a.project(q) else { return Some(near) };
let Some(qb) = b.project(qa) else { return Some(near) };
let step = measure::dist(qb, q);
q = qb;
if step <= 1e-13 * (1.0 + norm(q)) {
break;
}
}
// Both surfaces must agree at the result, and it must stay near the polyline.
let gap = a.project(q).map_or(f64::INFINITY, |qa| measure::dist(qa, q));
let tol = (0.5 * seg_len).max(2.0 * measure::dist(p, near));
if gap <= 1e-9 * (1.0 + norm(q)) && measure::dist(q, near) <= tol { Some(q) } else { Some(near) }
}
}
impl Tessellation {
/// The local thickness at each vertex: the distance to the nearest triangle facing it from
/// across the solid (a triangle that does not touch the vertex and whose outward normal
/// points back against the vertex's, by more than 120°). Infinite where nothing faces it.
/// Thin walls are this thick; elements larger than it would have to be flat.
pub fn thickness(&self) -> Vec<f64> {
let n = self.points.len();
let mut normal = vec![[0.0; 3]; n];
let tri_normal: Vec<P> = self
.triangles
.iter()
.map(|t| {
let (a, b, c) = (self.pt(t[0]), self.pt(t[1]), self.pt(t[2]));
cross(sub(b, a), sub(c, a))
})
.collect();
for (t, m) in self.triangles.iter().zip(&tri_normal) {
for &v in t {
if let Some(x) = normal.get_mut(v as usize) {
*x = add(*x, *m);
}
}
}
(0..n as u32)
.map(|v| {
let p = self.pt(v);
let Some(nv) = normal.get(v as usize).and_then(|m| unit(*m)) else { return f64::INFINITY };
let accept = |t: u32| {
let Some(tri) = self.triangles.get(t as usize) else { return false };
!tri.contains(&v) && tri_normal.get(t as usize).and_then(|m| unit(*m)).is_some_and(|m| dot(m, nv) < -0.5)
};
let dist = |t: u32| {
let tri = self.triangles.get(t as usize).copied().unwrap_or([0; 3]);
let q = closest_on_triangle(p, self.pt(tri[0]), self.pt(tri[1]), self.pt(tri[2]));
let d = sub(q, p);
(dot(d, d), [q[0], q[1], q[2], 0.0])
};
// Only what lies behind the surface (inside the solid) counts.
self.tri_tree.nearest4(p, &accept, &dist).map_or(f64::INFINITY, |r| {
let q = [r[0], r[1], r[2]];
if dot(sub(q, p), nv) < 0.0 { measure::dist(q, p) } else { f64::INFINITY }
})
})
.collect()
}
}
/// The curvature of the boundary, sampled at its vertices: for each edge inside one B-rep face,
/// the turn of the normal between its two triangles over the distance between their centroids
/// (a discrete estimate of the normal curvature across the edge), or the exact surface's radius.
/// Returns the smallest radius of curvature at each surface vertex (infinite where flat).
pub fn curvature_radii(surface: &BoundarySurface, geometry: &Tessellation) -> Vec<f64> {
let pts = surface.points();
let tris = surface.triangles();
let faces = surface.tri_face();
let mut radius = vec![f64::INFINITY; pts.len()];
let pt = |v: u32| pts.get(v as usize).copied().unwrap_or([0.0; 3]);
let centroid = |t: &[u32; 3]| scale(add(add(pt(t[0]), pt(t[1])), pt(t[2])), 1.0 / 3.0);
for (i, (t, nb)) in tris.iter().zip(surface.neighbors()).enumerate() {
let face = faces.get(i).copied().unwrap_or(u32::MAX);
if let Some(s) = geometry.analytic(face) {
for &v in t {
if let Some(r) = radius.get_mut(v as usize) {
*r = r.min(s.radius_at(pt(v)));
}
}
continue;
}
let n = measure::triangle_normal(pt(t[0]), pt(t[1]), pt(t[2]));
for (e, &j) in nb.iter().enumerate() {
let Some(u) = tris.get(j as usize) else { continue };
if j as usize <= i || faces.get(j as usize) != Some(&face) {
continue;
}
let m = measure::triangle_normal(pt(u[0]), pt(u[1]), pt(u[2]));
// The chord between the unit normals: the turn angle to second order, and only
// correctly rounded operations (the same on every platform).
let turn = norm(sub(n, m));
let d = measure::dist(centroid(t), centroid(u));
if !(turn > 1e-6) || !(d > 0.0) {
continue;
}
let r = d / turn;
for v in [t[e], t[(e + 1) % 3]] {
if let Some(x) = radius.get_mut(v as usize) {
*x = x.min(r);
}
}
}
}
radius
}
/// A size field from a global size and the boundary's curvature, graded: the size at `x` is
/// `min(global, min_i (h_i + g |x − x_i|))` over curvature samples `h_i = θ r_i` (θ radians per
/// element along a curve of radius `r_i`), stored on a background grid relaxed by fast sweeping
/// and interpolated trilinearly.
#[derive(Debug, Clone)]
pub struct GradedSize {
global: f64,
lo: P,
cell: f64,
dims: [usize; 3],
values: Vec<f64>,
}
/// The most nodes of the background grid.
pub const MAX_GRID_NODES: usize = 1 << 21;
impl GradedSize {
/// `samples`: points with their local size. `grading`: the largest change of size per unit
/// length.
pub fn new(global: f64, samples: &[(P, f64)], lo: P, hi: P, grading: f64) -> GradedSize {
let diag = measure::dist(lo, hi).max(f64::MIN_POSITIVE);
let finest = samples.iter().map(|s| s.1).fold(global, f64::min).max(diag * 1e-4);
let mut cell = finest.max(diag / 64.0) * 0.5;
let dims_for = |c: f64| [0, 1, 2].map(|k| (((hi[k] - lo[k]) / c).ceil().max(1.0) as usize) + 2);
let mut dims = dims_for(cell);
while dims.iter().product::<usize>() > MAX_GRID_NODES {
cell *= 1.25;
dims = dims_for(cell);
}
let lo = [lo[0] - cell, lo[1] - cell, lo[2] - cell];
let n = dims.iter().product::<usize>();
let mut values = vec![global; n];
let idx = |i: usize, j: usize, k: usize| (k * dims[1] + j) * dims[0] + i;
let g = grading.max(0.0);
// Seed: each sample's cell corners.
for &(x, h) in samples {
if !(h > 0.0 && h.is_finite()) {
continue;
}
let c = [0, 1, 2].map(|k| ((x[k] - lo[k]) / cell).floor().max(0.0) as usize);
for dk in 0..2 {
for dj in 0..2 {
for di in 0..2 {
let (i, j, k) = (c[0] + di, c[1] + dj, c[2] + dk);
if i >= dims[0] || j >= dims[1] || k >= dims[2] {
continue;
}
let node = [lo[0] + i as f64 * cell, lo[1] + j as f64 * cell, lo[2] + k as f64 * cell];
let v = h + g * measure::dist(node, x);
if let Some(s) = values.get_mut(idx(i, j, k)) {
*s = s.min(v);
}
}
}
}
}
// Fast sweeping: eight sweep orders, each relaxing against the three axis neighbours
// behind the sweep direction (and diagonals through them over passes).
if g > 0.0 {
for _ in 0..2 {
for dir in 0..8 {
let rev = [dir & 1 != 0, dir & 2 != 0, dir & 4 != 0];
for kk in 0..dims[2] {
let k = if rev[2] { dims[2] - 1 - kk } else { kk };
for jj in 0..dims[1] {
let j = if rev[1] { dims[1] - 1 - jj } else { jj };
for ii in 0..dims[0] {
let i = if rev[0] { dims[0] - 1 - ii } else { ii };
let mut best = values.get(idx(i, j, k)).copied().unwrap_or(global);
let back = |x: usize, r: bool, n: usize| -> Option<usize> {
if r { (x + 1 < n).then_some(x + 1) } else { x.checked_sub(1) }
};
if let Some(pi) = back(i, rev[0], dims[0]) {
best = best.min(values.get(idx(pi, j, k)).copied().unwrap_or(global) + g * cell);
}
if let Some(pj) = back(j, rev[1], dims[1]) {
best = best.min(values.get(idx(i, pj, k)).copied().unwrap_or(global) + g * cell);
}
if let Some(pk) = back(k, rev[2], dims[2]) {
best = best.min(values.get(idx(i, j, pk)).copied().unwrap_or(global) + g * cell);
}
if let Some(s) = values.get_mut(idx(i, j, k)) {
*s = best;
}
}
}
}
}
}
}
GradedSize { global, lo, cell, dims, values }
}
fn at(&self, i: usize, j: usize, k: usize) -> f64 {
let i = i.min(self.dims[0] - 1);
let j = j.min(self.dims[1] - 1);
let k = k.min(self.dims[2] - 1);
self.values.get((k * self.dims[1] + j) * self.dims[0] + i).copied().unwrap_or(self.global)
}
}
impl SizeField for GradedSize {
fn size(&self, p: [f64; 3]) -> f64 {
let f = [0, 1, 2].map(|k| ((p[k] - self.lo[k]) / self.cell).clamp(0.0, (self.dims[k] - 1) as f64));
if f.iter().any(|x| !x.is_finite()) {
return self.global;
}
let c = f.map(|x| (x.floor() as usize).min(usize::MAX - 1));
let t = [f[0] - c[0] as f64, f[1] - c[1] as f64, f[2] - c[2] as f64];
let mut h = 0.0;
for dk in 0..2 {
for dj in 0..2 {
for di in 0..2 {
let w = (if di == 1 { t[0] } else { 1.0 - t[0] })
* (if dj == 1 { t[1] } else { 1.0 - t[1] })
* (if dk == 1 { t[2] } else { 1.0 - t[2] });
h += w * self.at(c[0] + di, c[1] + dj, c[2] + dk);
}
}
}
if h > 0.0 && h.is_finite() { h.min(self.global) } else { self.global }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn analytic_projections() {
let cyl = AnalyticSurface::Cylinder { origin: [0.0; 3], axis: [0.0, 0.0, 2.0], radius: 3.0 };
let q = cyl.project([1.0, 1.0, 5.0]).unwrap();
assert!((norm([q[0], q[1], 0.0]) - 3.0).abs() < 1e-12 && (q[2] - 5.0).abs() < 1e-12);
let s = AnalyticSurface::Sphere { center: [1.0, 0.0, 0.0], radius: 2.0 };
assert!((measure::dist(s.project([5.0, 0.0, 0.0]).unwrap(), [3.0, 0.0, 0.0])) < 1e-12);
let pl = AnalyticSurface::Plane { normal: [0.0, 0.0, 1.0], point: [0.0, 0.0, 2.0] };
assert_eq!(pl.project([1.0, 2.0, 7.0]), Some([1.0, 2.0, 2.0]));
let t = AnalyticSurface::Torus { center: [0.0; 3], axis: [0.0, 0.0, 1.0], major: 5.0, minor: 1.0 };
let q = t.project([7.0, 0.0, 0.0]).unwrap();
assert!(measure::dist(q, [6.0, 0.0, 0.0]) < 1e-12);
let cone = AnalyticSurface::Cone { apex: [0.0; 3], axis: [0.0, 0.0, 1.0], half_angle: std::f64::consts::FRAC_PI_4 };
let q = cone.project([2.0, 0.0, 0.0]).unwrap();
assert!((q[0] - q[2]).abs() < 1e-12 && (q[0] - 1.0).abs() < 1e-12, "{q:?}");
assert!(cyl.project([0.0, 0.0, 1.0]).is_none());
for x in [-3.0, -1.0, 0.0, 0.3, 1.5, 3.1] {
let (s, c) = sin_cos(x);
assert!((s - f64::sin(x)).abs() < 1e-15 && (c - f64::cos(x)).abs() < 1e-15, "{x}");
}
}
#[test]
fn closest_points() {
let (a, b, c) = ([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
assert!(measure::dist(closest_on_triangle([0.2, 0.2, 3.0], a, b, c), [0.2, 0.2, 0.0]) < 1e-15);
assert_eq!(closest_on_triangle([-1.0, -1.0, 0.0], a, b, c), a);
let q = closest_on_triangle([1.0, 1.0, 0.0], a, b, c);
assert!(measure::dist(q, [0.5, 0.5, 0.0]) < 1e-15);
assert_eq!(closest_on_segment([0.5, 2.0, 0.0], a, b), [0.5, 0.0, 0.0]);
}
#[test]
fn graded_size_grows_away_from_samples() {
let f = GradedSize::new(10.0, &[([0.0; 3], 1.0)], [0.0; 3], [20.0, 20.0, 20.0], 0.5);
let near = f.size([0.0; 3]);
let mid = f.size([4.0, 0.0, 0.0]);
let far = f.size([20.0, 20.0, 20.0]);
assert!((1.0..1.6).contains(&near), "{near}");
assert!((mid - 3.0).abs() < 0.8, "{mid}");
assert_eq!(far, 10.0);
assert!(f.size([f64::NAN, 0.0, 0.0]) > 0.0);
}
}
+501
View File
@@ -0,0 +1,501 @@
//! Aggressive mesh improvement: the schedule of Klingner and Shewchuk (IMR 2007, §4.2,
//! Figure 4) over smoothing, topological transformations and vertex insertion, monotone in the
//! worst quality (the minimum dihedral sine).
//!
//! Each pass smooths; if that is not enough progress, a topological pass (edge removal,
//! multi-face removal, and deletion of flat tetrahedra lying on one B-rep face) follows; if still
//! not enough, an insertion pass tries new vertices in the worst tetrahedra (a boundary face's
//! barycentre first, then the tetrahedron's, then the midpoints of its boundary edges). An
//! insertion is a composite operation: the vertex goes in with a Delaunay cavity, is smoothed,
//! and topological passes and smoothing run around it; if the worst of the tetrahedra it made
//! or changed is not better than the worst of those it removed, everything is rolled back
//! ([`super::complex::Journal`]). Progress is judged on the worst quality and seven thresholded
//! means (§4.2); the schedule stops after three passes without progress, or at its pass and
//! insertion caps (reported).
//!
//! The paper chooses the inserted vertex's cavity by an optimal graph cut (§3.4); this
//! implementation uses the Delaunay cavity followed by the same smoothing and topological
//! repair, which the paper's own composite operation relies on to make insertion pay off.
use crate::Result;
use crate::predicates::Point3;
use super::complex::{EDGES, IdMap, NONE, Step, TetComplex, VertexKind, Walk, face_toward, sorted4};
use super::measure::{self, P};
use super::ops::{self, Site, remove_edge_at, remove_face, tet_quality};
use super::refine::{Context, MAX_CAVITY};
use super::smooth::smooth_vertex;
/// Settings of the schedule.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ImproveOptions {
pub max_passes: usize,
pub max_insertions: usize,
/// Tetrahedra at or above this quality are left alone by the topological and smoothing
/// passes (default sin 40°, the paper's desperation threshold: a mesh whose tetrahedra all
/// have dihedral angles between 40° and 140° needs nothing more).
pub good: f64,
/// Insertion runs every pass while the worst quality is below this (sin 30°: dihedral
/// angles between 30° and 150°).
pub target: f64,
}
impl Default for ImproveOptions {
fn default() -> Self {
ImproveOptions { max_passes: 10, max_insertions: 50_000, good: SIN_40, target: 0.5 }
}
}
/// What the schedule did.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ImproveStats {
pub passes: usize,
pub smoothed: usize,
pub topological: usize,
pub inserted: usize,
/// The worst quality (smallest dihedral sine) after each pass.
pub worst: Vec<f64>,
pub stopped: Option<String>,
}
/// sin 1°, 5°, 10°, 15°, 25°, 35°, 45° (the thresholds of §4.2), as literals so they are the same
/// everywhere.
pub const SIN_DEG: [f64; 7] = [
0.017_452_406_437_283_51,
0.087_155_742_747_658_17,
0.173_648_177_666_930_33,
0.258_819_045_102_520_74,
0.422_618_261_740_699_44,
0.573_576_436_351_046,
std::f64::consts::FRAC_1_SQRT_2,
];
/// Most tetrahedra one insertion pass tries (the worst first).
pub const MAX_INSERTION_LIST: usize = 2000;
/// sin 40°.
pub const SIN_40: f64 = 0.642_787_609_686_539_3;
/// The worst quality and the seven thresholded means.
fn indicators(cx: &TetComplex) -> Result<[f64; 8]> {
let mut out = [0.0; 8];
out[0] = f64::INFINITY;
let mut n = 0usize;
for (_, vs) in cx.live_tets() {
let q = tet_quality(cx, vs)?;
out[0] = out[0].min(q);
for (i, s) in SIN_DEG.iter().enumerate() {
out[i + 1] += q.min(*s);
}
n += 1;
}
for m in out.iter_mut().skip(1) {
*m /= n.max(1) as f64;
}
Ok(out)
}
fn progressed(now: &[f64; 8], before: &[f64; 8]) -> bool {
now[0] > before[0] || now.iter().zip(before).skip(1).any(|(a, b)| *a >= b + 1e-4)
}
/// Smooth every movable vertex of a tetrahedron below `good`. Returns the moves.
fn smoothing_pass(cx: &mut TetComplex, ctx: &Context, good: f64) -> Result<usize> {
let mut want = vec![false; cx.point_count()];
for (_, vs) in cx.live_tets() {
if tet_quality(cx, vs)? < good {
for v in vs {
if let Some(w) = want.get_mut(v as usize) {
*w = true;
}
}
}
}
let mut moved = 0;
for (v, w) in want.iter().enumerate() {
if *w && smooth_vertex(cx, ctx, v as u32)? {
moved += 1;
}
}
Ok(moved)
}
/// Delete a tetrahedron capping two boundary faces of one B-rep face that fold by at most 20°
/// ([`ops::BOUNDARY_FLIP_COS`]): its dihedral angle at their shared edge is at least 160°, and
/// its two other faces cover the same piece of surface.
fn delete_flat(cx: &mut TetComplex, t: u32) -> Result<bool> {
let vs = cx.tet(t)?;
let (ns, tags) = (cx.adj(t)?, cx.tags(t)?);
let bnd: Vec<usize> = (0..4).filter(|&k| ns[k] == NONE).collect();
let &[i, j] = bnd.as_slice() else { return Ok(false) };
if tags[i] != tags[j] {
return Ok(false);
}
let normal = |k: usize| -> Result<P> {
let f = face_toward(vs, k);
Ok(measure::triangle_normal(cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?))
};
// The inward normals of the two faces of such a cap point nearly the same way.
let (a, b) = (normal(i)?, normal(j)?);
if !(measure::dot(a, b) >= ops::BOUNDARY_FLIP_COS) {
return Ok(false);
}
cx.delete_boundary_tet(t)
}
/// A flat tetrahedron spanning two boundary faces of different B-rep faces that meet at a
/// nearly flat crease (two faces tangent along their curve): its two apexes lie almost on the
/// crease, so no flip or move fixes it. Collapse an apex face vertex onto an end of the crease
/// edge ([`ops::collapse`]), which takes the tetrahedron away.
fn collapse_into_crease(cx: &mut TetComplex, t: u32) -> Result<bool> {
let vs = cx.tet(t)?;
if tet_quality(cx, vs)? > 0.1 {
return Ok(false);
}
let (ns, tags) = (cx.adj(t)?, cx.tags(t)?);
let bnd: Vec<usize> = (0..4).filter(|&k| ns[k] == NONE).collect();
let &[i, j] = bnd.as_slice() else { return Ok(false) };
if tags[i] == tags[j] {
return Ok(false);
}
// The faces opposite i and j share the edge of the two other vertices; vs[i] and vs[j] are
// the apexes (vs[i] lies on face j, vs[j] on face i).
let edge: Vec<u32> = (0..4).filter(|&k| k != i && k != j).map(|k| vs[k]).collect();
let (ex, longest) = {
let x = cx.coords(vs)?;
(edge.clone(), measure::edge_range(&x).1)
};
for apex in [vs[i], vs[j]] {
if !matches!(cx.kind(apex), VertexKind::Face(_)) {
continue;
}
for &e in &ex {
if ops::collapse(cx, apex, e, 2.0 * longest, 0.0)? {
return Ok(true);
}
}
}
Ok(false)
}
/// Try to remove each tetrahedron of `list`: its edges, then its faces. Returns the changes.
pub fn topological_pass(cx: &mut TetComplex, list: &[u32]) -> Result<usize> {
let mut changes = 0;
for &t in list {
if !cx.is_live(t) {
continue;
}
let vs = cx.tet(t)?;
if delete_flat(cx, t)? || collapse_into_crease(cx, t)? {
changes += 1;
continue;
}
let mut gone = false;
for &(i, j) in &EDGES {
if remove_edge_at(cx, t, vs[i], vs[j])? {
gone = true;
break;
}
}
if gone {
changes += 1;
continue;
}
for k in 0..4 {
if remove_face(cx, t, k)? {
changes += 1;
break;
}
}
}
Ok(changes)
}
fn below(cx: &TetComplex, good: f64) -> Result<Vec<u32>> {
let mut out = Vec::new();
for (t, vs) in cx.live_tets() {
if tet_quality(cx, vs)? < good {
out.push(t);
}
}
Ok(out)
}
/// The quality changes since journal step `len`: the worst quality of the tetrahedra removed
/// (at their coordinates before) and of those made or moved (now).
fn change_quality(cx: &mut TetComplex, len: usize) -> Result<(f64, f64)> {
let steps: Vec<Step> = cx.journal.steps.get(len..).unwrap_or_default().to_vec();
// Earliest position of each moved vertex.
let mut first: IdMap<u32, Point3> = IdMap::default();
for s in &steps {
if let Step::Moved { v, from } = s {
first.entry(*v).or_insert(*from);
}
}
let old_xyz = |cx: &TetComplex, v: u32| -> Result<P> { first.get(&v).map_or_else(|| cx.xyz(v), |p| Ok(p.coords())) };
// Net removed and added tetrahedra by sorted vertices.
let mut net: IdMap<[u32; 4], (i32, [u32; 4])> = IdMap::default();
let key = sorted4;
for s in &steps {
match s {
Step::Replaced { removed, added, .. } => {
for r in removed {
net.entry(key(r.vs)).or_insert((0, r.vs)).0 -= 1;
}
for &vs in added {
net.entry(key(vs)).or_insert((0, vs)).0 += 1;
}
}
Step::Deleted { removed, .. } => net.entry(key(removed.vs)).or_insert((0, removed.vs)).0 -= 1,
_ => {}
}
}
let (mut before, mut after) = (f64::INFINITY, f64::INFINITY);
let mut keys: Vec<[u32; 4]> = net.keys().copied().collect();
keys.sort_unstable();
for k in &keys {
let Some(&(c, vs)) = net.get(k) else { continue };
if c < 0 {
let x = [old_xyz(cx, vs[0])?, old_xyz(cx, vs[1])?, old_xyz(cx, vs[2])?, old_xyz(cx, vs[3])?];
before = before.min(measure::quality(&x));
}
}
// The tetrahedra made, and those around moved vertices.
for k in &keys {
if let Some(&(c, vs)) = net.get(k)
&& c > 0
{
after = after.min(tet_quality(cx, vs)?);
}
}
let mut moved: Vec<u32> = first.keys().copied().collect();
moved.sort_unstable();
for v in moved {
if !cx.is_used(v) {
continue;
}
for t in cx.star(v)? {
let vs = cx.tet(t)?;
if net.get(&key(vs)).is_some_and(|e| e.0 > 0) {
continue;
}
after = after.min(tet_quality(cx, vs)?);
let x = [old_xyz(cx, vs[0])?, old_xyz(cx, vs[1])?, old_xyz(cx, vs[2])?, old_xyz(cx, vs[3])?];
before = before.min(measure::quality(&x));
}
}
Ok((before, after))
}
/// The composite insertion of §3.5 (see the module docs). Returns whether it was kept.
fn attempt_insert(cx: &mut TetComplex, ctx: &Context, p: Point3, kind: VertexKind, site: Site) -> Result<bool> {
let was = cx.journal.recording;
cx.journal.recording = true;
let len = cx.journal.steps.len();
let kept = (|| -> Result<bool> {
let Some(v) = ops::insert_delaunay(cx, p, kind, site, MAX_CAVITY)? else { return Ok(false) };
for _ in 0..4 {
smooth_vertex(cx, ctx, v)?;
if !cx.is_used(v) {
break;
}
let star = cx.star(v)?;
if topological_pass(cx, &star)? == 0 {
break;
}
}
if cx.is_used(v) {
let mut around: Vec<u32> = cx.neighbours(v)?;
around.push(v);
for u in around {
smooth_vertex(cx, ctx, u)?;
}
}
let (before, after) = change_quality(cx, len)?;
Ok(after > before && after.is_finite())
})();
let keep = match kept {
Ok(k) => k,
Err(e) => {
cx.rollback(len)?;
cx.journal.recording = was;
return Err(e);
}
};
if !keep {
cx.rollback(len)?;
}
if !was {
cx.journal.steps.truncate(len);
}
cx.journal.recording = was;
Ok(keep)
}
/// Try inserting a vertex in each tetrahedron of `list` (§4.2). Returns the insertions kept.
fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usize, good: f64) -> Result<usize> {
let mut kept = 0;
for &t in list {
if kept >= budget {
break;
}
if !cx.is_live(t) {
continue;
}
let vs = cx.tet(t)?;
if tet_quality(cx, vs)? >= good {
continue;
}
let ns = cx.adj(t)?;
let tags = cx.tags(t)?;
let mut done = false;
// A tetrahedron lying on two boundary faces that meet at a nearly flat edge (two faces
// that are tangent along their curve) is flat however its vertices move: the edge needs
// a second tetrahedron. A vertex inside, off the edge's midpoint along the inward
// normal, splits the edge's dihedral angle.
let bnd: Vec<usize> = (0..4).filter(|&k| ns[k] == NONE).collect();
if let &[i, j] = bnd.as_slice() {
let edge: Vec<u32> = (0..4).filter(|&k| k != i && k != j).map(|k| vs[k]).collect();
if let &[a, b] = edge.as_slice() {
let n = |k: usize| -> Result<P> {
let f = face_toward(vs, k);
Ok(measure::triangle_normal(cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?))
};
let inward = measure::add(n(i)?, n(j)?);
let len = measure::norm(inward);
let (pa, pb) = (cx.xyz(a)?, cx.xyz(b)?);
let l = measure::dist(pa, pb);
// Unit inward normals within 25° of each other: |n_i + n_j| > 2 cos 12.5°.
if len > 1.952 {
let target = measure::add(measure::mid(pa, pb), measure::scale(inward, 0.4 * l / len));
if let Ok(p) = Point3::try_from(target)
&& let Some(Walk::Inside(at)) = cx.walk(t, &p, 1 << 12)?
&& attempt_insert(cx, ctx, p, VertexKind::Interior, Site::Interior(at))?
{
done = true;
}
}
}
}
// Boundary faces' barycentres.
for k in 0..4 {
if done || ns[k] != NONE || tags[k] == NONE {
continue;
}
let f = face_toward(vs, k);
let g = measure::scale(measure::add(measure::add(cx.xyz(f[0])?, cx.xyz(f[1])?), cx.xyz(f[2])?), 1.0 / 3.0);
let kind = VertexKind::Face(tags[k]);
if let Some(p) = ctx.place(kind, g)
&& cx.is_live(t)
&& attempt_insert(cx, ctx, p, kind, Site::Face(t, k))?
{
done = true;
}
}
// The barycentre.
if !done && cx.is_live(t) && cx.tet(t)? == vs {
let g = measure::centroid(&cx.coords(vs)?);
if let Ok(p) = Point3::try_from(g)
&& attempt_insert(cx, ctx, p, VertexKind::Interior, Site::Interior(t))?
{
done = true;
}
}
// Boundary edges' midpoints.
if !done && cx.is_live(t) && cx.tet(t)? == vs {
for &(i, j) in &EDGES {
let (a, b) = (vs[i], vs[j]);
let Some(ring) = cx.edge_ring(a, b)? else { continue };
if ring.closed {
continue;
}
let tag = ring
.tets
.first()
.and_then(|&r| {
let rv = cx.tet(r).ok()?;
let rt = cx.tags(r).ok()?;
let rn = cx.adj(r).ok()?;
(0..4).find(|&k| rn[k] == NONE && face_toward(rv, k).contains(&a) && face_toward(rv, k).contains(&b)).map(|k| rt[k])
})
.unwrap_or(NONE);
let kind = match cx.curve(a, b) {
Some(c) => VertexKind::Curve(c),
None if tag != NONE => VertexKind::Face(tag),
None => continue,
};
let m = measure::mid(cx.xyz(a)?, cx.xyz(b)?);
if let Some(p) = ctx.place(kind, m)
&& attempt_insert(cx, ctx, p, kind, Site::Edge(a, b))?
{
done = true;
break;
}
if !cx.is_live(t) {
break;
}
}
}
kept += usize::from(done);
}
Ok(kept)
}
/// The improvement schedule (see the module docs). Each pass smooths and runs a topological
/// pass over the tetrahedra below the threshold, then an insertion pass when the worst
/// tetrahedron did not improve; it stops after three passes without progress (§4.2).
pub fn improve(cx: &mut TetComplex, ctx: &Context, o: &ImproveOptions) -> Result<ImproveStats> {
let mut stats = ImproveStats::default();
stats.smoothed += smoothing_pass(cx, ctx, o.good)?;
let list = below(cx, o.good)?;
stats.topological += topological_pass(cx, &list)?;
let mut failed = 0;
// A pass ends where the next begins: its indicators are the next one's starting point.
let mut carried: Option<[f64; 8]> = None;
while failed < 3 {
if stats.passes >= o.max_passes {
stats.stopped = Some(format!("improvement stopped after {} passes", o.max_passes));
break;
}
stats.passes += 1;
let before = match carried.take() {
Some(b) => b,
None => indicators(cx)?,
};
stats.smoothed += smoothing_pass(cx, ctx, o.good)?;
let list = below(cx, o.good)?;
stats.topological += topological_pass(cx, &list)?;
// Insertion when smoothing and flips did not make enough progress (§4.2), or left a
// tetrahedron below the target.
let mid = indicators(cx)?;
if (!progressed(&mid, &before) || mid[0] < o.target) && stats.inserted < o.max_insertions {
// The worst 3.5% of the tetrahedra, or (desperation pass, §4.2) every one with a
// dihedral angle below 40° or above 140° (sine below sin 40°).
let mut scored: Vec<(f64, u32)> = Vec::new();
for (t, vs) in cx.live_tets() {
scored.push((tet_quality(cx, vs)?, t));
}
scored.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
let list: Vec<u32> = if failed == 1 {
scored.iter().filter(|s| s.0 < SIN_40).take(MAX_INSERTION_LIST).map(|s| s.1).collect()
} else {
let n = (scored.len() * 35).div_ceil(1000).clamp(1, MAX_INSERTION_LIST);
scored.iter().take(n).map(|s| s.1).collect()
};
let budget = o.max_insertions - stats.inserted;
stats.inserted += insertion_pass(cx, ctx, &list, budget, o.good)?;
}
let now = indicators(cx)?;
carried = Some(now);
stats.worst.push(now[0]);
if progressed(&now, &before) {
failed = 0;
} else {
failed += 1;
}
}
if stats.stopped.is_none() && stats.inserted >= o.max_insertions {
stats.stopped = Some(format!("improvement stopped at {} inserted vertices", o.max_insertions));
}
Ok(stats)
}
+230
View File
@@ -0,0 +1,230 @@
//! Tetrahedron measures for the quality stage, on floating-point coordinates.
//!
//! Decisions use only `+ − × ÷ √` (correctly rounded in IEEE 754), so they are the same on every
//! platform: the minimum sine of the dihedral angles (the improvement objective, Klingner and
//! Shewchuk 2007, §2), the circumcentre and the radius–edge ratio (the refinement criterion,
//! Shewchuk 1998). Angles in degrees are for reports only.
//!
//! Sources: J. R. Shewchuk, *What is a good linear finite element? Interpolation, conditioning,
//! anisotropy, and quality measures*, 2002 (the dihedral-angle sine formula
//! `sin θ_ij = 3 V ℓ_ij / (2 A_k A_l)` and the volume–length and radius-ratio measures);
//! B. M. Klingner, J. R. Shewchuk, *Aggressive Tetrahedral Mesh Improvement*, IMR 2007, §2.
pub(crate) use crate::vec3::{P, add, cross, dist, dot, lerp, mid, norm, scale, sub};
/// Six times the signed volume (positive in tet4 order).
pub fn volume6(x: &[P; 4]) -> f64 {
let [a, b, c, d] = *x;
dot(sub(b, a), cross(sub(c, a), sub(d, a)))
}
/// The cross product of two edges of the face opposite vertex `k` (its length is twice the
/// face's area).
fn face_cross(x: &[P; 4], k: usize) -> P {
let [a, b, c] = match k {
0 => [x[1], x[2], x[3]],
1 => [x[0], x[2], x[3]],
2 => [x[0], x[1], x[3]],
_ => [x[0], x[1], x[2]],
};
cross(sub(b, a), sub(c, a))
}
/// Twice the area of the face opposite vertex `k`.
fn face_area2(x: &[P; 4], k: usize) -> f64 {
norm(face_cross(x, k))
}
/// The edges as `(i, j, k, l)`: edge `ij`, and the two other vertices (whose opposite faces meet
/// at the edge).
const EDGE_FACES: [(usize, usize, usize, usize); 6] = [(0, 1, 2, 3), (0, 2, 1, 3), (0, 3, 1, 2), (1, 2, 0, 3), (1, 3, 0, 2), (2, 3, 0, 1)];
/// The sines of the six dihedral angles, signed by orientation (all negative for an inverted
/// tetrahedron), in [`EDGE_FACES`] order; zeros for a degenerate one.
pub fn dihedral_sines(x: &[P; 4]) -> [f64; 6] {
let v6 = volume6(x);
let areas = [0, 1, 2, 3].map(|k| face_area2(x, k));
EDGE_FACES.map(|(i, j, k, l)| {
let len = dist(x[i], x[j]);
// sin θ = 3 V ℓ / (2 A_k A_l) with V = v6 / 6 and A = area2 / 2.
let den = areas[k] * areas[l];
let s = v6 * len / den;
if s.is_finite() { s } else { 0.0 }
})
}
/// The cosines of the six dihedral angles (from the outward face normals).
pub fn dihedral_cosines(x: &[P; 4]) -> [f64; 6] {
let normal = |k: usize| -> P {
let f: Vec<P> = (0..4).filter(|&i| i != k).filter_map(|i| x.get(i).copied()).collect();
match f.as_slice() {
[a, b, c] => {
let n = cross(sub(*b, *a), sub(*c, *a));
// Point it away from vertex k.
let o = x.get(k).copied().unwrap_or(*a);
if dot(n, sub(o, *a)) > 0.0 { scale(n, -1.0) } else { n }
}
_ => [0.0; 3],
}
};
let n = [0, 1, 2, 3].map(normal);
EDGE_FACES.map(|(_, _, k, l)| {
let c = -dot(n[k], n[l]) / (norm(n[k]) * norm(n[l]));
if c.is_finite() { c.clamp(-1.0, 1.0) } else { 1.0 }
})
}
/// The quality objective: the smallest signed dihedral sine (1 is impossible; the regular
/// tetrahedron scores √(8/9) ≈ 0.943). Positive for a valid tetrahedron, 0 for a flat one,
/// negative for an inverted one (then the sine of largest magnitude, so worse inversions score
/// lower). With `sin θ_ij = 6V ℓ_ij / (2A_k · 2A_l)`, the extreme sine is found on squared
/// lengths and areas, with one square root.
pub fn quality(x: &[P; 4]) -> f64 {
let v6 = volume6(x);
if v6 == 0.0 || !v6.is_finite() {
return 0.0;
}
let area2sq = [0, 1, 2, 3].map(|k| {
let a = face_cross(x, k);
dot(a, a)
});
let mut pick = if v6 > 0.0 { f64::INFINITY } else { 0.0 };
for (i, j, k, l) in EDGE_FACES {
let e = sub(x[i], x[j]);
let r = dot(e, e) / (area2sq[k] * area2sq[l]);
pick = if v6 > 0.0 { pick.min(r) } else { pick.max(r) };
}
let q = v6 * pick.sqrt();
if q.is_finite() { q } else { 0.0 }
}
/// The smallest and largest dihedral angle in degrees (reports only).
pub fn dihedral_range(x: &[P; 4]) -> (f64, f64) {
let (s, c) = (dihedral_sines(x), dihedral_cosines(x));
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for (sv, cv) in s.iter().zip(c) {
let a = sv.abs().atan2(cv).to_degrees();
lo = lo.min(a);
hi = hi.max(a);
}
(lo, hi)
}
/// The circumcentre, or `None` for a flat tetrahedron.
pub fn circumcenter(x: &[P; 4]) -> Option<P> {
let [a, b, c, d] = *x;
let (b, c, d) = (sub(b, a), sub(c, a), sub(d, a));
let den = 2.0 * dot(b, cross(c, d));
if den == 0.0 || !den.is_finite() {
return None;
}
let num = add(add(scale(cross(c, d), dot(b, b)), scale(cross(d, b), dot(c, c))), scale(cross(b, c), dot(d, d)));
let o = add(a, scale(num, 1.0 / den));
o.iter().all(|v| v.is_finite()).then_some(o)
}
/// The circumradius (infinite for a flat tetrahedron).
pub fn circumradius(x: &[P; 4]) -> f64 {
circumcenter(x).map_or(f64::INFINITY, |o| dist(o, x[0]))
}
/// The shortest and longest edge.
pub fn edge_range(x: &[P; 4]) -> (f64, f64) {
let mut lo = f64::INFINITY;
let mut hi = 0.0_f64;
for (i, j, _, _) in EDGE_FACES {
let l = x.get(i).zip(x.get(j)).map_or(0.0, |(a, b)| dist(*a, *b));
lo = lo.min(l);
hi = hi.max(l);
}
(lo, hi)
}
/// Circumradius over shortest edge (√6/4 ≈ 0.612 for the regular tetrahedron).
pub fn radius_edge(x: &[P; 4]) -> f64 {
circumradius(x) / edge_range(x).0
}
/// Three times the inradius over the circumradius, signed (1 for the regular tetrahedron).
pub fn radius_ratio(x: &[P; 4]) -> f64 {
let v6 = volume6(x);
let area2: f64 = (0..4).map(|k| face_area2(x, k)).sum();
// r = 3V / A_total = (v6 / 2) / (area2 / 2) = v6 / area2.
let r = v6 / area2;
let q = 3.0 * r / circumradius(x);
if q.is_finite() { q } else { 0.0 }
}
/// The centroid.
pub fn centroid(x: &[P; 4]) -> P {
let s = x.iter().fold([0.0; 3], |acc, p| add(acc, *p));
scale(s, 0.25)
}
/// The circumcentre of a triangle (in its plane), or `None` when degenerate.
pub fn triangle_circumcenter(a: P, b: P, c: P) -> Option<P> {
let (u, v) = (sub(b, a), sub(c, a));
let w = cross(u, v);
let den = 2.0 * dot(w, w);
if den == 0.0 || !den.is_finite() {
return None;
}
let num = add(scale(cross(w, u), dot(v, v)), scale(cross(v, w), dot(u, u)));
let o = add(a, scale(num, 1.0 / den));
o.iter().all(|x| x.is_finite()).then_some(o)
}
/// The unit normal of a triangle (zero when degenerate).
pub fn triangle_normal(a: P, b: P, c: P) -> P {
let n = cross(sub(b, a), sub(c, a));
let l = norm(n);
if l > 0.0 && l.is_finite() { scale(n, 1.0 / l) } else { [0.0; 3] }
}
#[cfg(test)]
mod tests {
use super::*;
const REGULAR: [P; 4] = [[1.0, 1.0, 1.0], [1.0, -1.0, -1.0], [-1.0, 1.0, -1.0], [-1.0, -1.0, 1.0]];
fn positive(mut x: [P; 4]) -> [P; 4] {
if volume6(&x) < 0.0 {
x.swap(0, 1);
}
x
}
#[test]
fn regular_tetrahedron() {
let x = positive(REGULAR);
assert!((quality(&x) - (8.0f64 / 9.0).sqrt()).abs() < 1e-12, "{}", quality(&x));
assert!((radius_ratio(&x) - 1.0).abs() < 1e-12);
assert!((radius_edge(&x) - 6f64.sqrt() / 4.0).abs() < 1e-12);
let (lo, hi) = dihedral_range(&x);
assert!((lo - 70.528_779).abs() < 1e-5 && (hi - lo).abs() < 1e-9, "{lo} {hi}");
let o = circumcenter(&x).unwrap();
assert!(norm(o) < 1e-12);
}
#[test]
fn right_corner_and_inverted() {
let x = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
assert!(volume6(&x) > 0.0);
let (lo, hi) = dihedral_range(&x);
assert!((hi - 90.0).abs() < 1e-9 && (lo - 54.735_610).abs() < 1e-5, "{lo} {hi}");
let mut y = x;
y.swap(1, 2);
assert!(quality(&y) < 0.0);
let flat = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.0]];
assert_eq!(quality(&flat), 0.0);
assert!(circumcenter(&flat).is_none());
}
#[test]
fn sliver_scores_low() {
let x = positive([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.01]]);
assert!(quality(&x) < 0.05 && quality(&x) > 0.0, "{}", quality(&x));
assert!(radius_ratio(&x) < 0.1);
}
}
+466
View File
@@ -0,0 +1,466 @@
//! Tetrahedral mesh quality: Delaunay refinement to a size field, ODT smoothing and aggressive
//! improvement of a constrained Delaunay tetrahedralization ([`crate::cdt`]), then optionally
//! quadratic elements with their mid-edge nodes on the geometry.
//!
//! # Pipeline ([`quality_mesh`], `docs/meshing/literature.md` Part D Step 3)
//! 1. **Boundary entities.** The CDT's boundary faces carry their B-rep face; an edge between two
//! faces of different B-rep faces, or whose faces turn by more than the feature angle, is a
//! feature edge (curve). Vertices are corners (fixed), curve vertices (move along their
//! curve), face vertices (move on their face) or interior.
//! 2. **Size field** ([`geometry::GradedSize`]): the global size, limited near curved boundary
//! to `θ r` (θ radians per element along a curve of radius `r`, from the exact surface or
//! estimated from the tessellation) and in thin walls to their thickness
//! ([`geometry::Tessellation::thickness`]), graded at a bounded rate.
//! 3. **Coarsening** ([`refine::coarsen`]): edge collapses where the boundary recovery's
//! Steiner points and the tessellation are denser than the size field.
//! 4. **Refinement interleaved with smoothing** (Tournois et al. 2009): rounds of constrained
//! Delaunay refinement ([`refine`], Shewchuk 1998, Si 2010) and ODT smoothing ([`smooth`],
//! Chen–Xu 2004, Alliez et al. 2005), boundary vertices kept on their faces by projection
//! ([`geometry`]).
//! 5. **Aggressive improvement** ([`improve`], Klingner–Shewchuk 2007): smoothing, edge
//! removal, multi-face removal and vertex insertion, monotone in the worst dihedral sine.
//! 6. **Gate and report**: every tetrahedron admissible by FieldCraft's one criterion, the volume
//! equal to what the mesh's own boundary encloses; minimum and maximum dihedral angles and
//! radius ratios ([`TetQuality`]).
//! 7. **Quadratic elements** ([`quadratic`]): mid-edge nodes, projected onto the B-rep faces and
//! curves on the boundary; an element that would not be valid keeps straight edges there.
//!
//! Every loop is bounded (insertions, tetrahedra, passes); a stage that stops early says so in
//! the report. Everything is deterministic: fixed orders, no hashing into output, and only
//! correctly rounded arithmetic in decisions.
// The tetrahedron kernels index their four corners and six edges by slot; ranges over them are
// clearer than iterator chains (as in the fem and solver crates).
#![allow(clippy::needless_range_loop)]
pub mod complex;
pub mod geometry;
pub mod improve;
pub mod measure;
pub mod ops;
pub mod quadratic;
pub mod refine;
pub mod smooth;
#[cfg(test)]
mod tests;
use fieldcraft_model::ElementKind;
use crate::cdt::TetMesh;
use crate::predicates::Point3;
use crate::quality::{GateOptions, GateReport, Metric, Validity, element_quality, enclosed_volume, validity_gate_elements};
use crate::surface::BoundarySurface;
use crate::{Error, Result};
use complex::{IdMap, NONE, TetComplex, VertexKind, face_toward, sorted2};
use geometry::{AnalyticSurface, GradedSize, Tessellation};
use improve::{ImproveOptions, ImproveStats};
use refine::{Context, RefineOptions, RefineStats};
/// Element order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Order {
Linear,
Quadratic,
}
/// Settings of [`quality_mesh`].
#[derive(Debug, Clone, PartialEq)]
pub struct QualityOptions {
/// Target edge length; `None`: a twentieth of the bounding-box diagonal.
pub size: Option<f64>,
/// Radians per element along curved boundary (0: no curvature refinement).
pub curvature_angle: f64,
/// Elements across a thin wall: the size is at most the local thickness over this (0: no
/// proximity sizing).
pub proximity: f64,
/// Largest change of size per unit length.
pub grading: f64,
/// Smallest size, as a fraction of the global size.
pub min_size: f64,
/// Radius–edge bound of the refinement.
pub radius_edge: f64,
/// Interleaved refinement and ODT rounds (0: no refinement).
pub rounds: usize,
/// ODT passes per round.
pub odt_passes: usize,
/// Run the improvement schedule.
pub improve: bool,
pub improve_options: ImproveOptions,
/// An edge whose two boundary faces turn by more than this many degrees is a feature edge.
pub feature_angle: f64,
pub order: Order,
pub max_tets: usize,
pub max_points: usize,
}
impl Default for QualityOptions {
fn default() -> Self {
QualityOptions {
size: None,
curvature_angle: std::f64::consts::PI / 8.0,
proximity: 1.0,
grading: 0.3,
min_size: 0.02,
radius_edge: 2.0,
rounds: 3,
odt_passes: 3,
improve: true,
improve_options: ImproveOptions::default(),
feature_angle: 45.0,
order: Order::Linear,
max_tets: 5_000_000,
max_points: 1_000_000,
}
}
}
/// Quality statistics of a tetrahedral mesh (corner geometry).
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct TetQuality {
pub tets: usize,
pub min_dihedral: f64,
pub max_dihedral: f64,
pub min_radius_ratio: f64,
pub mean_radius_ratio: f64,
/// Tetrahedra with a dihedral angle below 10° or above 170°.
pub slivers: usize,
/// Tetrahedra not admissible by FieldCraft's validity criterion.
pub inadmissible: usize,
}
/// The quality of tetrahedra given by corner coordinates, with the metrics `mesh.quality`
/// reports ([`crate::quality::element_quality`]).
pub fn tet_quality_stats(points: &[[f64; 3]], tets: &[[u32; 4]]) -> Result<TetQuality> {
let mut s = TetQuality { min_dihedral: 180.0, min_radius_ratio: 1.0, ..TetQuality::default() };
let mut sum = 0.0;
for t in tets {
let p = |v: u32| points.get(v as usize).copied().ok_or_else(|| Error::Failed("quality: vertex id".into()));
let q = element_quality(ElementKind::Tet4, &[p(t[0])?, p(t[1])?, p(t[2])?, p(t[3])?])?;
let m = |metric: Metric| q.get(metric).unwrap_or(f64::NAN);
let (lo, hi, rr) = (m(Metric::MinDihedral), m(Metric::MaxDihedral), m(Metric::RadiusRatio));
s.min_dihedral = s.min_dihedral.min(lo);
s.max_dihedral = s.max_dihedral.max(hi);
s.min_radius_ratio = s.min_radius_ratio.min(rr);
sum += rr;
s.slivers += usize::from(!(lo >= 10.0 && hi <= 170.0));
s.inadmissible += usize::from(q.validity() != Some(Validity::Valid));
}
s.tets = tets.len();
if tets.is_empty() {
s.min_dihedral = 0.0;
s.min_radius_ratio = 0.0;
} else {
s.mean_radius_ratio = sum / tets.len() as f64;
}
Ok(s)
}
/// A boundary face of the output: tetrahedron, the slot of its opposite vertex, B-rep face.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TaggedFace {
pub tet: u32,
pub opposite: u8,
pub face: u32,
}
impl TaggedFace {
/// The face's 1-based number in the model's tet4 (and tet10) face order.
pub fn element_face(&self) -> Option<u8> {
let k = usize::from(self.opposite);
let n = ElementKind::Tet4.faces().iter().position(|f| !f.contains(&k))?;
u8::try_from(n + 1).ok()
}
}
/// What [`quality_mesh`] did.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct QualityReport {
/// The constrained Delaunay tetrahedralization it started from.
pub input: TetQuality,
pub output: TetQuality,
pub refine: Vec<RefineStats>,
pub odt_moves: usize,
/// Edges collapsed where the input was denser than the size field.
pub collapsed: usize,
/// Boundary vertices that could not be moved onto their B-rep face or curve (the move would
/// have inverted a tetrahedron); they stay on the tessellation.
pub unsnapped: usize,
pub improve: ImproveStats,
/// The global size used.
pub size: f64,
/// Volume of the mesh, and what its boundary encloses.
pub volume: f64,
pub boundary_volume: f64,
/// Quadratic elements whose curved edges had to stay straight to be valid.
pub straightened: usize,
/// Why a stage stopped early, if one did.
pub stopped: Vec<String>,
}
/// The quality mesh.
#[derive(Debug, Clone, PartialEq)]
pub struct QualityMesh {
/// Corner vertices, then (quadratic) mid-edge nodes.
pub points: Vec<[f64; 3]>,
/// Elements in the model's node order (4 or 10 nodes).
pub elements: Vec<Vec<u32>>,
pub kind: ElementKind,
pub boundary: Vec<TaggedFace>,
pub report: QualityReport,
}
fn bounds(points: &[[f64; 3]]) -> ([f64; 3], [f64; 3]) {
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for p in points {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
(lo, hi)
}
/// The size field of a boundary: `global` limited by curvature, graded.
pub fn size_field(surface: &BoundarySurface, geometry: &Tessellation, global: f64, o: &QualityOptions) -> GradedSize {
let (lo, hi) = bounds(surface.points());
let floor = o.min_size * global;
let mut samples = Vec::new();
if o.curvature_angle > 0.0 {
let radii = geometry::curvature_radii(surface, geometry);
for (p, r) in surface.points().iter().zip(radii) {
let h = (o.curvature_angle * r).max(floor);
if h < global {
samples.push((*p, h));
}
}
}
if o.proximity > 0.0 {
// Thin walls: elements no larger than the wall's thickness over `proximity`.
for (p, t) in surface.points().iter().zip(geometry.thickness()) {
let h = (t / o.proximity).max(floor);
if h < global {
samples.push((*p, h));
}
}
}
GradedSize::new(global, &samples, lo, hi, o.grading)
}
/// The CDT as a [`TetComplex`] with B-rep face tags, feature curves and vertex kinds; and the
/// feature curves' face pairs.
pub fn complex_from_cdt(surface: &BoundarySurface, cdt: &TetMesh, o: &QualityOptions) -> Result<(TetComplex, Vec<[u32; 2]>)> {
let tri_face = surface.tri_face();
let tags: Vec<(u32, usize, u32)> =
cdt.boundary.iter().map(|b| (b.tet, usize::from(b.opposite), tri_face.get(b.triangle as usize).copied().unwrap_or(NONE))).collect();
let kinds = vec![VertexKind::Interior; cdt.points.len()];
let mut cx =
TetComplex::with_invalid(&cdt.points, kinds, &cdt.tets, &tags, &[], o.max_points.max(cdt.points.len()), o.max_tets.max(cdt.tets.len()))?;
let feature_cos = geometry::sin_cos(o.feature_angle.clamp(0.0, 180.0).to_radians()).1;
let (table, edges) = refine::feature_curves(&cx, feature_cos)?;
for &((a, b), c) in &edges {
cx.set_curve(a, b, Some(c));
}
// Vertex kinds from the boundary faces and curve edges at each vertex.
let mut face_tag: Vec<Option<u32>> = vec![None; cdt.points.len()];
let mut mixed = vec![false; cdt.points.len()];
for (t, vs) in cx.live_tets() {
let (ns, tg) = (cx.adj(t)?, cx.tags(t)?);
for k in 0..4 {
if ns[k] != NONE {
continue;
}
for v in face_toward(vs, k) {
let i = v as usize;
match face_tag.get(i).copied().flatten() {
None => {
if let Some(s) = face_tag.get_mut(i) {
*s = Some(tg[k]);
}
}
Some(x) if x != tg[k] => {
if let Some(m) = mixed.get_mut(i) {
*m = true;
}
}
_ => {}
}
}
}
}
let mut curve_count: IdMap<u32, Vec<u32>> = IdMap::default();
for &((a, b), c) in &edges {
curve_count.entry(a).or_default().push(c);
curve_count.entry(b).or_default().push(c);
}
for v in 0..cdt.points.len() as u32 {
let kind = match (face_tag.get(v as usize).copied().flatten(), curve_count.get(&v).map(Vec::as_slice)) {
(None, _) => VertexKind::Interior,
(Some(tag), None) if !mixed.get(v as usize).copied().unwrap_or(true) => VertexKind::Face(tag),
(Some(_), Some(&[c1, c2])) if c1 == c2 => VertexKind::Curve(c1),
_ => VertexKind::Corner,
};
cx.set_kind(v, kind);
}
Ok((cx, table))
}
/// Move every face and curve vertex onto its B-rep face or curve where that keeps the
/// tetrahedra around it positive (and, when `guarded`, their worst quality at least the smaller
/// of what it was and sin 17.5°, so a finished mesh is not spoilt). Returns the vertices that
/// could not move.
fn snap_to_geometry(cx: &mut TetComplex, ctx: &Context, guarded: bool) -> Result<usize> {
let mut stuck = 0;
for v in 0..cx.point_count() as u32 {
if !cx.is_used(v) || !matches!(cx.kind(v), VertexKind::Face(_) | VertexKind::Curve(_)) {
continue;
}
let x = cx.xyz(v)?;
let Some(p) = ctx.place(cx.kind(v), x) else { continue };
if p.coords() == x {
continue;
}
let star = cx.star(v)?;
let mut ok = cx.move_is_valid(v, &p, &star)?;
if ok && guarded {
let before = ops::worst(cx, &star)?;
cx.move_point(v, p)?;
let after = ops::worst(cx, &star)?;
if after < before.min(0.3) {
cx.move_point(v, Point3::try_from(x)?)?;
ok = false;
}
} else if ok {
cx.move_point(v, p)?;
}
stuck += usize::from(!ok);
}
Ok(stuck)
}
/// The volume enclosed by the boundary faces of a mesh ([`enclosed_volume`]).
fn boundary_volume(points: &[[f64; 3]], tets: &[[u32; 4]], boundary: &[TaggedFace]) -> Result<f64> {
let mut triangles = Vec::with_capacity(boundary.len());
for b in boundary {
let t = tets.get(b.tet as usize).ok_or_else(|| Error::Failed("boundary face of no tetrahedron".into()))?;
// face_toward points into the tetrahedron; outward is its reverse.
let f = face_toward(*t, usize::from(b.opposite));
triangles.push([f[0] as usize, f[2] as usize, f[1] as usize]);
}
enclosed_volume(points, &triangles)
}
/// Run the quality pipeline on a constrained Delaunay tetrahedralization of `surface` (see the
/// module docs). `analytic[face]` is the exact surface of a B-rep face when known.
pub fn quality_mesh(surface: &BoundarySurface, cdt: &TetMesh, analytic: Vec<Option<AnalyticSurface>>, o: &QualityOptions) -> Result<QualityMesh> {
let mut report = QualityReport { input: tet_quality_stats(&cdt.points, &cdt.tets)?, ..QualityReport::default() };
let geometry = Tessellation::new(surface, analytic);
let (lo, hi) = bounds(surface.points());
let global = match o.size {
Some(h) if h > 0.0 && h.is_finite() => h,
Some(h) => return Err(Error::Invalid(format!("size {h} must be a positive number"))),
None => measure::dist(lo, hi) / 20.0,
};
if !(global > 0.0 && global.is_finite()) {
return Err(Error::Invalid("the boundary has no extent".into()));
}
report.size = global;
let size = size_field(surface, &geometry, global, o);
let (mut cx, curve_faces) = complex_from_cdt(surface, cdt, o)?;
// Refinement and improvement keep boundary vertices on the tessellation, a piecewise-flat
// surface whose creases they can mesh around; the finished mesh then moves its boundary
// vertices onto the exact surfaces (and tet10 puts its mid-edge nodes there). Projecting onto
// the exact surfaces while improving leaves vertices on both sides of a blend's tangent
// edge (where two faces meet without a crease) too close to flat to separate.
let plain = Tessellation::new(surface, Vec::new());
let ctx = Context { size: &size, geometry: &plain, curve_faces: &curve_faces };
let exact = Context { size: &size, geometry: &geometry, curve_faces: &curve_faces };
// Remove what rounding left flat first, so refinement starts from a valid mesh.
let flat =
|cx: &TetComplex| -> Vec<(u32, [u32; 4])> { cx.live_tets().filter(|(_, vs)| ops::tet_quality(cx, *vs).is_ok_and(|q| q < 1e-6)).collect() };
let bad: Vec<u32> = flat(&cx).into_iter().map(|(t, _)| t).collect();
if !bad.is_empty() {
improve::topological_pass(&mut cx, &bad)?;
for (_, vs) in flat(&cx) {
for v in vs {
smooth::smooth_vertex(&mut cx, &ctx, v)?;
}
}
}
// Coarsen what the boundary recovery made denser than the size field asks for.
report.collapsed = refine::coarsen(&mut cx, &ctx, 0.6, 1.4, 0.05, 6)?;
let ropt = RefineOptions { radius_edge: o.radius_edge, ..RefineOptions::default() };
for _ in 0..o.rounds {
let stats = refine::refine(&mut cx, &ctx, &ropt)?;
let quiet = stats.interior + stats.boundary == 0;
let stopped = stats.stopped.clone();
report.refine.push(stats);
for _ in 0..o.odt_passes {
report.odt_moves += smooth::odt_pass(&mut cx, &ctx)?;
}
if let Some(s) = stopped {
report.stopped.push(s);
break;
}
if quiet {
break;
}
}
if o.improve {
report.improve = improve::improve(&mut cx, &ctx, &o.improve_options)?;
if let Some(s) = &report.improve.stopped {
report.stopped.push(s.clone());
}
}
// Put the boundary vertices on the exact surfaces (the tessellation is within its chordal
// tolerance of them).
report.unsnapped = snap_to_geometry(&mut cx, &exact, true)?;
let c = cx.compact()?;
let boundary: Vec<TaggedFace> = c.boundary.iter().map(|&(tet, k, face)| TaggedFace { tet, opposite: k as u8, face }).collect();
report.output = tet_quality_stats(&c.points, &c.tets)?;
report.boundary_volume = boundary_volume(&c.points, &c.tets, &boundary)?;
let gate = linear_gate(&c.points, &c.tets, report.boundary_volume)?;
report.volume = gate.volume;
gate.into_result()?;
match o.order {
Order::Linear => {
Ok(QualityMesh { elements: c.tets.iter().map(|t| t.to_vec()).collect(), points: c.points, kind: ElementKind::Tet4, boundary, report })
}
Order::Quadratic => {
let curve_of: IdMap<(u32, u32), u32> = c.curves.iter().map(|&(e, k)| (sorted2(e.0, e.1), k)).collect();
let q = quadratic::tet10(&c.points, &c.tets, &boundary, &curve_of, &curve_faces, &geometry)?;
report.straightened = q.straightened;
Ok(QualityMesh { points: q.points, elements: q.elements, kind: ElementKind::Tet10, boundary, report })
}
}
}
/// The constrained Delaunay tetrahedralization itself as a [`QualityMesh`] (no quality stage),
/// through the same validity gate: every tetrahedron admissible, the volume equal to what its
/// boundary encloses.
pub fn raw_mesh(surface: &BoundarySurface, cdt: &TetMesh) -> Result<QualityMesh> {
let tri_face = surface.tri_face();
let boundary: Vec<TaggedFace> = cdt
.boundary
.iter()
.map(|b| TaggedFace { tet: b.tet, opposite: b.opposite, face: tri_face.get(b.triangle as usize).copied().unwrap_or(NONE) })
.collect();
let stats = tet_quality_stats(&cdt.points, &cdt.tets)?;
let boundary_volume = boundary_volume(&cdt.points, &cdt.tets, &boundary)?;
let gate = linear_gate(&cdt.points, &cdt.tets, boundary_volume)?.into_result()?;
let report = QualityReport { input: stats, output: stats, volume: gate.volume, boundary_volume, ..QualityReport::default() };
Ok(QualityMesh { points: cdt.points.clone(), elements: cdt.tets.iter().map(|t| t.to_vec()).collect(), kind: ElementKind::Tet4, boundary, report })
}
/// The validity gate of a linear mesh: every tetrahedron admissible, and the volume equal to
/// what its boundary encloses within 1e-9 (no overlap, no gap).
pub fn linear_gate(points: &[[f64; 3]], tets: &[[u32; 4]], boundary_volume: f64) -> Result<GateReport> {
let coords = |t: &[u32; 4]| -> Result<[[f64; 3]; 4]> {
let p = |v: u32| points.get(v as usize).copied().ok_or_else(|| Error::Failed("gate: vertex id".into()));
Ok([p(t[0])?, p(t[1])?, p(t[2])?, p(t[3])?])
};
let elements = tets.iter().enumerate().map(|(i, t)| Ok((i as u32 + 1, ElementKind::Tet4, coords(t)?))).collect::<Result<Vec<_>>>()?;
validity_gate_elements(elements, &GateOptions { expected_volume: Some(boundary_volume), volume_tolerance: 1e-9 })
}
+705
View File
@@ -0,0 +1,705 @@
//! Local operations on the [`TetComplex`]: edge removal, face removal (2-3 flip and multi-face
//! removal), Delaunay vertex insertion with a star-shaped cavity, and the optimal-cavity vertex
//! insertion of the improvement stage.
//!
//! Every operation is a [`TetComplex::replace`], so it either keeps a valid mesh or changes
//! nothing; the topological ones are hill-climbing steps that are applied only when the worst new
//! tetrahedron is better than the worst one they remove.
//!
//! Sources:
//! - J. R. Shewchuk, *Two Discrete Optimization Algorithms for the Topological Improvement of
//! Tetrahedral Meshes*, 2002: edge removal by Klincsek's dynamic programming over the ring of
//! the edge (§2, including boundary edges ending in a 2-2 flip), and multi-face removal (§3).
//! - B. M. Klingner, J. R. Shewchuk, *Aggressive Tetrahedral Mesh Improvement*, IMR 2007, §3.4:
//! vertex insertion whose cavity maximizes the worst new tetrahedron (the cut of Figure 3).
//! - A. Bowyer 1981, D. F. Watson 1981; Shewchuk, *Tetrahedral Mesh Generation by Delaunay
//! Refinement*, SoCG 1998: Delaunay insertion; the cavity is shrunk until it is star-shaped from
//! the new vertex, so a mesh that is no longer exactly Delaunay (after smoothing) stays valid.
use crate::Result;
use crate::predicates::{Point3, Sign};
use super::complex::{Change, EDGES, IdMap, NONE, TetComplex, VertexKind, face_toward, sorted2, sorted3};
use super::measure::{self, P};
/// The quality objective of a tetrahedron given by vertex ids ([`measure::quality`]).
pub fn tet_quality(cx: &TetComplex, vs: [u32; 4]) -> Result<f64> {
Ok(measure::quality(&cx.coords(vs)?))
}
/// The worst quality of a set of tetrahedra.
pub fn worst(cx: &TetComplex, tets: &[u32]) -> Result<f64> {
let mut q = f64::INFINITY;
for &t in tets {
q = q.min(tet_quality(cx, cx.tet(t)?)?);
}
Ok(q)
}
/// The largest ring an edge removal considers (Shewchuk 2002 recommends 7; up to 10).
pub const MAX_RING: usize = 10;
/// The cosine of the largest angle (20°) between the normals of two boundary faces on one B-rep
/// face that a boundary edge removal (ending in a 2-2 flip) or the deletion of a tetrahedron
/// capping them may re-triangulate. The size field holds the faces of a curved B-rep face to
/// about 22.5° of turn per element, so faces of one B-rep face fold by about that much, and the
/// change moves the boundary by an element's chordal error.
pub const BOUNDARY_FLIP_COS: f64 = 0.939_692_620_785_908_4;
/// Klincsek's table: the best worst quality of a triangulation of the chain `v_i … v_j`
/// (with the closing edge `v_i v_j`), and the apex `k` that achieves it.
fn klincsek(m: usize, q: &mut dyn FnMut(usize, usize, usize) -> Result<f64>) -> Result<(Vec<f64>, Vec<usize>)> {
let mut best = vec![f64::INFINITY; m * m];
let mut arg = vec![0usize; m * m];
for i in (0..m.saturating_sub(2)).rev() {
for j in i + 2..m {
let mut b = f64::NEG_INFINITY;
let mut a = i + 1;
for k in i + 1..j {
let sub = best.get(i * m + k).copied().unwrap_or(f64::NEG_INFINITY).min(best.get(k * m + j).copied().unwrap_or(f64::NEG_INFINITY));
if sub <= b {
continue;
}
let v = sub.min(q(i, k, j)?);
if v > b {
b = v;
a = k;
}
}
if let Some(s) = best.get_mut(i * m + j) {
*s = b;
}
if let Some(s) = arg.get_mut(i * m + j) {
*s = a;
}
}
}
Ok((best, arg))
}
/// The triangles `(i, k, j)` of the optimal triangulation of `v_i … v_j`.
fn triangles(arg: &[usize], m: usize, i: usize, j: usize, out: &mut Vec<(usize, usize, usize)>) {
let mut stack = vec![(i, j)];
while let Some((i, j)) = stack.pop() {
if j < i + 2 {
continue;
}
let k = arg.get(i * m + j).copied().unwrap_or(i + 1);
out.push((i, k, j));
stack.push((i, k));
stack.push((k, j));
}
}
/// The two tetrahedra a ring triangle `(v_i, v_k, v_j)` (in ring order) makes with the edge's
/// ends: positive when the ring runs as in [`TetComplex::edge_ring`].
fn ring_tets(a: u32, b: u32, vi: u32, vk: u32, vj: u32) -> [[u32; 4]; 2] {
[[vi, vj, vk, a], [vi, vk, vj, b]]
}
/// Remove edge `ab` if that improves the worst tetrahedron around it (Shewchuk 2002 §2). A
/// boundary edge is removed only when its two boundary faces lie on the same B-rep face, are
/// nearly coplanar ([`BOUNDARY_FLIP_COS`]) and the edge is not a feature edge; the operation then
/// ends in a 2-2 flip of the boundary. Returns whether the mesh changed.
pub fn remove_edge(cx: &mut TetComplex, a: u32, b: u32) -> Result<bool> {
if cx.curve(a, b).is_some() {
return Ok(false);
}
let Some(t) = cx.edge_tet(a, b)? else { return Ok(false) };
remove_edge_at(cx, t, a, b)
}
/// [`remove_edge`] from a tetrahedron `t` that holds the edge.
pub fn remove_edge_at(cx: &mut TetComplex, t: u32, a: u32, b: u32) -> Result<bool> {
let vs = cx.tet(t)?;
if cx.curve(a, b).is_some() || !vs.contains(&a) || !vs.contains(&b) {
return Ok(false);
}
let Some(ring) = cx.edge_ring_at(t, a, b)? else { return Ok(false) };
let m = ring.verts.len();
if m < 3 || m > MAX_RING + usize::from(!ring.closed) {
return Ok(false);
}
let v = ring.verts.clone();
let x = |id: u32| cx.xyz(id);
let (xa, xb) = (x(a)?, x(b)?);
// An open ring runs from boundary face (a, b, v_0) to (a, b, v_{m−1}): both must lie on one
// flat B-rep face, and they become (a, v_0, v_{m−1}) and (b, v_0, v_{m−1}) (a 2-2 flip).
let mut new_boundary = Vec::new();
if !ring.closed {
let (first, last) = (ring.tets.first().copied().unwrap_or(NONE), ring.tets.last().copied().unwrap_or(NONE));
let (v0, vl) = (v.first().copied().unwrap_or(NONE), v.last().copied().unwrap_or(NONE));
let (Some(t0), Some(t1)) = (boundary_tag(cx, first, [a, b, v0])?, boundary_tag(cx, last, [a, b, vl])?) else { return Ok(false) };
let n0 = measure::triangle_normal(xa, xb, x(v0)?);
let n1 = measure::triangle_normal(xa, x(vl)?, xb);
if t0 != t1 || !(measure::dot(n0, n1) >= BOUNDARY_FLIP_COS) {
return Ok(false);
}
// Oriented into the mesh, as the faces of the new tetrahedra (v_0, v_l, v_k, a) and
// (v_0, v_k, v_l, b) opposite v_k.
new_boundary.push(([v0, a, vl], t0));
new_boundary.push(([v0, vl, b], t0));
}
let before = worst(cx, &ring.tets)?;
let xv = v.iter().map(|&id| x(id)).collect::<Result<Vec<P>>>()?;
let mut q = |i: usize, k: usize, j: usize| -> Result<f64> {
let (pi, pk, pj) = match (xv.get(i), xv.get(k), xv.get(j)) {
(Some(p), Some(q), Some(r)) => (*p, *q, *r),
_ => return Ok(f64::NEG_INFINITY),
};
Ok(measure::quality(&[pi, pj, pk, xa]).min(measure::quality(&[pi, pk, pj, xb])))
};
// Triangulate the polygon v_0 … v_{m−1}, closed by the edge v_{m−1} v_0.
let (best, arg) = klincsek(m, &mut q)?;
let total = best.get(m - 1).copied().unwrap_or(f64::NEG_INFINITY);
if !(total > before) {
return Ok(false);
}
let mut tris = Vec::new();
triangles(&arg, m, 0, m - 1, &mut tris);
let mut new = Vec::with_capacity(2 * tris.len());
for (i, k, j) in tris {
let (vi, vk, vj) = match (v.get(i), v.get(k), v.get(j)) {
(Some(p), Some(q), Some(r)) => (*p, *q, *r),
_ => return Ok(false),
};
new.extend(ring_tets(a, b, vi, vk, vj));
}
let change = Change { old: ring.tets.clone(), new, new_boundary, drop_curves: Vec::new() };
Ok(cx.replace(&change)?.is_some())
}
/// The tag of the boundary face of tetrahedron `t` with vertices `face`, if it is one.
pub fn boundary_tag(cx: &TetComplex, t: u32, face: [u32; 3]) -> Result<Option<u32>> {
if t == NONE {
return Ok(None);
}
let vs = cx.tet(t)?;
let key = sorted3(face);
let ns = cx.adj(t)?;
let tags = cx.tags(t)?;
for k in 0..4 {
if sorted3(face_toward(vs, k)) == key && ns[k] == NONE {
return Ok(Some(tags[k]));
}
}
Ok(None)
}
/// Remove the interior face opposite vertex `k` of tetrahedron `t` by the best multi-face removal
/// that includes it (Shewchuk 2002 §3), if that improves the worst tetrahedron. With a single
/// face this is the 2-3 flip. Returns whether the mesh changed.
pub fn remove_face(cx: &mut TetComplex, t: u32, k: usize) -> Result<bool> {
let vs = cx.tet(t)?;
let u = cx.adj(t)?.get(k).copied().unwrap_or(NONE);
if u == NONE {
return Ok(false);
}
let a = vs[k];
let us = cx.tet(u)?;
let face = face_toward(vs, k);
let b = us.iter().copied().find(|v| !face.contains(v)).unwrap_or(NONE);
if b == NONE || cx.curve(a, b).is_some() || cx.edge_tet(a, b)?.is_some() {
return Ok(false);
}
// Sandwiched faces between a and b: faces f with tetrahedra (a ∪ f) and (b ∪ f). Grow the
// set from the given face across shared edges while every new tetrahedron (a, b, e) on the
// ring stays better than the worst removed one; take the best prefix found (a greedy form of
// Shewchuk's optimal search, which visits the same candidates).
let mut faces: Vec<[u32; 3]> = vec![face];
let mut old: Vec<u32> = vec![t, u];
let mut best: Option<(f64, Vec<u32>, Vec<[u32; 4]>)> = None;
for _round in 0..8 {
let removed_q = worst(cx, &old)?;
// The boundary edges of the face set (edges in exactly one face), oriented as in the face
// seen from a: each gives the new tetrahedron (a, b, e) positive.
let mut count: IdMap<(u32, u32), (u32, u32, usize)> = IdMap::default();
for f in &faces {
for i in 0..3 {
let (p, q) = (f[i], f[(i + 1) % 3]);
let e = count.entry(sorted2(p, q)).or_insert((p, q, 0));
e.2 += 1;
}
}
let mut ring: Vec<(u32, u32)> = count.values().filter(|e| e.2 == 1).map(|e| (e.0, e.1)).collect();
ring.sort_unstable();
let mut new = Vec::with_capacity(ring.len());
let mut q = f64::INFINITY;
for &(p, r) in &ring {
// face (p, r, x) has a on its positive side, so (p, r, a, b)… orient explicitly.
let cand = [p, r, b, a];
let cand = if cx.orient(cand)? == Sign::Positive { cand } else { [r, p, b, a] };
q = q.min(tet_quality(cx, cand)?);
new.push(cand);
}
if q > removed_q && best.as_ref().is_none_or(|(bq, _, _)| q > *bq) {
best = Some((q, old.clone(), new));
}
// Grow: a sandwiched neighbour face across a ring edge.
let mut grown = false;
for &(p, r) in &ring {
if let Some((f, ta, tb)) = sandwiched_across(cx, a, b, p, r, &faces)? {
faces.push(f);
old.push(ta);
old.push(tb);
grown = true;
break;
}
}
if !grown {
break;
}
}
let Some((_, old, new)) = best else { return Ok(false) };
let change = Change { old, new, new_boundary: Vec::new(), drop_curves: Vec::new() };
Ok(cx.replace(&change)?.is_some())
}
/// A face `(p, r, x)` sandwiched between `a` and `b` (tetrahedra `a ∪ f` and `b ∪ f` both exist)
/// next to edge `pr`, not already in `faces`.
fn sandwiched_across(cx: &mut TetComplex, a: u32, b: u32, p: u32, r: u32, faces: &[[u32; 3]]) -> Result<Option<([u32; 3], u32, u32)>> {
let Some(ring) = cx.edge_ring(p, r)? else { return Ok(None) };
// Tetrahedra around pr containing a, and containing b.
for &ta in &ring.tets {
let va = cx.tet(ta)?;
if !va.contains(&a) {
continue;
}
let x = va.iter().copied().find(|&v| v != a && v != p && v != r).unwrap_or(NONE);
if x == NONE || x == b {
continue;
}
let f = [p, r, x];
if faces.iter().any(|g| sorted3(*g) == sorted3(f)) {
continue;
}
let k = va.iter().position(|&v| v == a).unwrap_or(0);
let tb = cx.adj(ta)?.get(k).copied().unwrap_or(NONE);
if tb != NONE && cx.tet(tb)?.contains(&b) {
return Ok(Some((f, ta, tb)));
}
}
Ok(None)
}
/// How an inserted vertex is placed.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Site {
/// Inside the domain, in (or on) tetrahedron `t`.
Interior(u32),
/// On boundary face `k` of tetrahedron `t` (the vertex lies on that face's B-rep face).
Face(u32, usize),
/// On the boundary edge `ab` (a feature edge, which is split, or an edge inside one face).
Edge(u32, u32),
}
/// Insert a vertex at `p` with the Delaunay cavity: the tetrahedra whose circumsphere contains
/// `p`, grown from the site's tetrahedra across interior faces, then shrunk until every face of
/// the cavity sees `p` (star-shaped). A boundary site also removes the boundary faces of the
/// cavity on the same B-rep face(s) whose circumcircle holds `p` and that connect to the site
/// without crossing a feature edge, and fans the new vertex to their outline. Returns the new
/// vertex, or `None` when no valid cavity exists (nothing changes then).
pub fn insert_delaunay(cx: &mut TetComplex, p: Point3, kind: VertexKind, site: Site, max_cavity: usize) -> Result<Option<u32>> {
insert_delaunay_apart(cx, p, kind, site, max_cavity, 0.0)
}
/// [`insert_delaunay`], refused when a vertex of the cavity lies closer than `min_gap` to `p`
/// (the nearest vertex of a Delaunay mesh is a vertex of the cavity), so refinement cannot
/// pile vertices onto one spot.
pub fn insert_delaunay_apart(cx: &mut TetComplex, p: Point3, kind: VertexKind, site: Site, max_cavity: usize, min_gap: f64) -> Result<Option<u32>> {
let (roots, root_faces) = site_roots(cx, site)?;
if roots.is_empty() {
return Ok(None);
}
// Grow the conflict region.
let mut cavity: Vec<u32> = roots.clone();
let mut inset: IdMap<u32, ()> = cavity.iter().map(|&t| (t, ())).collect();
let mut i = 0;
while let Some(&t) = cavity.get(i) {
i += 1;
if cavity.len() > max_cavity {
return Ok(None);
}
for n in cx.adj(t)? {
if n == NONE || inset.contains_key(&n) {
continue;
}
if cx.in_sphere(cx.tet(n)?, &p)? {
inset.insert(n, ());
cavity.push(n);
}
}
}
if min_gap > 0.0 {
let px = p.coords();
for &t in &cavity {
for v in cx.tet(t)? {
if measure::dist(cx.xyz(v)?, px) < min_gap {
return Ok(None);
}
}
}
}
let curve_split = match site {
Site::Edge(a, b) => cx.curve(a, b).map(|c| (a, b, c)),
_ => None,
};
// Shrink until star-shaped; boundary faces of the cavity on the site's face(s) are removed
// when connected to the site and in conflict.
for _ in 0..max_cavity.max(8) {
let plan = plan_cavity(cx, &cavity, &p, site, &root_faces)?;
match plan {
Plan::Ready { new, new_boundary } => {
let len = cx.journal.steps.len();
let v = cx.add_point(p, kind)?;
let new: Vec<[u32; 4]> = new.into_iter().map(|f| [f[0], f[1], f[2], v]).collect();
let new_boundary: Vec<([u32; 3], u32)> = new_boundary.into_iter().map(|(e, tag)| ([e.0, e.1, v], tag)).collect();
let change =
Change { old: cavity.clone(), new, new_boundary, drop_curves: curve_split.map(|(a, b, _)| (a, b)).into_iter().collect() };
if cx.replace(&change)?.is_some() {
if let Some((a, b, c)) = curve_split {
cx.set_curve(a, b, None);
cx.set_curve(a, v, Some(c));
cx.set_curve(v, b, Some(c));
}
return Ok(Some(v));
}
// Undo the vertex.
if cx.journal.recording {
cx.rollback(len)?;
} else {
cx.drop_last_point(v)?;
}
return Ok(None);
}
Plan::Drop(t) => {
if roots.contains(&t) {
return Ok(None);
}
cavity.retain(|&c| c != t);
// Keep the cavity connected to the roots.
cavity = connected(cx, &cavity, &roots)?;
}
Plan::Fail => return Ok(None),
}
}
Ok(None)
}
/// The tetrahedra a site must remove, and its boundary faces (as sorted keys) it must remove.
fn site_roots(cx: &mut TetComplex, site: Site) -> Result<(Vec<u32>, Vec<[u32; 3]>)> {
Ok(match site {
Site::Interior(t) => (vec![t], Vec::new()),
Site::Face(t, k) => {
let vs = cx.tet(t)?;
if cx.adj(t)?.get(k) != Some(&NONE) {
return Ok((Vec::new(), Vec::new()));
}
(vec![t], vec![sorted3(face_toward(vs, k))])
}
Site::Edge(a, b) => {
let Some(ring) = cx.edge_ring(a, b)? else { return Ok((Vec::new(), Vec::new())) };
if ring.closed {
return Ok((Vec::new(), Vec::new()));
}
let mut faces = Vec::new();
if let (Some(&v0), Some(&vl)) = (ring.verts.first(), ring.verts.last()) {
faces.push(sorted3([a, b, v0]));
faces.push(sorted3([a, b, vl]));
}
(ring.tets, faces)
}
})
}
enum Plan {
/// New tetrahedra as faces (to be joined to the new vertex) and new boundary edges.
Ready {
new: Vec<[u32; 3]>,
new_boundary: Vec<((u32, u32), u32)>,
},
/// Drop this tetrahedron from the cavity and try again.
Drop(u32),
Fail,
}
/// Plan the fill of a cavity from `p`.
fn plan_cavity(cx: &TetComplex, cavity: &[u32], p: &Point3, site: Site, root_faces: &[[u32; 3]]) -> Result<Plan> {
let inset: IdMap<u32, ()> = cavity.iter().map(|&t| (t, ())).collect();
// Boundary faces of cavity tetrahedra: candidates for removal (only for boundary sites).
let mut bfaces: Vec<([u32; 3], [u32; 3], u32, u32)> = Vec::new(); // (oriented, sorted, tag, tet)
let mut outer: Vec<([u32; 3], u32)> = Vec::new(); // (oriented face, owning tet)
for &t in cavity {
let vs = cx.tet(t)?;
let ns = cx.adj(t)?;
let tags = cx.tags(t)?;
for k in 0..4 {
let n = ns[k];
if n != NONE && inset.contains_key(&n) {
continue;
}
let f = face_toward(vs, k);
if n == NONE {
bfaces.push((f, sorted3(f), tags[k], t));
} else {
outer.push((f, t));
}
}
}
// Removed boundary faces D: for a boundary site, the root faces plus faces connected to them
// across non-feature edges, on the same tag, whose circumcircle contains p (approximately:
// the diametral sphere of the triangle).
let mut removed: Vec<usize> = Vec::new();
if !matches!(site, Site::Interior(_)) {
let mut queue: Vec<usize> = bfaces.iter().enumerate().filter(|(_, f)| root_faces.contains(&f.1)).map(|(i, _)| i).collect();
if queue.len() != root_faces.len() {
return Ok(Plan::Fail);
}
let px = p.coords();
let mut taken = vec![false; bfaces.len()];
for &i in &queue {
if let Some(s) = taken.get_mut(i) {
*s = true;
}
}
let mut qi = 0;
while let Some(&i) = queue.get(qi) {
qi += 1;
let Some(&(f, _, tag, _)) = bfaces.get(i) else { continue };
for e in 0..3 {
let (u, w) = (f[e], f[(e + 1) % 3]);
if cx.curve(u, w).is_some() {
continue;
}
for (j, g) in bfaces.iter().enumerate() {
if taken.get(j).copied().unwrap_or(true) || g.2 != tag || !(g.0.contains(&u) && g.0.contains(&w)) {
continue;
}
let [ga, gb, gc] = [cx.xyz(g.0[0])?, cx.xyz(g.0[1])?, cx.xyz(g.0[2])?];
let Some(o) = measure::triangle_circumcenter(ga, gb, gc) else { continue };
let r = measure::dist(o, ga);
if measure::dist(o, px) < r {
if let Some(s) = taken.get_mut(j) {
*s = true;
}
queue.push(j);
}
}
}
}
removed = queue;
// Vertices strictly inside D would be deleted: only allowed for face vertices of the
// same tag that no kept face uses.
let mut in_kept: IdMap<u32, ()> = IdMap::default();
for (j, g) in bfaces.iter().enumerate() {
if !removed.contains(&j) {
for v in g.0 {
in_kept.insert(v, ());
}
}
}
for &(f, _) in &outer {
for v in f {
in_kept.insert(v, ());
}
}
for &i in &removed {
if let Some(g) = bfaces.get(i) {
for v in g.0 {
if !in_kept.contains_key(&v) && !matches!(cx.kind(v), VertexKind::Face(_) | VertexKind::Interior) {
return Ok(Plan::Fail);
}
}
}
}
}
// Every kept face must see p strictly.
let mut new = Vec::with_capacity(outer.len() + bfaces.len());
for &(f, t) in &outer {
if cx.orient_with(f, p)? != Sign::Positive {
return Ok(Plan::Drop(t));
}
new.push(f);
}
for (j, &(f, _, _, t)) in bfaces.iter().enumerate() {
if removed.contains(&j) {
continue;
}
if cx.orient_with(f, p)? != Sign::Positive {
return Ok(Plan::Drop(t));
}
new.push(f);
}
// The outline of D (edges in exactly one removed face) becomes new boundary, fanned to p.
let mut new_boundary = Vec::new();
if !removed.is_empty() {
let mut count: IdMap<(u32, u32), ((u32, u32), u32, usize)> = IdMap::default();
for &i in &removed {
let Some(&(f, _, tag, _)) = bfaces.get(i) else { continue };
for e in 0..3 {
let (u, w) = (f[e], f[(e + 1) % 3]);
let c = count.entry(sorted2(u, w)).or_insert(((u, w), tag, 0));
c.2 += 1;
}
}
let split = match site {
Site::Edge(a, b) => Some(sorted2(a, b)),
_ => None,
};
let mut edges: Vec<((u32, u32), u32)> = count.into_iter().filter(|(k, c)| c.2 == 1 && Some(*k) != split).map(|(_, c)| (c.0, c.1)).collect();
edges.sort_unstable();
new_boundary = edges;
}
// A cavity whose tetrahedra all go must still leave something to fill.
if new.is_empty() {
return Ok(Plan::Fail);
}
Ok(Plan::Ready { new, new_boundary })
}
/// The tetrahedra of `cavity` connected to the roots through faces inside the cavity.
fn connected(cx: &TetComplex, cavity: &[u32], roots: &[u32]) -> Result<Vec<u32>> {
let inset: IdMap<u32, ()> = cavity.iter().map(|&t| (t, ())).collect();
let mut seen: IdMap<u32, ()> = IdMap::default();
let mut out: Vec<u32> = Vec::new();
for &r in roots {
if inset.contains_key(&r) && seen.insert(r, ()).is_none() {
out.push(r);
}
}
let mut i = 0;
while let Some(&t) = out.get(i) {
i += 1;
for n in cx.adj(t)? {
if n != NONE && inset.contains_key(&n) && seen.insert(n, ()).is_none() {
out.push(n);
}
}
}
Ok(out)
}
/// The edges of a set of tetrahedra, sorted and unique.
pub fn edges_of(cx: &TetComplex, tets: &[u32]) -> Result<Vec<(u32, u32)>> {
let mut out = Vec::with_capacity(tets.len() * 6);
for &t in tets {
let vs = cx.tet(t)?;
out.extend(EDGES.iter().map(|&(i, j)| sorted2(vs[i], vs[j])));
}
out.sort_unstable();
out.dedup();
Ok(out)
}
/// Collapse vertex `a` onto its neighbour `b` (edge contraction): every tetrahedron around `a`
/// takes `b` in its place and those holding the edge disappear. Allowed when `a` may move to `b`
/// without leaving its boundary entity — an interior vertex anywhere, a face vertex along an edge
/// inside its B-rep face, a curve vertex along its curve, never a corner — when the boundary
/// faces it changes keep their orientation within 30°, every new edge is at most `max_len`, and
/// the worst new tetrahedron is at least `min(worst old, floor)`. Returns whether it collapsed.
///
/// Edge contraction is the coarsening operation of mesh adaptation by local modification (X. Li,
/// M. S. Shephard, M. W. Beall, *3D anisotropic mesh adaptation by mesh modification*, CMAME
/// 194, 2005); here it removes the boundary recovery's closely spaced Steiner points and dense
/// tessellation vertices where the size field asks for larger elements.
pub fn collapse(cx: &mut TetComplex, a: u32, b: u32, max_len: f64, floor: f64) -> Result<bool> {
let ka = cx.kind(a);
let allowed = match ka {
VertexKind::Corner => false,
VertexKind::Interior => true,
VertexKind::Curve(c) => cx.curve(a, b) == Some(c),
VertexKind::Face(tag) => {
if cx.curve(a, b).is_some() {
false
} else {
match cx.kind(b) {
VertexKind::Interior => false,
_ => {
// The edge must be a boundary edge inside face `tag`.
let Some(ring) = cx.edge_ring(a, b)? else { return Ok(false) };
if ring.closed {
false
} else {
let ends = [ring.tets.first().copied(), ring.tets.last().copied()];
let (v0, vl) = (ring.verts.first().copied().unwrap_or(NONE), ring.verts.last().copied().unwrap_or(NONE));
boundary_tag(cx, ends[0].unwrap_or(NONE), [a, b, v0])? == Some(tag)
&& boundary_tag(cx, ends[1].unwrap_or(NONE), [a, b, vl])? == Some(tag)
}
}
}
}
}
};
if !allowed || cx.edge_tet(a, b)?.is_none() {
return Ok(false);
}
let star = cx.star(a)?;
let before = worst(cx, &star)?;
let xb = cx.xyz(b)?;
let mut new = Vec::with_capacity(star.len());
let mut new_boundary = Vec::new();
for &t in &star {
let vs = cx.tet(t)?;
if vs.contains(&b) {
continue;
}
let moved = vs.map(|v| if v == a { b } else { v });
for v in moved {
if v != b && measure::dist(cx.xyz(v)?, xb) > max_len {
return Ok(false);
}
}
let (ns, tags) = (cx.adj(t)?, cx.tags(t)?);
for k in 0..4 {
if ns[k] != NONE || vs[k] == a {
continue;
}
let f = face_toward(vs, k);
let g = f.map(|v| if v == a { b } else { v });
let n0 = measure::triangle_normal(cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?);
let n1 = measure::triangle_normal(cx.xyz(g[0])?, cx.xyz(g[1])?, cx.xyz(g[2])?);
if !(measure::dot(n0, n1) >= COLLAPSE_NORMAL_COS) {
return Ok(false);
}
new_boundary.push((g, tags[k]));
}
new.push(moved);
}
if new.is_empty() {
return Ok(false);
}
let mut after = f64::INFINITY;
for vs in &new {
after = after.min(tet_quality(cx, *vs)?);
}
if !(after > 0.0) || after < before.min(floor) {
return Ok(false);
}
// Curve edges at a move to b.
let mut drop = Vec::new();
let mut add = Vec::new();
if let VertexKind::Curve(c) = ka {
for u in cx.neighbours(a)? {
if cx.curve(a, u) == Some(c) {
drop.push((a, u));
if u != b {
add.push((b, u, c));
}
}
}
}
let change = Change { old: star, new, new_boundary, drop_curves: drop.clone() };
if cx.replace(&change)?.is_none() {
return Ok(false);
}
for (x, y) in drop {
cx.set_curve(x, y, None);
}
for (x, y, c) in add {
cx.set_curve(x, y, Some(c));
}
Ok(true)
}
/// cos 30°: the largest turn of a boundary face's normal a collapse may cause.
const COLLAPSE_NORMAL_COS: f64 = 0.866_025_403_784_438_6;
+120
View File
@@ -0,0 +1,120 @@
//! Quadratic tetrahedra (tet10): a node on every edge, at the midpoint inside the domain and on
//! the geometry on the boundary — projected onto the edge's feature curve, or onto the B-rep face
//! of a boundary edge inside one face — so curved boundaries are represented to second order.
//!
//! A curved edge can make an element invalid (a thin element against a strongly curved face).
//! Every element is checked with the certified Jacobian test that the validity gate uses
//! ([`crate::quality::element_validity`], which proves FieldCraft's criterion on the whole
//! element); an element that fails gets its curved edges straightened (the node goes back to the
//! straight midpoint, for every element sharing that edge) and the elements around are checked
//! again. Straightening only ever moves nodes back to the linear mesh, which is valid, so the loop
//! ends; it is also capped by the number of edges.
use fieldcraft_model::ElementKind;
use crate::quality::{Validity, element_validity};
use crate::{Error, Result};
use super::TaggedFace;
use super::complex::{IdMap, NONE, face_toward, sorted2};
use super::geometry::{BoundaryGeometry, Tessellation};
use super::measure;
/// Tet10 mid-side nodes on edges 1-2, 2-3, 3-1, 1-4, 2-4, 3-4 (the model's node order).
use crate::quality::jacobian::TET_EDGES as MID_EDGES;
/// The quadratic mesh.
#[derive(Debug, Clone, PartialEq)]
pub struct Quadratic {
pub points: Vec<[f64; 3]>,
pub elements: Vec<Vec<u32>>,
/// Edges left straight to keep their elements valid.
pub straightened: usize,
}
/// Tet10 elements from tet4 ones (see the module docs).
pub fn tet10(
points: &[[f64; 3]],
tets: &[[u32; 4]],
boundary: &[TaggedFace],
curves: &IdMap<(u32, u32), u32>,
curve_faces: &[[u32; 2]],
geometry: &Tessellation,
) -> Result<Quadratic> {
// Boundary edges and the B-rep face they lie on.
let mut edge_face: IdMap<(u32, u32), u32> = IdMap::default();
for b in boundary {
let t = tets.get(b.tet as usize).ok_or_else(|| Error::Failed("tet10: boundary face of no tetrahedron".into()))?;
let f = face_toward(*t, usize::from(b.opposite));
for i in 0..3 {
edge_face.entry(sorted2(f[i], f[(i + 1) % 3])).or_insert(b.face);
}
}
let p = |v: u32| points.get(v as usize).copied().ok_or_else(|| Error::Failed("tet10: vertex id".into()));
let mut out = points.to_vec();
// Edge -> (node, straight midpoint).
let mut node: IdMap<(u32, u32), (u32, [f64; 3])> = IdMap::default();
let mut elements = Vec::with_capacity(tets.len());
for t in tets {
let mut e = t.to_vec();
for &(i, j) in &MID_EDGES {
let key = sorted2(t[i], t[j]);
let id = match node.get(&key) {
Some(&(id, _)) => id,
None => {
let m = measure::mid(p(t[i])?, p(t[j])?);
let curved = match curves.get(&key) {
Some(&c) => curve_faces.get(c as usize).and_then(|f| geometry.project_curve(*f, m)),
None => edge_face.get(&key).and_then(|&f| geometry.project_face(f, m)),
};
let id = u32::try_from(out.len()).map_err(|_| Error::Limit("too many nodes".into()))?;
out.push(curved.filter(|q| q.iter().all(|x| x.is_finite())).unwrap_or(m));
node.insert(key, (id, m));
id
}
};
e.push(id);
}
elements.push(e);
}
// Straighten what makes an element invalid.
let coords = |out: &[[f64; 3]], e: &[u32]| -> Result<Vec<[f64; 3]>> {
e.iter().map(|&v| out.get(v as usize).copied().ok_or_else(|| Error::Failed("tet10: node id".into()))).collect()
};
let mut straightened = 0;
let mut check: Vec<usize> = (0..elements.len()).collect();
let mut edge_elements: IdMap<(u32, u32), Vec<usize>> = IdMap::default();
for (n, t) in tets.iter().enumerate() {
for &(i, j) in &MID_EDGES {
edge_elements.entry(sorted2(t[i], t[j])).or_default().push(n);
}
}
for _ in 0..=node.len() {
let mut next = Vec::new();
for &n in &check {
let Some(e) = elements.get(n) else { continue };
if element_validity(ElementKind::Tet10, &coords(&out, e)?)?.validity == Validity::Valid {
continue;
}
let t = tets.get(n).copied().unwrap_or([NONE; 4]);
for &(i, j) in &MID_EDGES {
let key = sorted2(t[i], t[j]);
let Some(&(id, m)) = node.get(&key) else { continue };
if out.get(id as usize) != Some(&m) {
if let Some(s) = out.get_mut(id as usize) {
*s = m;
}
straightened += 1;
next.extend(edge_elements.get(&key).cloned().unwrap_or_default());
}
}
}
next.sort_unstable();
next.dedup();
if next.is_empty() {
break;
}
check = next;
}
Ok(Quadratic { points: out, elements, straightened })
}
+391
View File
@@ -0,0 +1,391 @@
//! Constrained Delaunay refinement to a size field.
//!
//! A tetrahedron is bad when its radius–edge ratio exceeds a bound `B` (about 2) or its
//! circumradius exceeds the local target size; its circumcentre is inserted (Delaunay insertion,
//! [`ops::insert_delaunay`]). A circumcentre outside the domain, or inside the diametral sphere of
//! a boundary face (it *encroaches* the face), is not inserted; the encroached boundary face is
//! split instead, at its circumcentre when that lies inside it and otherwise at the midpoint of
//! its longest edge (which splits a feature edge when the edge is one), and every boundary point
//! is projected onto its B-rep face or feature curve ([`super::geometry`]).
//!
//! Sources: J. R. Shewchuk, *Tetrahedral Mesh Generation by Delaunay Refinement*, SoCG 1998
//! (radius–edge refinement with encroachment rules, termination for B = 2 and input angles of 90°
//! or more); H. Si, *Constrained Delaunay tetrahedral mesh generation and refinement*, FEAD
//! 46(1–2), 2010 (refinement of a constrained Delaunay tetrahedralization with small input
//! angles). CAD boundaries have small angles, so termination is guaranteed here by bounds
//! instead: a boundary face smaller than a fraction of the local size is not split, a
//! tetrahedron whose point could not be inserted is not retried, and the number of insertions
//! and tetrahedra are capped (the stop is reported).
use crate::predicates::{Point3, snap_to_domain};
use crate::size::SizeField;
use crate::{Error, Result};
use super::complex::{IdMap, NONE, TetComplex, VertexKind, Walk, face_toward, sorted2, sorted4};
use super::geometry::BoundaryGeometry;
use super::measure::{self, P};
use super::ops::{self, Site};
/// What the boundary insertions project onto.
pub struct Context<'a> {
pub size: &'a dyn SizeField,
pub geometry: &'a dyn BoundaryGeometry,
/// The B-rep faces of each feature curve (by curve id).
pub curve_faces: &'a [[u32; 2]],
}
impl Context<'_> {
/// The local target size, or `fallback` when the field misbehaves.
pub fn h(&self, p: P, fallback: f64) -> f64 {
let h = self.size.size(p);
if h > 0.0 && h.is_finite() { h } else { fallback }
}
/// `p` moved onto the boundary entity of a vertex of `kind`, as an admissible point.
pub fn place(&self, kind: VertexKind, p: P) -> Option<Point3> {
let q = match kind {
VertexKind::Interior => p,
VertexKind::Face(f) => self.geometry.project_face(f, p)?,
VertexKind::Curve(c) => self.geometry.project_curve(*self.curve_faces.get(c as usize)?, p)?,
VertexKind::Corner => return None,
};
Point3::try_from(q.map(snap_to_domain)).ok()
}
}
/// Refinement settings.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RefineOptions {
/// The radius–edge bound `B`.
pub radius_edge: f64,
/// A tetrahedron is too large when its circumradius exceeds this times the local size (the
/// regular tetrahedron with edge `h` has circumradius 0.61 h).
pub size_factor: f64,
/// Boundary faces whose circumradius is below this times the local size are not split.
pub min_face: f64,
/// A tetrahedron is refined for its shape (radius–edge ratio) only when its circumradius is
/// at least this times the local size; smaller ones are left to smoothing and improvement,
/// so badly shaped input near the boundary does not cascade into refinement far below the
/// target size.
pub min_shape_radius: f64,
/// Most vertices inserted.
pub max_insertions: usize,
}
impl Default for RefineOptions {
fn default() -> Self {
RefineOptions { radius_edge: 2.0, size_factor: 0.95, min_face: 0.5, min_shape_radius: 0.5, max_insertions: 2_000_000 }
}
}
/// What a refinement did.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RefineStats {
pub interior: usize,
pub boundary: usize,
pub skipped: usize,
/// Why it stopped before every tetrahedron was good, if it did.
pub stopped: Option<String>,
}
/// Refinement never puts a vertex closer than this times the local size to another.
pub const MIN_GAP: f64 = 0.05;
/// Most tetrahedra a Delaunay cavity may hold.
pub const MAX_CAVITY: usize = 4096;
/// Most steps of a point location walk.
const MAX_WALK: usize = 1 << 16;
/// The boundary face of a tetrahedron whose diametral sphere holds `p`, if any (the largest).
fn encroached(cx: &TetComplex, tets: &[u32], p: P) -> Result<Option<(u32, usize)>> {
let mut best: Option<(f64, u32, usize)> = None;
for &t in tets {
let vs = cx.tet(t)?;
let ns = cx.adj(t)?;
for k in 0..4 {
if ns[k] != NONE {
continue;
}
let f = face_toward(vs, k);
let (a, b, c) = (cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?);
let Some(o) = measure::triangle_circumcenter(a, b, c) else { continue };
let r = measure::dist(o, a);
if measure::dist(o, p) < r && best.is_none_or(|(br, _, _)| r > br) {
best = Some((r, t, k));
}
}
}
Ok(best.map(|(_, t, k)| (t, k)))
}
/// The tetrahedra whose circumsphere holds `p`, grown from `t` across interior faces.
fn conflicts(cx: &TetComplex, t: u32, p: &Point3) -> Result<Vec<u32>> {
let mut out = vec![t];
let mut seen: IdMap<u32, ()> = IdMap::default();
seen.insert(t, ());
let mut i = 0;
while let Some(&u) = out.get(i) {
i += 1;
if out.len() > MAX_CAVITY {
break;
}
for n in cx.adj(u)? {
if n != NONE && !seen.contains_key(&n) {
seen.insert(n, ());
if cx.in_sphere(cx.tet(n)?, p)? {
out.push(n);
}
}
}
}
Ok(out)
}
/// Split boundary face `k` of tetrahedron `t`: at its circumcentre when inside it, else at the
/// midpoint of its longest edge. Returns the new vertex.
pub fn split_face(cx: &mut TetComplex, ctx: &Context, t: u32, k: usize, min_face: f64) -> Result<Option<u32>> {
let vs = cx.tet(t)?;
if cx.adj(t)?.get(k) != Some(&NONE) {
return Ok(None);
}
let tag = cx.tags(t)?.get(k).copied().unwrap_or(NONE);
let f = face_toward(vs, k);
let x = [cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?];
let Some(o) = measure::triangle_circumcenter(x[0], x[1], x[2]) else { return Ok(None) };
let r = measure::dist(o, x[0]);
let centroid = measure::scale(measure::add(measure::add(x[0], x[1]), x[2]), 1.0 / 3.0);
if r < min_face * ctx.h(centroid, r) {
return Ok(None);
}
// Barycentric coordinates of o (signed areas against the face normal).
let n = measure::cross(measure::sub(x[1], x[0]), measure::sub(x[2], x[0]));
let inside = (0..3).all(|i| {
let (a, b) = (x[(i + 1) % 3], x[(i + 2) % 3]);
measure::dot(measure::cross(measure::sub(b, a), measure::sub(o, a)), n) >= 0.0
});
if inside && tag != NONE {
// A point inside the diametral sphere of one of the face's feature edges encroaches it:
// split the edge instead (Shewchuk 1998).
for i in 0..3 {
let (u, w) = (f[i], f[(i + 1) % 3]);
if cx.curve(u, w).is_some() {
let (a, b) = (x[i], x[(i + 1) % 3]);
if measure::dist(o, measure::mid(a, b)) < 0.5 * measure::dist(a, b) {
return split_edge(cx, ctx, u, w, tag, min_face);
}
}
}
let kind = VertexKind::Face(tag);
let Some(p) = ctx.place(kind, o) else { return Ok(None) };
// The projection may only move the point a little, and never next to a corner.
let shortest = (0..3).map(|i| measure::dist(x[i], x[(i + 1) % 3])).fold(f64::INFINITY, f64::min);
if measure::dist(p.coords(), o) > 0.5 * r || x.iter().any(|c| measure::dist(*c, p.coords()) < 0.25 * shortest) {
return Ok(None);
}
return ops::insert_delaunay_apart(cx, p, kind, Site::Face(t, k), MAX_CAVITY, MIN_GAP * ctx.h(o, r));
}
// The longest edge.
let (mut u, mut w, mut len) = (f[0], f[1], -1.0);
for i in 0..3 {
let l = measure::dist(x[i], x[(i + 1) % 3]);
if l > len {
(u, w, len) = (f[i], f[(i + 1) % 3], l);
}
}
split_edge(cx, ctx, u, w, tag, min_face)
}
/// Split boundary edge `uw` at its midpoint, projected onto its feature curve or onto B-rep face
/// `tag`. An edge shorter than `2 · min_face` times the local size is not split.
pub fn split_edge(cx: &mut TetComplex, ctx: &Context, u: u32, w: u32, tag: u32, min_face: f64) -> Result<Option<u32>> {
let m = measure::mid(cx.xyz(u)?, cx.xyz(w)?);
let len = measure::dist(cx.xyz(u)?, cx.xyz(w)?);
if len < 2.0 * min_face * ctx.h(m, len) {
return Ok(None);
}
let kind = match cx.curve(u, w) {
Some(c) => VertexKind::Curve(c),
None if tag != NONE => VertexKind::Face(tag),
None => return Ok(None),
};
let Some(p) = ctx.place(kind, m) else { return Ok(None) };
// The projection may only move the midpoint a little (a quarter of the edge).
if measure::dist(p.coords(), m) > 0.25 * len {
return Ok(None);
}
ops::insert_delaunay_apart(cx, p, kind, Site::Edge(u, w), MAX_CAVITY, MIN_GAP * ctx.h(m, len))
}
/// Whether a tetrahedron is bad, and its circumcentre.
fn badness(x: &[P; 4], ctx: &Context, o: &RefineOptions) -> Option<P> {
let c = measure::circumcenter(x)?;
let r = measure::dist(c, x[0]);
let (lmin, lmax) = measure::edge_range(x);
let h = ctx.h(measure::centroid(x), lmax);
((r > o.radius_edge * lmin && r > o.min_shape_radius * h) || r > o.size_factor * h).then_some(c)
}
/// Refine until no tetrahedron is bad (see the module docs).
pub fn refine(cx: &mut TetComplex, ctx: &Context, o: &RefineOptions) -> Result<RefineStats> {
let mut stats = RefineStats::default();
let mut queue: Vec<u32> = cx.live_tets().map(|(t, _)| t).collect();
let mut given_up: IdMap<[u32; 4], ()> = IdMap::default();
let mut head = 0usize;
let mut inserted = 0usize;
while head < queue.len() {
let t = queue.get(head).copied().unwrap_or(NONE);
head += 1;
// Keep the queue's memory bounded: drop the processed prefix now and then.
if head > 1 << 20 {
queue.drain(..head);
head = 0;
}
if !cx.is_live(t) {
continue;
}
let vs = cx.tet(t)?;
let key = sorted4(vs);
if given_up.contains_key(&key) {
continue;
}
let x = cx.coords(vs)?;
let Some(c) = badness(&x, ctx, o) else { continue };
if inserted >= o.max_insertions {
stats.stopped = Some(format!("refinement stopped at {} inserted vertices", o.max_insertions));
break;
}
if cx.live_count() + 64 >= cx.max_tets() || cx.point_count() + 64 >= cx.max_points() {
stats.stopped = Some(format!("refinement stopped at {} tetrahedra and {} vertices", cx.live_count(), cx.point_count()));
break;
}
let result = match Point3::try_from(c.map(snap_to_domain)) {
Err(_) => None,
Ok(p) => {
// The boundary face the circumcentre lies beyond or encroaches, if any.
let (inside, face) = match cx.walk(t, &p, MAX_WALK)? {
None => (None, None),
Some(Walk::Outside(tb, k)) => (None, Some((tb, k))),
Some(Walk::Inside(ti)) => (Some(ti), encroached(cx, &conflicts(cx, ti, &p)?, c)?),
};
match (inside, face) {
(_, Some((tb, k))) => {
let v = split_face(cx, ctx, tb, k, o.min_face)?;
stats.boundary += usize::from(v.is_some());
v
}
(Some(ti), None) => {
let v = ops::insert_delaunay_apart(cx, p, VertexKind::Interior, Site::Interior(ti), MAX_CAVITY, MIN_GAP * ctx.h(c, 1.0))?;
stats.interior += usize::from(v.is_some());
v
}
(None, None) => None,
}
}
};
match result {
Some(v) => {
inserted += 1;
queue.extend(cx.star(v)?);
// The bad tetrahedron may survive a boundary split: look at it again.
if cx.is_live(t) {
queue.push(t);
}
}
None => {
stats.skipped += 1;
given_up.insert(key, ());
}
}
}
Ok(stats)
}
/// The feature curves of a boundary: an edge between two boundary faces is a feature edge when
/// their tags differ or their normals turn by more than `feature_cos` (cosine). Returns the
/// curve table (face pairs) and the edges with their curve.
pub fn feature_curves(cx: &TetComplex, feature_cos: f64) -> Result<(Vec<[u32; 2]>, Vec<((u32, u32), u32)>)> {
// Boundary edge -> (tag, oriented normal) of each side.
let mut sides: IdMap<(u32, u32), Vec<(u32, P)>> = IdMap::default();
for (t, vs) in cx.live_tets() {
let ns = cx.adj(t)?;
let tags = cx.tags(t)?;
for k in 0..4 {
if ns[k] != NONE {
continue;
}
let f = face_toward(vs, k);
let n = measure::triangle_normal(cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?);
for i in 0..3 {
sides.entry(sorted2(f[i], f[(i + 1) % 3])).or_default().push((tags[k], n));
}
}
}
let mut keys: Vec<(u32, u32)> = sides.keys().copied().collect();
keys.sort_unstable();
let mut table: Vec<[u32; 2]> = Vec::new();
let mut index: IdMap<[u32; 2], u32> = IdMap::default();
let mut edges = Vec::new();
for e in keys {
let s = sides.get(&e).map(Vec::as_slice).unwrap_or_default();
let [(ta, na), (tb, nb)] = s else {
return Err(Error::Failed(format!("boundary edge {e:?} has {} faces", s.len())));
};
if ta != tb || measure::dot(*na, *nb) < feature_cos {
let pair = [(*ta).min(*tb), (*ta).max(*tb)];
let id = match index.get(&pair) {
Some(&id) => id,
None => {
let id = table.len() as u32;
table.push(pair);
index.insert(pair, id);
id
}
};
edges.push((e, id));
}
}
Ok((table, edges))
}
/// Coarsening: collapse edges shorter than `short` times the local size, shortest (relative)
/// first, each onto whichever end it may move to ([`ops::collapse`]); new edges stay below
/// `long` times the size, and the worst tetrahedron around does not drop below
/// `min(its old worst, floor)`. At most `passes` passes. Returns the collapses.
pub fn coarsen(cx: &mut TetComplex, ctx: &Context, short: f64, long: f64, floor: f64, passes: usize) -> Result<usize> {
let mut done = 0;
for _ in 0..passes {
let mut edges: Vec<(f64, u32, u32)> = Vec::new();
let mut seen: IdMap<(u32, u32), ()> = IdMap::default();
for (_, vs) in cx.live_tets() {
for &(i, j) in &super::complex::EDGES {
let e = sorted2(vs[i], vs[j]);
if seen.insert(e, ()).is_some() {
continue;
}
let (a, b) = (cx.xyz(e.0)?, cx.xyz(e.1)?);
let l = measure::dist(a, b);
let h = ctx.h(measure::mid(a, b), l);
if l < short * h {
edges.push((l / h, e.0, e.1));
}
}
}
edges.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.cmp(&y.1)).then(x.2.cmp(&y.2)));
let mut changed = 0;
for (_, a, b) in edges {
if !cx.is_used(a) || !cx.is_used(b) {
continue;
}
let (pa, pb) = (cx.xyz(a)?, cx.xyz(b)?);
let max_len = long * ctx.h(measure::mid(pa, pb), measure::dist(pa, pb));
if ops::collapse(cx, a, b, max_len, floor)? || ops::collapse(cx, b, a, max_len, floor)? {
changed += 1;
}
}
done += changed;
if changed == 0 {
break;
}
}
Ok(done)
}
+294
View File
@@ -0,0 +1,294 @@
//! Vertex smoothing: optimal Delaunay triangulation (ODT) moves, and optimization-based smoothing
//! of the worst tetrahedron around a vertex.
//!
//! Boundary vertices move on their B-rep face (in its tangent plane, then projected) or along
//! their feature curve; corners never move. Every move keeps every tetrahedron around the vertex
//! positive (exact `orient3d`), or is not made.
//!
//! Sources:
//! - L. Chen, J. Xu, *Optimal Delaunay triangulations*, J. Comput. Math. 22(2), 2004; L. Chen,
//! M. Holst, *Efficient mesh optimization schemes based on Optimal Delaunay Triangulations*,
//! CMAME 200, 2011; P. Alliez, D. Cohen-Steiner, M. Yvinec, M. Desbrun, *Variational
//! tetrahedral meshing*, ACM TOG 24(3), 2005: the ODT update moves a vertex to the
//! volume-weighted average of the circumcentres of its tetrahedra, weighted here by the density
//! `h⁻³` of the size field (J. Tournois, C. Wormser, P. Alliez, M. Desbrun, *Interleaving
//! Delaunay refinement and optimization for practical isotropic tetrahedron mesh generation*,
//! ACM TOG 28(3), 2009, which interleaves these moves with refinement).
//! - L. Freitag, M. Jones, P. Plassmann, *An efficient parallel algorithm for mesh smoothing*,
//! IMR 1995; L. Freitag, C. Ollivier-Gooch, *Tetrahedral mesh improvement using swapping and
//! smoothing*, IJNME 40, 1997; Klingner and Shewchuk 2007, §3.1: nonsmooth optimization of the
//! worst quality around a vertex (active set, steepest ascent along the smallest vector in the
//! convex hull of the active gradients, line search), applied only when the worst improves.
use crate::Result;
use super::complex::{NONE, TetComplex, VertexKind, face_toward};
use super::measure::{self, P, add, dot, scale, sub};
use super::refine::Context;
/// The worst quality around `v` with `v` at `p` (the tetrahedra of `star`).
fn star_quality(cx: &TetComplex, v: u32, p: P, star: &[u32]) -> Result<f64> {
let mut q = f64::INFINITY;
for &t in star {
let vs = cx.tet(t)?;
let mut x = cx.coords(vs)?;
for (i, &u) in vs.iter().enumerate() {
if u == v
&& let Some(s) = x.get_mut(i)
{
*s = p;
}
}
q = q.min(measure::quality(&x));
}
Ok(q)
}
/// How `v` may move: `None` for a corner, the identity for an interior vertex, the tangent
/// plane's normal for a face vertex, the tangent line for a curve vertex.
#[derive(Debug, Clone, Copy)]
enum Freedom {
Free,
Plane(P),
Line(P),
}
fn freedom(cx: &mut TetComplex, v: u32, star: &[u32]) -> Result<Option<Freedom>> {
Ok(match cx.kind(v) {
VertexKind::Corner => None,
VertexKind::Interior => Some(Freedom::Free),
VertexKind::Face(_) => {
let mut n = [0.0; 3];
for &t in star {
let vs = cx.tet(t)?;
let ns = cx.adj(t)?;
for k in 0..4 {
if ns[k] == NONE && vs[k] != v {
let f = face_toward(vs, k);
let (a, b, c) = (cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?);
n = add(n, measure::cross(sub(b, a), sub(c, a)));
}
}
}
let l = measure::norm(n);
if !(l > 0.0) {
return Ok(None);
}
Some(Freedom::Plane(scale(n, 1.0 / l)))
}
VertexKind::Curve(c) => {
let ends: Vec<u32> = cx.neighbours(v)?.into_iter().filter(|&u| cx.curve(v, u) == Some(c)).collect();
let [a, b] = ends.as_slice() else { return Ok(None) };
let d = sub(cx.xyz(*b)?, cx.xyz(*a)?);
let l = measure::norm(d);
if !(l > 0.0) {
return Ok(None);
}
Some(Freedom::Line(scale(d, 1.0 / l)))
}
})
}
fn restrict(d: P, f: Freedom) -> P {
match f {
Freedom::Free => d,
Freedom::Plane(n) => sub(d, scale(n, dot(d, n))),
Freedom::Line(t) => scale(t, dot(d, t)),
}
}
/// Try moving `v` to `target` (restricted to its freedom and placed on its boundary entity),
/// at full, half and quarter steps; accept the first that keeps the star valid and its worst
/// quality at least `floor`. Returns whether it moved.
fn try_move(cx: &mut TetComplex, ctx: &Context, v: u32, star: &[u32], f: Freedom, target: P, floor: f64) -> Result<bool> {
let x = cx.xyz(v)?;
let d = restrict(sub(target, x), f);
for s in [1.0, 0.5, 0.25] {
let Some(p) = ctx.place(cx.kind(v), add(x, scale(d, s))) else { continue };
if p.coords() == x || !cx.move_is_valid(v, &p, star)? {
continue;
}
if star_quality(cx, v, p.coords(), star)? >= floor {
cx.move_point(v, p)?;
return Ok(true);
}
}
Ok(false)
}
/// The quality a smoothing move may not go below (sin 17.5°) unless the star was already worse.
const ODT_FLOOR: f64 = 0.3;
/// One ODT pass over the vertices (ascending ids) that may move. Returns the number moved.
pub fn odt_pass(cx: &mut TetComplex, ctx: &Context) -> Result<usize> {
let mut moved = 0;
for v in 0..cx.point_count() as u32 {
if !cx.is_used(v) || cx.kind(v) == VertexKind::Corner {
continue;
}
let star = cx.star(v)?;
let Some(f) = freedom(cx, v, &star)? else { continue };
let (mut num, mut den) = ([0.0; 3], 0.0);
for &t in &star {
let x = cx.coords(cx.tet(t)?)?;
let vol = measure::volume6(&x) / 6.0;
let g = measure::centroid(&x);
let (_, lmax) = measure::edge_range(&x);
let c = measure::circumcenter(&x).filter(|c| measure::dist(*c, g) <= lmax).unwrap_or(g);
let h = ctx.h(g, lmax);
let w = vol / (h * h * h);
if !(w > 0.0 && w.is_finite()) {
continue;
}
num = add(num, scale(c, w));
den += w;
}
if !(den > 0.0) {
continue;
}
let target = scale(num, 1.0 / den);
let before = star_quality(cx, v, cx.xyz(v)?, &star)?;
if try_move(cx, ctx, v, &star, f, target, before.min(ODT_FLOOR))? {
moved += 1;
}
}
Ok(moved)
}
/// The smallest vector in the convex hull of `g` (Frank–Wolfe iterations).
fn min_norm(g: &[P]) -> P {
let Some(&first) = g.first() else { return [0.0; 3] };
let mut d = first;
for _ in 0..64 {
let mut s = first;
let mut best = f64::INFINITY;
for &gi in g {
let v = dot(gi, d);
if v < best {
best = v;
s = gi;
}
}
let e = sub(d, s);
let gap = dot(e, d);
let ee = dot(e, e);
if !(gap > 1e-30) || !(ee > 0.0) {
break;
}
let gamma = (gap / ee).clamp(0.0, 1.0);
d = sub(d, scale(e, gamma));
}
d
}
/// The star of a vertex as plain coordinates: per tetrahedron, its four corners and the slot of
/// the moving vertex (so a trial position costs no lookups).
struct LocalStar {
tets: Vec<([P; 4], usize)>,
}
impl LocalStar {
fn new(cx: &TetComplex, v: u32, star: &[u32]) -> Result<LocalStar> {
let mut tets = Vec::with_capacity(star.len());
for &t in star {
let vs = cx.tet(t)?;
let i = vs.iter().position(|&u| u == v).unwrap_or(0);
tets.push((cx.coords(vs)?, i));
}
Ok(LocalStar { tets })
}
fn quality_at(&self, k: usize, p: P) -> f64 {
let Some((x, i)) = self.tets.get(k) else { return f64::NEG_INFINITY };
let mut y = *x;
if let Some(s) = y.get_mut(*i) {
*s = p;
}
measure::quality(&y)
}
fn worst_at(&self, p: P) -> f64 {
(0..self.tets.len()).map(|k| self.quality_at(k, p)).fold(f64::INFINITY, f64::min)
}
}
/// Optimization-based smoothing of `v`: raise the worst quality of its star (see the module
/// docs). Returns whether it moved.
pub fn smooth_vertex(cx: &mut TetComplex, ctx: &Context, v: u32) -> Result<bool> {
if !cx.is_used(v) || cx.kind(v) == VertexKind::Corner {
return Ok(false);
}
let star = cx.star(v)?;
let Some(f) = freedom(cx, v, &star)? else { return Ok(false) };
let local = LocalStar::new(cx, v, &star)?;
let x0 = cx.xyz(v)?;
// A length scale: the mean edge length from v.
let mut len = 0.0;
let mut count = 0usize;
for (x, i) in &local.tets {
for (k, p) in x.iter().enumerate() {
if k != *i {
len += measure::dist(*p, x0);
count += 1;
}
}
}
let len = len / count.max(1) as f64;
if !(len > 0.0 && len.is_finite()) {
return Ok(false);
}
let delta = 1e-6 * len;
let mut x = x0;
let mut worst = local.worst_at(x);
let mut moved = false;
for _ in 0..8 {
// Qualities and gradients (central differences) of the tetrahedra near the worst.
let mut qs = Vec::with_capacity(local.tets.len());
for k in 0..local.tets.len() {
let q = local.quality_at(k, x);
let mut g = [0.0; 3];
if q <= worst + 0.1 {
for (c, gc) in g.iter_mut().enumerate() {
let (mut a, mut b) = (x, x);
a[c] += delta;
b[c] -= delta;
*gc = (local.quality_at(k, a) - local.quality_at(k, b)) / (2.0 * delta);
}
}
qs.push((q, restrict(g, f)));
}
let active: Vec<P> = qs.iter().filter(|s| s.0 <= worst + 1e-4).map(|s| s.1).collect();
let d = min_norm(&active);
let dd = dot(d, d);
if !(dd > 1e-20) {
break;
}
// The step at which the linear model of an inactive tetrahedron meets the worst, capped
// to a fifth of the edge length.
let mut alpha = 0.2 * len / dd.sqrt();
for &(q, g) in &qs {
let gd = dot(g, d);
if q > worst + 1e-4 && q <= worst + 0.1 && gd < dd {
alpha = alpha.min((q - worst) / (dd - gd));
}
}
let mut accepted = false;
for _ in 0..6 {
let Some(p) = ctx.place(cx.kind(v), add(x, scale(d, alpha))) else { break };
let q = local.worst_at(p.coords());
if p.coords() != x && q > worst && cx.move_is_valid(v, &p, &star)? {
cx.move_point(v, p)?;
x = p.coords();
worst = q;
accepted = true;
moved = true;
break;
}
alpha *= 0.5;
}
if !accepted {
break;
}
}
Ok(moved)
}
+252
View File
@@ -0,0 +1,252 @@
use super::complex::*;
use super::measure;
use super::ops::*;
use crate::predicates::Point3;
/// A cube [0, 1]³ split into six tetrahedra around the diagonal 0–6, boundary faces tagged by
/// cube side (0..6).
pub(crate) fn cube() -> TetComplex {
let p: Vec<[f64; 3]> = (0..8).map(|i| [(i & 1) as f64, ((i >> 1) & 1) as f64, ((i >> 2) & 1) as f64]).collect();
// Vertex i = (x, y, z) bits; diagonal 0 → 7.
let raw = [[0, 1, 3, 7], [0, 3, 2, 7], [0, 2, 6, 7], [0, 6, 4, 7], [0, 4, 5, 7], [0, 5, 1, 7]];
let pts: Vec<Point3> = p.iter().map(|c| Point3::try_from(*c).unwrap()).collect();
let tets: Vec<[u32; 4]> = raw
.iter()
.map(|t| {
let t = t.map(|v: usize| v as u32);
let x = t.map(|v| pts[v as usize].coords());
if measure::volume6(&x) > 0.0 { t } else { [t[1], t[0], t[2], t[3]] }
})
.collect();
let side = |f: [u32; 3]| -> Option<u32> {
let c = f.map(|v| p[v as usize]);
for axis in 0..3 {
for (s, val) in [(0, 0.0), (1, 1.0)] {
if c.iter().all(|q| q[axis] == val) {
return Some((axis * 2 + s) as u32);
}
}
}
None
};
let mut boundary = Vec::new();
for (t, vs) in tets.iter().enumerate() {
for k in 0..4 {
if let Some(tag) = side(face_toward(*vs, k)) {
boundary.push((t as u32, k, tag));
}
}
}
let kinds = vec![VertexKind::Corner; 8];
TetComplex::new(&p, kinds, &tets, &boundary, &[], 1000, 10_000).unwrap()
}
fn volume(cx: &TetComplex) -> f64 {
cx.live_tets().map(|(_, vs)| measure::volume6(&cx.coords(vs).unwrap()) / 6.0).sum()
}
#[test]
fn cube_builds_and_rings() {
let mut cx = cube();
assert_eq!(cx.live_count(), 6);
assert!((volume(&cx) - 1.0).abs() < 1e-15);
let ring = cx.edge_ring(0, 7).unwrap().unwrap();
assert!(ring.closed);
assert_eq!(ring.verts.len(), 6);
assert_eq!(ring.tets.len(), 6);
let ring = cx.edge_ring(0, 1).unwrap().unwrap();
assert!(!ring.closed);
assert_eq!(ring.verts.len(), ring.tets.len() + 1);
}
#[test]
fn interior_insertion_and_rollback() {
let mut cx = cube();
cx.journal.recording = true;
let start = cx.journal.steps.len();
let p = Point3::try_from([0.5, 0.5, 0.5]).unwrap();
let t = match cx.walk(0, &p, 100).unwrap().unwrap() {
Walk::Inside(t) => t,
w => panic!("{w:?}"),
};
let v = insert_delaunay(&mut cx, p, VertexKind::Interior, Site::Interior(t), 1000).unwrap().unwrap();
assert!(cx.is_used(v));
assert!((volume(&cx) - 1.0).abs() < 1e-15);
assert!(cx.live_count() > 6);
cx.rollback(start).unwrap();
cx.validate().unwrap();
assert_eq!(cx.live_count(), 6);
assert_eq!(cx.point_count(), 8);
assert!((volume(&cx) - 1.0).abs() < 1e-15);
}
#[test]
fn boundary_face_insertion_keeps_tags() {
let mut cx = cube();
// A point on the z = 0 side.
let p = Point3::try_from([0.3, 0.6, 0.0]).unwrap();
let mut site = None;
for (t, _) in cx.live_tets().collect::<Vec<_>>() {
let vs = cx.tet(t).unwrap();
for k in 0..4 {
if cx.adj(t).unwrap()[k] == NONE && cx.tags(t).unwrap()[k] == 4 {
let f = face_toward(vs, k).map(|v| cx.xyz(v).unwrap());
// inside the triangle in xy
let s = |a: [f64; 3], b: [f64; 3]| (b[0] - a[0]) * (0.6 - a[1]) - (b[1] - a[1]) * (0.3 - a[0]);
let signs = [s(f[0], f[1]), s(f[1], f[2]), s(f[2], f[0])];
if signs.iter().all(|x| *x > 0.0) || signs.iter().all(|x| *x < 0.0) {
site = Some(Site::Face(t, k));
}
}
}
}
let v = insert_delaunay(&mut cx, p, VertexKind::Face(4), site.unwrap(), 1000).unwrap().unwrap();
assert!(cx.is_used(v));
assert!((volume(&cx) - 1.0).abs() < 1e-15);
let c = cx.compact().unwrap();
let area: f64 = c
.boundary
.iter()
.filter(|b| b.2 == 4)
.map(|&(t, k, _)| {
let f = face_toward(c.tets[t as usize], k).map(|v| c.points[v as usize]);
0.5 * measure::norm(measure::cross(measure::sub(f[1], f[0]), measure::sub(f[2], f[0])))
})
.sum();
assert!((area - 1.0).abs() < 1e-15, "{area}");
}
#[test]
fn edge_and_face_removal_keep_the_mesh_valid() {
let mut cx = cube();
let p = Point3::try_from([0.5, 0.45, 0.55]).unwrap();
let Walk::Inside(t) = cx.walk(0, &p, 100).unwrap().unwrap() else { panic!() };
insert_delaunay(&mut cx, p, VertexKind::Interior, Site::Interior(t), 1000).unwrap().unwrap();
let mut changed = 0;
for _ in 0..3 {
let edges = edges_of(&cx, &cx.live_tets().map(|(t, _)| t).collect::<Vec<_>>()).unwrap();
for (a, b) in edges {
if cx.edge_tet(a, b).unwrap().is_some() && remove_edge(&mut cx, a, b).unwrap() {
changed += 1;
}
}
let tets: Vec<u32> = cx.live_tets().map(|(t, _)| t).collect();
for t in tets {
for k in 0..4 {
if cx.is_live(t) && remove_face(&mut cx, t, k).unwrap() {
changed += 1;
}
}
}
}
assert!(changed > 0);
cx.validate().unwrap();
assert!((volume(&cx) - 1.0).abs() < 1e-14);
for (_, vs) in cx.live_tets() {
assert_eq!(cx.orient(vs).unwrap(), crate::predicates::Sign::Positive);
}
}
mod pipeline {
use super::super::geometry::AnalyticSurface;
use super::super::{Order, QualityOptions, quality_mesh};
use crate::cdt::tests::{cube, surface, uv_sphere};
use crate::cdt::{CdtOptions, tetrahedralize};
use crate::surface::{BoundarySurface, SurfaceOptions};
/// A 10 mm cube with one B-rep face per side.
fn box_surface() -> BoundarySurface {
let (p, t) = cube();
let p: Vec<[f64; 3]> = p.iter().map(|c| c.map(|x| 10.0 * x)).collect();
let s = surface(&p, &t, [5.0; 3]);
// One face tag per side: the side's axis and end.
let faces: Vec<u32> = s
.triangles()
.iter()
.map(|t| {
let c = t.map(|v| s.points()[v as usize]);
(0..3).find_map(|k| [0.0, 10.0].iter().position(|&x| c.iter().all(|q| q[k] == x)).map(|e| (2 * k + e) as u32)).unwrap()
})
.collect();
BoundarySurface::new(s.points(), s.triangles(), &faces, &SurfaceOptions::default()).unwrap()
}
#[test]
fn a_box_is_refined_improved_and_deterministic() {
let s = box_surface();
let cdt = tetrahedralize(&s, &CdtOptions::default()).unwrap();
let o = QualityOptions { size: Some(2.5), ..QualityOptions::default() };
let a = quality_mesh(&s, &cdt, Vec::new(), &o).unwrap();
let r = &a.report;
assert!(r.output.tets > 100, "{r:?}");
assert_eq!(r.output.inadmissible, 0);
assert_eq!(r.output.slivers, 0, "{r:?}");
assert!(r.output.min_dihedral > 25.0 && r.output.max_dihedral < 155.0, "{r:?}");
// Flat faces: the volume is the box's, and each side keeps its tag on its own plane.
assert!((r.volume - 1000.0).abs() < 1e-9, "{}", r.volume);
for b in &a.boundary {
let t: [u32; 4] = a.elements[b.tet as usize].clone().try_into().unwrap();
let f = super::super::complex::face_toward(t, b.opposite as usize).map(|v| a.points[v as usize]);
let (k, end) = ((b.face / 2) as usize, if b.face % 2 == 0 { 0.0 } else { 10.0 });
assert!(f.iter().all(|p| p[k] == end), "face {} at {f:?}", b.face);
}
// The same input gives the same mesh, bit for bit.
let b = quality_mesh(&s, &cdt, Vec::new(), &o).unwrap();
assert_eq!(a, b);
}
#[test]
fn a_sphere_keeps_its_boundary_on_the_sphere_to_second_order() {
let (p, t) = uv_sphere(10, 20);
let p: Vec<[f64; 3]> = p.iter().map(|c| c.map(|x| 5.0 * x)).collect();
let s = surface(&p, &t, [0.0; 3]);
let faces = vec![0u32; s.triangles().len()];
let s = BoundarySurface::new(s.points(), s.triangles(), &faces, &SurfaceOptions::default()).unwrap();
let cdt = tetrahedralize(&s, &CdtOptions::default()).unwrap();
let sphere = vec![Some(AnalyticSurface::Sphere { center: [0.0; 3], radius: 5.0 })];
let o = QualityOptions { size: Some(1.5), order: Order::Quadratic, ..QualityOptions::default() };
let m = quality_mesh(&s, &cdt, sphere, &o).unwrap();
assert_eq!(m.kind, fieldcraft_model::ElementKind::Tet10);
assert_eq!(m.report.straightened, 0);
// Every node of a boundary face, corners and mid-edge nodes, lies on the sphere.
let mut on = 0;
for b in &m.boundary {
let e = &m.elements[b.tet as usize];
let k = b.opposite as usize;
let corners: Vec<usize> = (0..4).filter(|&i| i != k).collect();
let mids =
[(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)].iter().enumerate().filter(|(_, (i, j))| *i != k && *j != k).map(|(n, _)| 4 + n);
for i in corners.into_iter().chain(mids) {
let q = m.points[e[i] as usize];
let r = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2]).sqrt();
assert!((r - 5.0).abs() < 1e-9, "a boundary node {i} of {e:?} at {q:?}, radius {r}; report {:?}", m.report);
on += 1;
}
}
assert!(on > 0);
assert!(m.report.output.min_dihedral > 15.0, "{:?}", m.report.output);
// Every element passes the certified check of the gate.
for e in &m.elements {
let xyz: Vec<[f64; 3]> = e.iter().map(|&v| m.points[v as usize]).collect();
let v = crate::quality::element_validity(fieldcraft_model::ElementKind::Tet10, &xyz).unwrap();
assert_eq!(v.validity, crate::quality::Validity::Valid);
}
}
#[test]
fn caps_stop_the_stage_without_panicking() {
let s = box_surface();
let cdt = tetrahedralize(&s, &CdtOptions::default()).unwrap();
for o in [
QualityOptions { size: Some(1e-3), max_points: 200, ..QualityOptions::default() },
QualityOptions { size: Some(0.5), max_tets: 500, ..QualityOptions::default() },
QualityOptions { size: Some(f64::NAN), ..QualityOptions::default() },
QualityOptions { size: Some(-1.0), ..QualityOptions::default() },
] {
match quality_mesh(&s, &cdt, Vec::new(), &o) {
Ok(m) => assert!(!m.report.stopped.is_empty(), "{:?}", m.report),
Err(e) => assert!(matches!(e, crate::Error::Limit(_) | crate::Error::Invalid(_)), "{e}"),
}
}
}
}
+15
View File
@@ -23,3 +23,18 @@ pub(crate) fn det(a: P, b: P, c: P) -> f64 {
pub(crate) fn angle_deg(a: P, b: P) -> f64 {
norm(cross(a, b)).atan2(dot(a, b)).to_degrees()
}
pub(crate) fn add(a: P, b: P) -> P {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
pub(crate) fn scale(a: P, s: f64) -> P {
[a[0] * s, a[1] * s, a[2] * s]
}
pub(crate) fn dist(a: P, b: P) -> f64 {
norm(sub(a, b))
}
pub(crate) fn lerp(a: P, b: P, t: f64) -> P {
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
}
pub(crate) fn mid(a: P, b: P) -> P {
[0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]), 0.5 * (a[2] + b[2])]
}
+6 -3
View File
@@ -58,12 +58,15 @@ pub use fluids::{BoxFace, BoxFaces, FaceCondition, FluidBoundary, FluidDomain, P
pub use geometry::{Classification, Entity, GeometryRef};
pub use loads::{Distribution, Load, centrifugal_acceleration};
pub use materials::{Damage, Directional, Elastic, Erosion, Fluid, Hardening, HardeningPoint, Hyperelastic, Material, Plastic, Thermal};
pub use mesh::{Element, ElementEdge, ElementFace, ElementKind, Node, check_coordinates};
pub use mesh::{
Element, ElementEdge, ElementFace, ElementKind, MIN_NORMALIZED_JACOBIAN, Node, admissible_jacobian, check_coordinates, normalized_jacobian,
tet4_jacobian,
};
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]
+37
View File
@@ -227,6 +227,43 @@ pub fn check_coordinates(xyz: &[f64]) -> Result<()> {
}
}
/// The smallest normalized Jacobian ([`normalized_jacobian`]) of an admissible solid element.
///
/// This is FieldCraft's one definition of an inverted or degenerate element. The solvers
/// (implicit and explicit) refuse an element whose Jacobian at one of their integration points is
/// not admissible ([`admissible_jacobian`]); the meshers' validity gate proves the same bound on
/// the whole element (`fieldcraft_mesh::quality`), so a mesh that passes the gate always passes
/// the solvers. A linear tetrahedron has one constant Jacobian ([`tet4_jacobian`]), which the gate
/// and the solvers compute from the same coordinate differences, so they agree on it bit for bit.
pub const MIN_NORMALIZED_JACOBIAN: f64 = 1e-12;
/// The normalized Jacobian `det J / (|J₁| |J₂| |J₃|)` of a Jacobian matrix with rows
/// `J_a = ∂x/∂ξ_a`: the signed volume of the parallelepiped its rows span over that of a box with
/// the same edge lengths. It lies in `[-1, 1]` (Hadamard's inequality), does not depend on the
/// scale of each reference direction, is 1 for orthogonal rows and 0 for a flat element. A zero
/// row, or a matrix that is not finite, gives 0.
pub fn normalized_jacobian(j: &[[f64; 3]; 3]) -> f64 {
let [a, b, c] = j;
let det = 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]);
let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product();
let q = det / norms;
if q.is_finite() { q.clamp(-1.0, 1.0) } else { 0.0 }
}
/// Whether a Jacobian matrix belongs to an admissible (not inverted, not degenerate) element:
/// its normalized Jacobian exceeds [`MIN_NORMALIZED_JACOBIAN`].
pub fn admissible_jacobian(j: &[[f64; 3]; 3]) -> bool {
normalized_jacobian(j) > MIN_NORMALIZED_JACOBIAN
}
/// The Jacobian matrix of a linear tetrahedron with corners `xyz` in tet4 order: its edges from
/// the first corner (`∂x/∂ξ_a` of the linear map), the form the solvers and the gate share.
pub fn tet4_jacobian(xyz: &[[f64; 3]; 4]) -> [[f64; 3]; 3] {
let [o, a, b, c] = xyz;
let e = |p: &[f64; 3]| [p[0] - o[0], p[1] - o[1], p[2] - o[2]];
[e(a), e(b), e(c)]
}
pub(crate) fn check_label(what: &str, label: u32) -> Result<()> {
if label == 0 || label > MAX_LABEL {
return Err(Error::Invalid(format!("{what} label {label} is outside 1..={MAX_LABEL}")));
+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]
+3 -6
View File
@@ -5,6 +5,7 @@
use serde::{Deserialize, Serialize};
use crate::pick::Projector;
use crate::{Aabb3, Vec3};
/// Column-major 4×4 matrix.
@@ -206,13 +207,9 @@ impl Camera {
self.proj(aspect, n, f).mul(&self.view())
}
/// World point → pixel coordinates (x right, y down) and depth (0 near … 1 far).
/// For many points, a [`Projector`] computes the projection once.
pub fn to_screen(&self, p: Vec3, w: f64, h: f64, radius: f64) -> Option<(f64, f64, f64)> {
let c = self.view_proj(w / h.max(1.0), radius).apply(p);
if c[3].abs() < 1e-12 {
return None;
}
let (x, y, z) = (c[0] / c[3], c[1] / c[3], c[2] / c[3]);
(c[3] > 0.0).then_some(((x + 1.0) * 0.5 * w, (1.0 - y) * 0.5 * h, z))
Projector::new(self, w, h, radius).screen(p).map(|[x, y, z]| (x, y, z))
}
/// Picking ray through pixel (x, y): origin and unit direction.
pub fn ray(&self, x: f64, y: f64, w: f64, h: f64) -> (Vec3, Vec3) {
+66 -26
View File
@@ -14,6 +14,7 @@
use fieldcraft_model::{ElementKind, Model};
use crate::pick::{Hit, first_hit};
use crate::{Aabb3, Vec3};
/// One drawn triangle of an element face.
@@ -25,6 +26,18 @@ pub struct DisplayTri {
/// The face's unit normal, pointing out of its element (a shell's own orientation).
pub normal: Vec3,
pub kind: ElementKind,
/// The element it is a face of, and the face's number (from 1, as
/// [`ElementKind::faces`] numbers them; a shell's is 1, its top side).
pub element: u32,
pub face: u8,
}
impl DisplayTri {
/// The element face seen looking along `dir`: a shell's top side (1) from in front and its
/// bottom side (2) from behind; a solid's face as it is.
pub fn seen_face(&self, dir: Vec3) -> u8 {
if self.kind.dimension() == 2 && self.normal.dot(dir) > 0.0 { 2 } else { self.face }
}
}
/// An element edge.
@@ -41,6 +54,7 @@ pub struct DisplayLine {
pub b: Vec3,
pub nodes: [u32; 2],
pub kind: ElementKind,
pub element: u32,
}
/// A node.
@@ -57,6 +71,8 @@ pub struct SurfaceTri {
pub normal: Vec3,
/// Which surface (body) it belongs to: the order [`DisplayMesh::add_surface`] got them in.
pub body: u32,
/// The face of that surface it lies on (its `tri_face` tag; `u32::MAX` when it has none).
pub face: u32,
}
/// Display data for a model and its geometry.
@@ -80,7 +96,9 @@ pub struct DisplayMesh {
}
/// The faces of an element as corner indices (0-based into its node list), each wound
/// counter-clockwise seen from outside for a positively oriented element.
/// counter-clockwise seen from outside for a positively oriented element. A solid's faces come in
/// the order [`ElementKind::faces`] numbers them (with the same corners); a shell draws its top
/// side, face 1.
fn faces(kind: ElementKind) -> &'static [&'static [usize]] {
match kind {
ElementKind::Rod2 | ElementKind::Rod3 | ElementKind::Beam2 => &[],
@@ -142,7 +160,7 @@ impl DisplayMesh {
inner.dedup();
drop(keys);
let mut edges: Vec<(u32, u32)> = Vec::new();
for e in model.elements().values() {
for (&label, e) in model.elements() {
// Corners: one past the highest face index (line elements: their two ends).
let corners = faces(e.kind).iter().flat_map(|f| f.iter()).max().map_or(2, |m| m + 1).min(MAX_CORNERS);
let mut at = [Vec3::ZERO; MAX_CORNERS];
@@ -159,13 +177,14 @@ impl DisplayMesh {
let at = at.get(..corners).unwrap_or(&[]);
if e.kind.dimension() == 1 {
if let (Some(&a), Some(&b), Some(&na), Some(&nb)) = (at.first(), at.get(1), e.nodes.first(), e.nodes.get(1)) {
out.lines.push(DisplayLine { a, b, nodes: [na, nb], kind: e.kind });
out.lines.push(DisplayLine { a, b, nodes: [na, nb], kind: e.kind, element: label });
}
continue;
}
let solid = e.kind.dimension() == 3;
let element_centre = centre(at);
for f in faces(e.kind) {
for (number, f) in faces(e.kind).iter().enumerate() {
let face = u8::try_from(number + 1).unwrap_or(u8::MAX);
if solid && face_key(&e.nodes, f).is_some_and(|k| inner.binary_search(&k).is_ok()) {
continue;
}
@@ -179,7 +198,7 @@ impl DisplayMesh {
if let (Some(&a), Some(&b), Some(&c), Some(&la), Some(&lb), Some(&lc)) =
(p.first(), p.get(k), p.get(k + 1), labels.first(), labels.get(k), labels.get(k + 1))
{
out.tris.push(DisplayTri { p: [a, b, c], nodes: [la, lb, lc], normal, kind: e.kind });
out.tris.push(DisplayTri { p: [a, b, c], nodes: [la, lb, lc], normal, kind: e.kind, element: label, face });
}
}
for (j, &i) in f.iter().enumerate() {
@@ -229,8 +248,8 @@ impl DisplayMesh {
for p in [a, b, c] {
self.bounds.add(p);
}
self.surface_tris.push(SurfaceTri { p: [a, b, c], normal, body: self.bodies });
let face = tri_face.get(t).copied().unwrap_or(u32::MAX);
self.surface_tris.push(SurfaceTri { p: [a, b, c], normal, body: self.bodies, face });
let [v0, v1, v2] = [vertex(i0), vertex(i1), vertex(i2)];
for (u, v) in [(v0, v1), (v1, v2), (v2, v0)] {
sides.push((u.min(v), u.max(v), face));
@@ -263,30 +282,24 @@ impl DisplayMesh {
self.nodes.binary_search_by_key(&label, |n| n.label).ok().and_then(|i| self.nodes.get(i)).map(|n| n.p)
}
/// The element face triangle ([`DisplayMesh::tris`]) a ray (`origin`, `dir`) hits first
/// ([`pick::first_hit`]).
pub fn hit_element_face(&self, origin: Vec3, dir: Vec3) -> Option<Hit> {
first_hit(origin, dir, self.tris.iter().map(|t| t.p))
}
/// The geometry surface triangle ([`DisplayMesh::surface_tris`]) a ray hits first.
pub fn hit_surface(&self, origin: Vec3, dir: Vec3) -> Option<Hit> {
first_hit(origin, dir, self.surface_tris.iter().map(|t| t.p))
}
/// The node nearest to where a ray (`origin`, `dir`) first hits an element face ahead of
/// its origin: the hit face's closest corner, and the hit point. Line elements and geometry
/// are not hit.
pub fn pick(&self, origin: Vec3, dir: Vec3) -> Option<(u32, Vec3)> {
let mut best: Option<(f64, &DisplayTri)> = None;
for t in &self.tris {
// Möller–Trumbore, both sides.
let (e1, e2) = (t.p[1] - t.p[0], t.p[2] - t.p[0]);
let h = dir.cross(e2);
let det = e1.dot(h);
if !(det.abs() > 1e-300) {
continue;
}
let s = origin - t.p[0];
let u = s.dot(h) / det;
let q = s.cross(e1);
let v = dir.dot(q) / det;
let d = e2.dot(q) / det;
if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && d >= 0.0 && d.is_finite() && best.is_none_or(|(b, _)| d < b) {
best = Some((d, t));
}
}
let (d, t) = best?;
let hit = origin + dir * d;
let h = self.hit_element_face(origin, dir)?;
let t = self.tris.get(h.index)?;
let hit = origin + dir * h.t;
let (i, _) = t.p.iter().enumerate().min_by(|a, b| a.1.dist(hit).total_cmp(&b.1.dist(hit)))?;
Some((*t.nodes.get(i)?, hit))
}
@@ -461,6 +474,33 @@ mod tests {
assert_eq!(d.pick(Vec3::new(f64::NAN, 0.0, 0.0), Vec3::new(0.0, 0.0, -1.0)), None);
}
/// A solid's drawn faces are numbered as the model numbers them, so a picked triangle names
/// the element face a set or load refers to.
#[test]
fn drawn_faces_carry_the_models_face_numbers() {
for kind in [ElementKind::Tet4, ElementKind::Tet10, ElementKind::Wedge6, ElementKind::Hex8, ElementKind::Hex20] {
let drawn = faces(kind);
assert_eq!(drawn.len(), kind.faces().len(), "{kind:?}");
for (d, m) in drawn.iter().zip(kind.faces()) {
let corners = d.len();
let (mut a, mut b) = (d.to_vec(), m.get(..corners).unwrap().to_vec());
a.sort_unstable();
b.sort_unstable();
assert_eq!(a, b, "{kind:?}");
}
}
let m = block(2, 1, 1);
let d = DisplayMesh::from_model(&m);
for t in &d.tris {
let face = m.face_nodes(fieldcraft_model::ElementFace { element: t.element, face: t.face }).unwrap();
assert!(t.nodes.iter().all(|n| face.contains(n)), "{t:?} {face:?}");
}
// The ray from +x hits element 2's face 4 (corners 1, 2, 6, 5: x = 2).
let h = d.hit_element_face(Vec3::new(10.0, 0.3, 0.6), Vec3::new(-1.0, 0.0, 0.0)).unwrap();
let t = d.tris[h.index];
assert_eq!((t.element, t.face, h.t), (2, 4, 8.0));
}
#[test]
fn empty_and_deterministic() {
assert_eq!(DisplayMesh::from_model(&Model::default()), DisplayMesh::default());
+5 -3
View File
@@ -1,7 +1,8 @@
//! Display data for FieldCraft, with no GPU dependency: what a finite element model looks like
//! on screen ([`DisplayMesh`]: the visible faces, element edges, line elements and nodes), an
//! orbit camera with perspective or orthographic projection, and a CPU rasterizer for headless
//! snapshots. The desktop viewport draws the same [`Scene`] on the GPU.
//! orbit camera with perspective or orthographic projection, picking ([`pick`]: what a ray through
//! a pixel hits), and a CPU rasterizer for headless snapshots. The desktop viewport draws the
//! same [`Scene`] on the GPU.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -10,6 +11,7 @@ pub mod contour;
mod display;
mod font;
pub mod glyphs;
pub mod pick;
mod raster;
mod vec;
@@ -17,6 +19,6 @@ pub use camera::{Camera, Mat4, Orientation, StandardView};
pub use display::{DisplayEdge, DisplayLine, DisplayMesh, DisplayNode, DisplayTri, SurfaceTri};
pub use raster::{
Canvas, LABEL_OFFSET, MAX_DIM, MAX_PIXELS, MAX_PRIMITIVES, RenderError, Rgb, Scene, SceneLabel, SceneLine, ScenePoint, SceneTri, fit_size, png,
render, render_png, shade,
render, render_layers, render_layers_png, render_png, shade,
};
pub use vec::{Aabb3, Vec3};
+295
View File
@@ -0,0 +1,295 @@
//! Picking: what a ray through a pixel hits first, and what lies near a pixel or inside a box on
//! screen.
//!
//! Rays are tested against triangles with the watertight ray–triangle intersection of Woop,
//! Benthin and Wald ("Watertight Ray/Triangle Intersection", Journal of Computer Graphics
//! Techniques 2(1), 2013): the ray is sheared onto the +z axis and the triangle's edges are tested
//! with 2D edge functions. An edge shared by two triangles is evaluated from the same operands in
//! both (once each way round, which IEEE arithmetic negates exactly), so a ray through a shared
//! edge or vertex hits at least one of them and never slips between. Hits are two-sided. The
//! nearest hit wins; equal distances go to the lower index, so the answer is the same on every
//! run and platform.
//!
//! Screen-space picks (nodes, edges, box selection) go through a [`Projector`] (the camera's
//! view-projection, computed once) and, for box selection, a [`DepthMap`] of the drawn faces that
//! tells which points are in front.
use crate::raster::raster_tri;
use crate::{Camera, Mat4, Vec3};
/// The distance along a ray (`origin`, `dir`; `dir` need not be unit) at which it hits triangle
/// `p`, from either side; `None` when it misses, runs in the triangle's plane, or hits behind the
/// origin. Non-finite input misses.
pub fn ray_triangle(origin: Vec3, dir: Vec3, p: &[Vec3; 3]) -> Option<f64> {
let d = [dir.x, dir.y, dir.z];
// The dominant axis of the direction becomes z; x and y keep the winding.
let kz = if d[0].abs() >= d[1].abs() && d[0].abs() >= d[2].abs() {
0
} else if d[1].abs() >= d[2].abs() {
1
} else {
2
};
let (mut kx, mut ky) = ((kz + 1) % 3, (kz + 2) % 3);
let dz = *d.get(kz)?;
if !(dz.abs() > 0.0) || !dz.is_finite() {
return None;
}
if dz < 0.0 {
std::mem::swap(&mut kx, &mut ky);
}
let (sx, sy, sz) = (d.get(kx)? / dz, d.get(ky)? / dz, 1.0 / dz);
let rel = |q: Vec3| {
let r = q - origin;
[r.x, r.y, r.z]
};
let (a, b, c) = (rel(p[0]), rel(p[1]), rel(p[2]));
let shear = |v: &[f64; 3]| -> Option<(f64, f64, f64)> {
let z = *v.get(kz)?;
Some((v.get(kx)? - sx * z, v.get(ky)? - sy * z, sz * z))
};
let ((ax, ay, az), (bx, by, bz), (cx, cy, cz)) = (shear(&a)?, shear(&b)?, shear(&c)?);
// Edge functions: e(P, Q) = Px·Qy − Py·Qx for the edges (C, B), (A, C), (B, A).
let u = cx * by - cy * bx;
let v = ax * cy - ay * cx;
let w = bx * ay - by * ax;
if !(u.is_finite() && v.is_finite() && w.is_finite()) {
return None;
}
if (u < 0.0 || v < 0.0 || w < 0.0) && (u > 0.0 || v > 0.0 || w > 0.0) {
return None;
}
let det = u + v + w;
if det == 0.0 {
return None;
}
let t = (u * az + v * bz + w * cz) / det;
(t >= 0.0 && t.is_finite()).then_some(t)
}
/// A ray's first hit among triangles: the triangle's index and the distance along the ray.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Hit {
pub index: usize,
pub t: f64,
}
/// The first triangle `tris` (in order) that the ray hits: the nearest, the lowest index among
/// equally near ones.
pub fn first_hit(origin: Vec3, dir: Vec3, tris: impl IntoIterator<Item = [Vec3; 3]>) -> Option<Hit> {
let mut best: Option<Hit> = None;
for (index, p) in tris.into_iter().enumerate() {
if let Some(t) = ray_triangle(origin, dir, &p)
&& best.is_none_or(|b| t < b.t)
{
best = Some(Hit { index, t });
}
}
best
}
/// The distance on screen from `p` to the segment `a`–`b` (pixels), and where along it (0 at
/// `a`, 1 at `b`) the nearest point lies.
pub fn segment_distance(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> (f64, f64) {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len2 = dx * dx + dy * dy;
let k = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
let (qx, qy) = (a[0] + dx * k, a[1] + dy * k);
(((p[0] - qx).powi(2) + (p[1] - qy).powi(2)).sqrt(), k)
}
/// A camera's projection for one viewport size, computed once: world points to pixels.
#[derive(Clone, Copy, Debug)]
pub struct Projector {
cam: Camera,
vp: Mat4,
w: f64,
h: f64,
}
impl Projector {
/// The projection of `cam` onto a `w × h` viewport whose scene has radius `radius` (the
/// clipping planes', as drawn).
pub fn new(cam: &Camera, w: f64, h: f64, radius: f64) -> Projector {
let (w, h) = (if w.is_finite() { w.max(1.0) } else { 1.0 }, if h.is_finite() { h.max(1.0) } else { 1.0 });
Projector { cam: *cam, vp: cam.view_proj(w / h, radius.max(1e-3)), w, h }
}
/// The pixel (x right, y down) and depth (0 near … 1 far) of a world point in front of the
/// eye ([`Camera::to_screen`]).
pub fn screen(&self, p: Vec3) -> Option<[f64; 3]> {
let c = self.vp.apply(p);
if !(c[3] > 1e-12) {
return None;
}
let (x, y, z) = (c[0] / c[3], c[1] / c[3], c[2] / c[3]);
let s = [(x + 1.0) * 0.5 * self.w, (1.0 - y) * 0.5 * self.h, z];
s.iter().all(|v| v.is_finite()).then_some(s)
}
/// The picking ray through pixel (x, y): origin and unit direction ([`Camera::ray`]).
pub fn ray(&self, x: f64, y: f64) -> (Vec3, Vec3) {
self.cam.ray(x, y, self.w, self.h)
}
pub fn size(&self) -> [f64; 2] {
[self.w, self.h]
}
}
/// Most pixels a [`DepthMap`] holds; a larger viewport is sampled more coarsely.
pub const MAX_DEPTH_PIXELS: usize = 2048 * 2048;
/// Depth (of the 0…1 range) a point may lie behind the drawn surface and still count as on it.
const DEPTH_ALLOWANCE: f64 = 2e-3;
/// The depth of the nearest drawn face at every pixel: which points are in front.
#[derive(Clone, Debug)]
pub struct DepthMap {
proj: Projector,
/// Map pixels per viewport pixel (1, or less for a large viewport).
scale: f64,
w: usize,
h: usize,
depth: Vec<f32>,
}
impl DepthMap {
/// Rasterise `tris` with `proj`. `None` when the buffer cannot be allocated.
pub fn new(proj: &Projector, tris: impl IntoIterator<Item = [Vec3; 3]>) -> Option<DepthMap> {
let [vw, vh] = proj.size();
let scale = ((MAX_DEPTH_PIXELS as f64) / (vw * vh)).sqrt().min(1.0);
let (w, h) = (((vw * scale).ceil() as usize).max(1), ((vh * scale).ceil() as usize).max(1));
let n = w.checked_mul(h)?;
let mut depth = Vec::new();
depth.try_reserve_exact(n).ok()?;
depth.resize(n, f32::INFINITY);
let mut map = DepthMap { proj: *proj, scale, w, h, depth };
for p in tris {
map.add(&p);
}
Some(map)
}
fn add(&mut self, p: &[Vec3; 3]) {
let s = |q: Vec3| self.proj.screen(q).map(|v| [v[0] * self.scale, v[1] * self.scale, v[2]]);
let (Some(a), Some(b), Some(c)) = (s(p[0]), s(p[1]), s(p[2])) else { return };
let (w, depth) = (self.w, &mut self.depth);
raster_tri(a, b, c, w, self.h, |x, y, z| {
if let Some(d) = depth.get_mut(y * w + x) {
*d = d.min(z);
}
});
}
/// Whether a world point is on screen and not behind a drawn face (a point on a face is in
/// front of it). A point whose pixel no face covers, on a face's outline, is compared with
/// the nearest face around it, so what lies behind an outline stays hidden.
pub fn visible(&self, p: Vec3) -> bool {
let Some([x, y, z]) = self.proj.screen(p) else { return false };
let [vw, vh] = self.proj.size();
if !(0.0..=vw).contains(&x) || !(0.0..=vh).contains(&y) || !(0.0..=1.0).contains(&z) {
return false;
}
let (mx, my) = (((x * self.scale) as usize).min(self.w - 1), ((y * self.scale) as usize).min(self.h - 1));
let at = |x: usize, y: usize| self.depth.get(y * self.w + x).copied().unwrap_or(f32::INFINITY);
let mut d = at(mx, my);
if d == f32::INFINITY {
for y in my.saturating_sub(1)..=(my + 1).min(self.h - 1) {
for x in mx.saturating_sub(1)..=(mx + 1).min(self.w - 1) {
d = d.min(at(x, y));
}
}
}
z <= f64::from(d) + DEPTH_ALLOWANCE
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::StandardView;
const SQUARE: [[Vec3; 3]; 2] = [
[Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 0.0)],
[Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 0.0), Vec3::new(0.0, 1.0, 0.0)],
];
#[test]
fn rays_hit_from_either_side_and_report_the_distance() {
let down = Vec3::new(0.0, 0.0, -1.0);
assert_eq!(ray_triangle(Vec3::new(0.7, 0.2, 5.0), down, &SQUARE[0]), Some(5.0));
assert_eq!(ray_triangle(Vec3::new(0.7, 0.2, -2.0), Vec3::Z, &SQUARE[0]), Some(2.0));
// Behind the origin, beside the triangle, in its plane.
assert_eq!(ray_triangle(Vec3::new(0.7, 0.2, -2.0), down, &SQUARE[0]), None);
assert_eq!(ray_triangle(Vec3::new(0.2, 0.7, 5.0), down, &SQUARE[0]), None);
assert_eq!(ray_triangle(Vec3::new(-1.0, 0.5, 0.0), Vec3::X, &SQUARE[0]), None);
// A slanted ray, a zero or non-finite one.
let t = ray_triangle(Vec3::new(0.0, 0.0, 1.0), Vec3::new(0.5, 0.25, -1.0), &SQUARE[0]).unwrap();
assert!((t - 1.0).abs() < 1e-15);
assert_eq!(ray_triangle(Vec3::new(0.5, 0.2, 1.0), Vec3::ZERO, &SQUARE[0]), None);
assert_eq!(ray_triangle(Vec3::new(f64::NAN, 0.2, 1.0), down, &SQUARE[0]), None);
assert_eq!(ray_triangle(Vec3::new(0.5, 0.2, 1.0), Vec3::new(0.0, 0.0, f64::INFINITY), &SQUARE[0]), None);
let huge = [Vec3::new(1e300, 0.0, 0.0), Vec3::new(-1e300, 1e300, 0.0), Vec3::new(-1e300, -1e300, 0.0)];
assert_eq!(ray_triangle(Vec3::new(0.0, 0.0, 1.0), down, &huge), None, "overflowing edge functions miss");
}
/// Rays through the shared diagonal (inside the square) and corners of two triangles, at
/// every angle, always hit one of them.
#[test]
fn no_ray_slips_through_a_shared_edge_or_vertex() {
for k in 1..400 {
let s = k as f64 / 400.0;
for dir in [Vec3::new(0.0, 0.0, -1.0), Vec3::new(0.3, -0.1, -1.0), Vec3::new(-1e-9, 2e-9, -1.0)] {
let at = Vec3::new(s, s, 0.0) - dir * 3.0;
assert!(first_hit(at, dir, SQUARE).is_some(), "{s} {dir:?}");
}
}
for corner in [Vec3::ZERO, Vec3::new(1.0, 1.0, 0.0)] {
assert!(first_hit(corner + Vec3::Z, Vec3::new(0.0, 0.0, -1.0), SQUARE).is_some());
}
}
#[test]
fn the_nearest_hit_wins_and_ties_go_to_the_lower_index() {
let lift = |t: [Vec3; 3], z: f64| t.map(|p| p + Vec3::new(0.0, 0.0, z));
let tris = [lift(SQUARE[0], 0.0), lift(SQUARE[0], 2.0), lift(SQUARE[0], 1.0)];
let hit = first_hit(Vec3::new(0.7, 0.2, 10.0), Vec3::new(0.0, 0.0, -1.0), tris).unwrap();
assert_eq!(hit, Hit { index: 1, t: 8.0 });
let same = [SQUARE[0], SQUARE[0]];
assert_eq!(first_hit(Vec3::new(0.7, 0.2, 1.0), Vec3::new(0.0, 0.0, -1.0), same).map(|h| h.index), Some(0));
assert_eq!(first_hit(Vec3::new(0.7, 0.2, 1.0), Vec3::new(0.0, 0.0, -1.0), []), None);
}
#[test]
fn screen_distances_to_segments() {
assert_eq!(segment_distance([1.0, 1.0], [0.0, 0.0], [2.0, 0.0]), (1.0, 0.5));
assert_eq!(segment_distance([3.0, 0.0], [0.0, 0.0], [2.0, 0.0]), (1.0, 1.0));
assert_eq!(segment_distance([0.0, 2.0], [0.0, 0.0], [0.0, 0.0]), (2.0, 0.0));
}
#[test]
fn the_projector_agrees_with_the_camera_and_the_depth_map_hides_what_is_behind() {
let mut cam = Camera { distance: 10.0, ..Camera::default() };
cam.set_view(StandardView::Top);
cam.target = Vec3::new(0.5, 0.5, 0.0);
let p = Projector::new(&cam, 400.0, 300.0, 2.0);
let q = Vec3::new(0.25, 0.75, 0.0);
let (x, y, z) = cam.to_screen(q, 400.0, 300.0, 2.0).unwrap();
assert_eq!(p.screen(q), Some([x, y, z]));
// The ray through a point's pixel passes through the point.
let (o, d) = p.ray(x, y);
assert!((q - o).cross(d).len() < 1e-9);
let map = DepthMap::new(&p, SQUARE).unwrap();
assert!(map.visible(q), "a point on the face");
assert!(map.visible(q + Vec3::Z * 0.5), "a point above it");
assert!(!map.visible(q - Vec3::Z * 0.5), "a point under it");
assert!(map.visible(Vec3::new(1.5, 0.5, -0.5)), "beside it, nothing covers the point");
assert!(!map.visible(Vec3::new(500.0, 0.5, 0.0)), "off screen");
assert!(!map.visible(Vec3::new(f64::NAN, 0.5, 0.0)));
// A huge viewport is sampled coarsely.
let big = DepthMap::new(&Projector::new(&cam, 8192.0, 8192.0, 2.0), SQUARE).unwrap();
assert!(big.depth.len() <= MAX_DEPTH_PIXELS + 2 * 2048 + 1);
let hostile = Projector::new(&cam, f64::NAN, -1.0, f64::INFINITY);
assert!(DepthMap::new(&hostile, SQUARE).is_some());
}
}
+80 -40
View File
@@ -5,6 +5,7 @@
//! Adapted from SolidCraft's `crates/render/src/raster.rs` (MIT OR Apache-2.0), see
//! ATTRIBUTION.md.
use crate::pick::Projector;
use crate::{Camera, Vec3, font};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -179,10 +180,22 @@ impl Canvas {
/// 1..=[`MAX_DIM`] per side or over [`MAX_PIXELS`], and scenes over [`MAX_PRIMITIVES`], are
/// refused ([`fit_size`] gives an accepted size).
pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Result<Canvas, RenderError> {
render_layers(scene, None, cam, w, h)
}
/// Depth (of the 0…1 range) an overlay face may lie behind the scene's and still be drawn: it
/// covers the faces it repeats.
const OVERLAY_DEPTH: f32 = 2e-4;
/// [`render`] with an overlay drawn over the scene: its faces over the scene's faces they
/// coincide with (a highlight repeats the faces it marks), then its lines and points. The
/// overlay's background and radius are not used.
pub fn render_layers(scene: &Scene, overlay: Option<&Scene>, cam: &Camera, w: usize, h: usize) -> Result<Canvas, RenderError> {
if !size_ok(w, h) {
return Err(RenderError::Size { w, h });
}
let primitives = scene.tris.len().saturating_add(scene.lines.len()).saturating_add(scene.points.len()).saturating_add(scene.labels.len());
let count = |s: &Scene| s.tris.len().saturating_add(s.lines.len()).saturating_add(s.points.len()).saturating_add(s.labels.len());
let primitives = count(scene).saturating_add(overlay.map_or(0, count));
if primitives > MAX_PRIMITIVES {
return Err(RenderError::SceneTooLarge { primitives });
}
@@ -197,48 +210,23 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Result<Canvas,
c.put(x, y, col, 1.0);
}
}
let vp = cam.view_proj(sw as f64 / sh as f64, scene.radius.max(1e-3));
let project = |p: Vec3| -> Option<[f64; 3]> {
let q = vp.apply(p);
if q[3] <= 1e-9 {
return None;
}
Some([(q[0] / q[3] + 1.0) * 0.5 * sw as f64, (1.0 - q[1] / q[3]) * 0.5 * sh as f64, q[2] / q[3]])
};
let proj = Projector::new(cam, sw as f64, sh as f64, scene.radius);
let project = |p: Vec3| proj.screen(p);
let view_dir = cam.back();
for t in &scene.tris {
for (t, allowance) in scene.tris.iter().map(|t| (t, 0.0)).chain(overlay.into_iter().flat_map(|o| &o.tris).map(|t| (t, OVERLAY_DEPTH))) {
let (Some(a), Some(b), Some(cc)) = (project(t.p[0]), project(t.p[1]), project(t.p[2])) else { continue };
let col = shade(t.normal, view_dir, t.color.f());
let (x0, x1) = (a[0].min(b[0]).min(cc[0]).floor().max(0.0), a[0].max(b[0]).max(cc[0]).ceil().min(sw as f64 - 1.0));
let (y0, y1) = (a[1].min(b[1]).min(cc[1]).floor().max(0.0), a[1].max(b[1]).max(cc[1]).ceil().min(sh as f64 - 1.0));
if !(x0 <= x1 && y0 <= y1) {
continue;
}
let area = (b[0] - a[0]) * (cc[1] - a[1]) - (b[1] - a[1]) * (cc[0] - a[0]);
if !(area.abs() >= 1e-12) {
continue;
}
for y in y0 as usize..=y1 as usize {
for x in x0 as usize..=x1 as usize {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let w0 = ((b[0] - px) * (cc[1] - py) - (b[1] - py) * (cc[0] - px)) / area;
let w1 = ((cc[0] - px) * (a[1] - py) - (cc[1] - py) * (a[0] - px)) / area;
let w2 = 1.0 - w0 - w1;
if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 {
continue;
}
let z = (w0 * a[2] + w1 * b[2] + w2 * cc[2]) as f32;
let i = y * sw + x;
if let Some(d) = c.depth.get_mut(i)
&& z < *d
{
*d = z;
c.put(x, y, col, 1.0);
}
raster_tri(a, b, cc, sw, sh, |x, y, z| {
if let Some(d) = c.depth.get_mut(y * sw + x)
&& z < *d + allowance
{
*d = d.min(z);
c.put(x, y, col, 1.0);
}
}
});
}
for l in &scene.lines {
let layers = || std::iter::once(scene).chain(overlay);
for l in layers().flat_map(|s| &s.lines) {
let (Some(a), Some(b)) = (project(l.a), project(l.b)) else { continue };
let len = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
let steps = if len.is_finite() { (len.ceil() as usize).clamp(1, MAX_LINE_STEPS) } else { continue };
@@ -249,7 +237,7 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Result<Canvas,
dot(&mut c, p, l.width * SS as f64, col, true);
}
}
for p in &scene.points {
for p in layers().flat_map(|s| &s.points) {
if let Some(q) = project(p.p) {
dot(&mut c, q, p.size * SS as f64, p.color.f(), false);
}
@@ -262,6 +250,34 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Result<Canvas,
downsample(&c, w, h)
}
/// Cover a triangle with screen corners `a`, `b`, `c` (x, y, depth) on a `w × h` pixel grid:
/// `pixel(x, y, depth)` for every pixel whose centre is inside it (its edges included), the depth
/// interpolated. Degenerate and off-grid triangles cover nothing. The renderer and the picking
/// depth map ([`crate::pick::DepthMap`]) share it, so a box selects what is drawn.
pub(crate) fn raster_tri(a: [f64; 3], b: [f64; 3], c: [f64; 3], w: usize, h: usize, mut pixel: impl FnMut(usize, usize, f32)) {
let (x0, x1) = (a[0].min(b[0]).min(c[0]).floor().max(0.0), a[0].max(b[0]).max(c[0]).ceil().min(w as f64 - 1.0));
let (y0, y1) = (a[1].min(b[1]).min(c[1]).floor().max(0.0), a[1].max(b[1]).max(c[1]).ceil().min(h as f64 - 1.0));
if !(x0 <= x1 && y0 <= y1) {
return;
}
let area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
if !(area.abs() >= 1e-12) {
return;
}
for y in y0 as usize..=y1 as usize {
for x in x0 as usize..=x1 as usize {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let w0 = ((b[0] - px) * (c[1] - py) - (b[1] - py) * (c[0] - px)) / area;
let w1 = ((c[0] - px) * (a[1] - py) - (c[1] - py) * (a[0] - px)) / area;
let w2 = 1.0 - w0 - w1;
if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 {
continue;
}
pixel(x, y, (w0 * a[2] + w1 * b[2] + w2 * c[2]) as f32);
}
}
}
/// Text above and right of screen point `p`, over everything, with a halo of the opposite
/// lightness so it reads on any background.
fn label(c: &mut Canvas, p: [f64; 3], text: &str, col: [f64; 3]) {
@@ -350,7 +366,12 @@ fn downsample(c: &Canvas, w: usize, h: usize) -> Result<Canvas, RenderError> {
/// Render to PNG bytes.
pub fn render_png(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Result<Vec<u8>, RenderError> {
let c = render(scene, cam, w, h)?;
render_layers_png(scene, None, cam, w, h)
}
/// [`render_layers`] as PNG bytes.
pub fn render_layers_png(scene: &Scene, overlay: Option<&Scene>, cam: &Camera, w: usize, h: usize) -> Result<Vec<u8>, RenderError> {
let c = render_layers(scene, overlay, cam, w, h)?;
png(c.w, c.h, &c.rgba).ok_or(RenderError::Encode)
}
@@ -407,6 +428,25 @@ mod tests {
assert!(p[2] > 200 && p[0] < 60, "{p:?}");
}
/// An overlay face repeating a scene face covers it; one behind another face does not show.
#[test]
fn an_overlay_covers_the_faces_it_repeats() {
let scene = red_triangle();
let mut cam = Camera { distance: 50.0, ..Default::default() };
cam.set_view(StandardView::Front);
let px = |c: &Canvas| c.rgba[(32 * c.w + 32) * 4..(32 * c.w + 32) * 4 + 3].to_vec();
let green = |s: &Scene| Scene { tris: s.tris.iter().map(|t| SceneTri { color: Rgb(0, 255, 0), ..*t }).collect(), ..Scene::default() };
let over = render_layers(&scene, Some(&green(&scene)), &cam, 64, 64).unwrap();
assert!(px(&over)[1] > px(&over)[0] + 50, "{:?}", px(&over));
// The same face moved behind the red one (front view looks along +y) stays hidden.
let mut behind = green(&scene);
for t in &mut behind.tris {
t.p = t.p.map(|p| p + Vec3::Y * 5.0);
}
let hidden = render_layers(&scene, Some(&behind), &cam, 64, 64).unwrap();
assert_eq!(px(&hidden), px(&render(&scene, &cam, 64, 64).unwrap()));
}
#[test]
fn png_bytes_and_hostile_sizes() {
let scene = red_triangle();
+4
View File
@@ -14,3 +14,7 @@ fieldcraft-fem = { workspace = true }
fieldcraft-parallel = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true }
[dev-dependencies]
# The validity gate, to prove the solvers and the meshers share one criterion.
fieldcraft-mesh = { workspace = true }
+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");
}
}
+76
View File
@@ -0,0 +1,76 @@
//! One validity criterion: the meshers' gate and both solvers decide every element alike.
//!
//! The end-to-end demo of 2026-10-13 met a raw CDT sliver that the gate accepted (its exact
//! orientation is positive) and the solver refused (its Jacobian was below the solver's
//! tolerance). Both now use `fieldcraft_model::admissible_jacobian`: the gate on the whole
//! element, the implicit solver at its integration points, the explicit tetrahedron on its one
//! Jacobian.
use fieldcraft_fem::uniform::UniformStrain;
use fieldcraft_fem::{Formulation, Solid, material::isotropic};
use fieldcraft_mesh::quality::{Validity, element_validity};
use fieldcraft_model::{ElementKind, MIN_NORMALIZED_JACOBIAN, normalized_jacobian, tet4_jacobian};
fn implicit_accepts(kind: ElementKind, xyz: &[[f64; 3]]) -> bool {
let d = isotropic(210_000.0, 0.3).unwrap();
Solid::new(kind, xyz, Formulation::Full).and_then(|e| e.stiffness(&d)).is_ok()
}
fn gate_accepts(kind: ElementKind, xyz: &[[f64; 3]]) -> bool {
element_validity(kind, xyz).map(|c| c.validity == Validity::Valid).unwrap_or(false)
}
/// Slivers flattening through the criterion's threshold, near and far from the origin: the gate,
/// the implicit solver and the explicit tetrahedron agree on every one, and some of them are
/// exactly positive yet inadmissible (the case the demo met).
#[test]
fn gate_and_solvers_agree_on_slivers() {
let mut refused_positive = 0;
let mut accepted = 0;
for offset in [0.0, 1e3, -7.25e4] {
for k in 0..400 {
// Heights from 1e-17 to 1e-7 of the edge length, on a log scale with jitter.
let h = 10f64.powf(-17.0 + 10.0 * k as f64 / 400.0) * (1.0 + 0.37 * ((k * 7919) % 13) as f64 / 13.0);
let o = [offset, 0.5 * offset, -offset];
let xyz = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.4, 0.35, h]].map(|p| [p[0] + o[0], p[1] + o[1], p[2] + o[2]]);
let gate = gate_accepts(ElementKind::Tet4, &xyz);
let implicit = implicit_accepts(ElementKind::Tet4, &xyz);
let explicit = UniformStrain::tet4(&xyz).is_ok();
assert_eq!(
(gate, implicit, explicit),
(gate, gate, gate),
"h = {h:e}, offset {offset}: gate {gate}, implicit {implicit}, explicit {explicit}"
);
let q = normalized_jacobian(&tet4_jacobian(&xyz));
if !gate && q > 0.0 {
refused_positive += 1;
}
accepted += usize::from(gate);
assert_eq!(gate, q > MIN_NORMALIZED_JACOBIAN, "h = {h:e}");
}
}
assert!(refused_positive > 0 && accepted > 0, "{refused_positive} refused, {accepted} accepted");
}
/// A curved tetrahedron the gate accepts is accepted by the implicit solver at every integration
/// point; the gate proves the criterion on the whole element.
#[test]
fn gate_accepts_only_what_the_solver_accepts_for_tet10() {
let corners = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.3, 0.3, 1.0]];
let edges = [(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)];
for k in 0..200 {
let bend = -0.6 + 1.2 * k as f64 / 200.0;
let mut xyz: Vec<[f64; 3]> = corners.to_vec();
for (e, &(a, b)) in edges.iter().enumerate() {
let (p, q): ([f64; 3], [f64; 3]) = (corners[a], corners[b]);
let mut m = [0.5 * (p[0] + q[0]), 0.5 * (p[1] + q[1]), 0.5 * (p[2] + q[2])];
if e == 1 {
m[2] += bend;
}
xyz.push(m);
}
if gate_accepts(ElementKind::Tet10, &xyz) {
assert!(implicit_accepts(ElementKind::Tet10, &xyz), "bend {bend}");
}
}
}
+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),
);
}
}
+99 -15
View File
@@ -16,8 +16,10 @@ use crate::corpus::{self, CATEGORIES, Part};
use fieldcraft_geometry::{Design, FaceSurface, Measure, SurfaceMesh};
use fieldcraft_mesh::cdt::{CdtOptions, tetrahedralize};
use fieldcraft_mesh::hex::{Cell, HexOptions, Surface, hex_mesh, hex_mesh_cells};
use fieldcraft_mesh::octree::{OctreeOptions, octree_hex_mesh};
use fieldcraft_mesh::size::UniformSize;
use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
use fieldcraft_mesh::tet::{QualityOptions, quality_mesh};
pub const FILE: &str = "docs/meshing/corpus.md";
/// Chordal tolerance of the boundary surface, relative to the part's bounding-box diagonal.
@@ -33,6 +35,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 +48,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(TetQualityStage), Box::new(StructuredHex), Box::new(OctreeHex)]
}
/// The boundary surface's enclosed volume against SolidCraft's measured volume and the closed
@@ -101,22 +105,67 @@ impl Stage for TetCdt {
fn run(&self, l: &Loaded) -> Result<Vec<String>, String> {
let start = Instant::now();
let (mut tets, mut steiner, mut cavities) = (0, 0, 0);
let (mut tets, mut steiner, mut cavities, mut inadmissible) = (0, 0, 0, 0);
for (body, mesh) in l.design.bodies.iter().zip(&l.surfaces) {
let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() };
let s = BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options).map_err(|e| format!("{}: {e}", body.name))?;
let m = tetrahedralize(&s, &CdtOptions::default()).map_err(|e| format!("{}: {e}", body.name))?;
m.gate(s.volume()).and_then(|r| r.into_result()).map_err(|e| format!("{}: rounded mesh: {e}", body.name))?;
let report = m.gate(s.volume()).map_err(|e| format!("{}: rounded mesh: {e}", body.name))?;
if !report.volume_matches() {
return Err(format!("{}: rounded mesh: {:?}", body.name, report.into_result().err()));
}
inadmissible += report.invalid.len() + report.undetermined.len();
tets += m.tets.len();
steiner += m.steiner.len();
cavities += m.stats.cavities;
}
let ms = start.elapsed().as_secs_f64() * 1e3;
Ok(vec![tets.to_string(), steiner.to_string(), cavities.to_string(), "pass".into(), format!("{ms:.0}")])
let gate = if inadmissible == 0 { "pass".to_string() } else { format!("{inadmissible} flat") };
Ok(vec![tets.to_string(), steiner.to_string(), cavities.to_string(), gate, format!("{ms:.0}")])
}
}
/// Element size of the structured hex stage, relative to the part's bounding-box diagonal.
/// The quality stage (`fieldcraft_mesh::tet`) on each body's CDT, with the default options: the
/// mesh's tetrahedra, its extreme dihedral angles, the slivers (a dihedral angle below 10° or
/// above 170°) of the CDT and of the result, and the validity gate.
struct TetQualityStage;
impl Stage for TetQualityStage {
fn headers(&self) -> &'static [&'static str] {
&["quality tets", "min dihedral °", "max dihedral °", "min radius ratio", "slivers CDT → mesh", "quality gate", "quality ms"]
}
fn run(&self, l: &Loaded) -> Result<Vec<String>, String> {
let start = Instant::now();
let (mut tets, mut lo, mut hi, mut rr, mut before, mut after) = (0, 180.0_f64, 0.0_f64, 1.0_f64, 0, 0);
for (body, mesh) in l.design.bodies.iter().zip(&l.surfaces) {
let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() };
let s = BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options).map_err(|e| format!("{}: {e}", body.name))?;
let m = tetrahedralize(&s, &CdtOptions::default()).map_err(|e| format!("{}: {e}", body.name))?;
let q = quality_mesh(&s, &m, fieldcraft_engine::analytic_surfaces(body), &QualityOptions::default())
.map_err(|e| format!("{}: {e}", body.name))?;
let r = &q.report;
tets += r.output.tets;
lo = lo.min(r.output.min_dihedral);
hi = hi.max(r.output.max_dihedral);
rr = rr.min(r.output.min_radius_ratio);
before += r.input.slivers;
after += r.output.slivers;
}
let ms = start.elapsed().as_secs_f64() * 1e3;
Ok(vec![
tets.to_string(),
format!("{lo:.1}"),
format!("{hi:.1}"),
format!("{rr:.3}"),
format!("{before} → {after}"),
"pass".into(),
format!("{ms:.0}"),
])
}
}
/// 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;
@@ -134,15 +183,7 @@ 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") {
@@ -231,6 +272,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())?;
@@ -250,7 +334,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.
+2
View File
@@ -84,6 +84,7 @@ impl ModelApp {
let toolbar = std::mem::take(&mut self.toolbar_rects);
let layout = std::mem::take(&mut self.layout);
let panels = std::mem::take(&mut self.panel_rects);
self.capturing = true;
crate::theme::apply(&off.ctx, self.ui.dark);
// A capture is one still frame: windows and menus appear without fading in.
off.ctx.global_style_mut(|s| s.animation_time = 0.0);
@@ -114,6 +115,7 @@ impl ModelApp {
}
std::mem::swap(&mut self.icons, &mut off.icons);
std::mem::swap(&mut self.viewport, &mut off.viewport);
self.capturing = false;
self.toolbar_rects = toolbar;
self.layout = layout;
self.panel_rects = panels;
+27 -1
View File
@@ -6,10 +6,10 @@
use egui::{RichText, vec2};
use serde_json::{Value, json};
use crate::ModelApp;
use crate::browser::{self, Def};
use crate::frame::{Level, heading};
use crate::theme::{Tokens, label_font};
use crate::{ModelApp, selection};
/// The panel's width.
pub const WIDTH: f32 = 310.0;
@@ -59,7 +59,11 @@ pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) {
}
/// The open command's parameters as JSON, with the command's parameter documentation; OK runs it.
/// A command that applies to part of the model (a set, a boundary condition, a load, a section, a
/// coupling) has a Target row that takes the viewport's selection.
fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
let selected = (!app.pick.selection.is_empty()).then(|| app.pick.selection.summary());
let mut use_selection = false;
let Some(d) = app.dialog.as_mut() else { return };
let Some(spec) = fieldcraft_engine::find_command(d.command) else {
app.dialog = None;
@@ -74,6 +78,23 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
});
ui.add_space(4.0);
ui.label(RichText::new(spec.id).small().color(t.text_dim));
let params = serde_json::from_str::<Value>(&d.text).unwrap_or(Value::Null);
if let Some(target) = selection::target(spec.id, &params) {
heading(ui, t, Level::Section, |ui| {
ui.label(RichText::new("Target").size(12.0));
});
ui.horizontal(|ui| {
let tip = match target {
selection::Target::Members => "The selection becomes the set's members (geometry by name, the mesh by label)",
selection::Target::Set | selection::Target::TargetSet => "Makes a set of the selection (model.set.create) and names it here",
};
use_selection = ui.add_enabled(selected.is_some(), egui::Button::new("Use selection")).on_hover_text(tip).clicked();
match &selected {
Some(text) => ui.label(RichText::new(text).color(t.pick_selected)),
None => ui.label(RichText::new("Pick in the viewport, or type the names below").color(t.text_dim)),
};
});
}
heading(ui, t, Level::Section, |ui| {
ui.label(RichText::new("Parameters").size(12.0));
});
@@ -98,6 +119,11 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
app.dialog = None;
} else if run {
let _ = app.run_dialog();
} else if use_selection
&& let Err(e) = app.use_selection_in_dialog()
&& let Some(d) = app.dialog.as_mut()
{
d.error = Some(e);
}
}
+195
View File
@@ -85,6 +85,76 @@ fn render_dim(p: &Value, key: &str, default: usize) -> Result<usize, String> {
}
}
/// The keys that make `ui.select` change the viewport's selection (without them it selects a
/// definition in the browser, `{kind, name}`).
pub const VIEWPORT_SELECT: [&str; 10] = ["filter", "box_mode", "mode", "clear", "at", "box", "geometry", "nodes", "elements", "faces"];
/// The viewport's selection: the filter, the items, their set members and the hover.
pub fn selection_json(app: &ModelApp) -> Value {
let s = &app.pick.selection;
json!({
"filter": app.pick.filter,
"box_mode": app.pick.box_mode,
"count": s.len(),
"capped": s.capped(),
"summary": s.summary(),
"items": s.items().iter().map(crate::selection::Item::to_json).collect::<Vec<_>>(),
"params": s.params(),
"hover": app.pick.hover.as_ref().map(crate::selection::Item::to_json),
})
}
/// The window points in `v`, a list of `n` numbers (two per point).
fn points<const N: usize>(v: &Value, key: &str, what: &str) -> Result<[f32; N], String> {
let bad = || format!("`{key}` must be {what} in window points");
let list = v.as_array().filter(|a| a.len() == N).ok_or_else(bad)?;
let mut out = [0.0; N];
for (slot, x) in out.iter_mut().zip(list) {
*slot = x.as_f64().filter(|x| x.is_finite() && x.abs() <= 1e6).ok_or_else(bad)? as f32;
}
Ok(out)
}
/// `ui.select` on the viewport: set the filter and box mode, then pick by name and label
/// (`geometry`, `nodes`, `elements`, `faces`), at a point (`at`) or in a box (`box`), changing
/// the selection by `mode`; `clear` empties it first.
fn select(app: &mut ModelApp, p: &Value) -> Result<Value, String> {
use crate::selection::{Filter, Mode, items_from_params};
if let Some(f) = p.get("filter") {
app.pick.filter = serde_json::from_value::<Filter>(f.clone())
.map_err(|_| format!("`filter` must be one of {}", Filter::ALL.map(|f| json!(f).as_str().unwrap_or_default().to_string()).join(", ")))?;
}
if let Some(on) = p.get("box_mode") {
app.pick.box_mode = on.as_bool().ok_or("`box_mode` must be true or false")?;
}
let mode = match p.get("mode") {
None => Mode::Replace,
Some(m) => serde_json::from_value::<Mode>(m.clone()).map_err(|_| "`mode` must be replace, add, remove or toggle".to_string())?,
};
let at = p.get("at").map(|v| points::<2>(v, "at", "a point [x, y]")).transpose()?;
let corners = p.get("box").map(|v| points::<4>(v, "box", "[x0, y0, x1, y1]")).transpose()?;
if corners.is_some() && !app.pick.filter.boxes() {
return Err(format!("box selection picks nodes, element faces or elements, not {}", app.pick.filter.label().to_lowercase()));
}
if (at.is_some() || corners.is_some()) && app.viewport.rect.is_none() {
return Err("the viewport has not been drawn yet".into());
}
let items = items_from_params(p)?;
if p.get("clear").and_then(Value::as_bool) == Some(true) {
app.clear_selection();
}
if !items.is_empty() {
app.select(items, mode);
}
if let Some([x, y]) = at {
app.select_at(egui::pos2(x, y), mode);
}
if let Some([x0, y0, x1, y1]) = corners {
app.select_box(egui::pos2(x0, y0), egui::pos2(x1, y1), mode);
}
Ok(selection_json(app))
}
pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
@@ -131,6 +201,8 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
"version": crate::VERSION,
})),
"ui.toolbar" => ok(crate::toolbar::describe(app)),
"ui.select" if p.get("kind").is_none() && VIEWPORT_SELECT.iter().any(|k| p.get(*k).is_some()) => wrap(select(app, p)),
"ui.selection" => ok(selection_json(app)),
"ui.select" => {
// Select a definition as a browser click does (its properties show on the right).
let kind = p.get("kind").cloned().and_then(|k| serde_json::from_value::<crate::browser::Def>(k).ok());
@@ -209,6 +281,12 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
if let (Some(d), Some(params)) = (app.dialog.as_mut(), p.get("params")) {
d.text = serde_json::to_string_pretty(params).unwrap_or_default();
}
// The form's Target row: the viewport's selection into the parameters.
if b("use_selection")
&& let Err(e) = app.use_selection_in_dialog()
{
return err(e);
}
if b("run") {
return wrap(app.run_dialog());
}
@@ -454,6 +532,122 @@ mod tests {
assert_eq!(call(&mut app, "no.method", json!({}))["ok"], false);
}
/// One frame of the whole window (1400 × 900 points), with the control channel's synthetic
/// input, as the desktop app runs it.
fn frame(app: &mut ModelApp, ctx: &egui::Context) {
let mut raw =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() };
app.raw_input_hook(&mut raw);
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
}
fn call_in(app: &mut ModelApp, ctx: &egui::Context, method: &str, params: Value) -> Value {
let (req, _rx) = ControlRequest::new(method, params);
match handle(app, ctx, &req) {
Outcome::Done(v) => v,
Outcome::Screenshot { .. } => json!({"screenshot": true}),
}
}
/// The cantilever's free end (x = 100), looked at from +x: picked by a simulated click and
/// by `ui.select`, made into a set through the set form's Target row, and resolved.
#[test]
fn the_cantilevers_end_is_picked_and_made_a_set_over_the_control_channel() {
let mut app = ModelApp::new(Session::default());
app.open_sample("cantilever");
let ctx = egui::Context::default();
assert_eq!(call_in(&mut app, &ctx, "ui.view", json!({"view": "right"}))["ok"], true);
app.fit_view();
frame(&mut app, &ctx);
let viewport = app.viewport.rect.unwrap();
// A click on the end cell at y ∈ [0, 5], z ∈ [5, 10]: element 1 + 19 + 0·20 + 1·40, face 4.
let at = crate::viewport::to_screen(&app, fieldcraft_render::Vec3::new(100.0, 2.5, 7.5)).unwrap();
assert_eq!(call_in(&mut app, &ctx, "ui.select", json!({"filter": "element_faces"}))["result"]["count"], 0);
call_in(&mut app, &ctx, "ui.click", json!({"x": at.x, "y": at.y}));
for _ in 0..4 {
frame(&mut app, &ctx);
}
let r = call_in(&mut app, &ctx, "ui.selection", json!({}));
assert_eq!(r["result"]["items"], json!([{"element": 60, "face": 4}]), "{r}");
// What is under the pointer is the hover.
assert_eq!(r["result"]["hover"], json!({"element": 60, "face": 4}));
// The same pick by `ui.select {at}`, added to with Cmd (toggle) removes it again.
let r = call_in(&mut app, &ctx, "ui.select", json!({"at": [at.x, at.y], "mode": "toggle"}));
assert_eq!(r["result"]["count"], 0, "{r}");
// A box over the whole viewport holds the end's eight element faces (the rest are hidden).
let corners = [viewport.left(), viewport.top(), viewport.right(), viewport.bottom()];
let r = call_in(&mut app, &ctx, "ui.select", json!({"box": corners}));
assert_eq!(r["result"]["count"], 8, "{r}");
assert!(r["result"]["items"].as_array().unwrap().iter().all(|f| f["face"] == 4));
// The set form's Target row takes them; the set resolves to the end's faces and nodes.
call_in(&mut app, &ctx, "ui.start", json!({"command": "model.set.create"}));
let r = call_in(&mut app, &ctx, "ui.dialog", json!({"params": {"name": "end"}, "use_selection": true, "run": true}));
assert_eq!(r["ok"], true, "{r}");
let r = call_in(&mut app, &ctx, "engine.execute", json!({"command": "model.set.resolve", "params": {"name": "end"}}));
assert_eq!((r["result"]["faces"]["count"].as_u64(), r["result"]["nodes"]["count"].as_u64()), (Some(8), Some(15)), "{r}");
// A boundary condition's Target row makes a set of its own and names it.
call_in(&mut app, &ctx, "ui.start", json!({"command": "model.boundary_condition.create"}));
let r = call_in(&mut app, &ctx, "ui.dialog", json!({"params": {"name": "Hold", "type": "fixed"}, "use_selection": true, "run": true}));
assert_eq!(r["ok"], true, "{r}");
let bc = serde_json::to_value(app.session.model.boundary_conditions().get("Hold").unwrap()).unwrap();
assert_eq!(bc["target"], json!({"set": "Hold-set"}), "{bc}");
assert_eq!(app.session.model.resolve_set("Hold-set").unwrap().faces.len(), 8);
// Nodes in the box: the end's 3 × 5, none of those behind it.
let r = call_in(&mut app, &ctx, "ui.select", json!({"filter": "nodes", "box": corners}));
assert_eq!(r["result"]["count"], 15, "{r}");
// Escape clears the selection.
call_in(&mut app, &ctx, "ui.key", json!({"key": "Escape"}));
frame(&mut app, &ctx);
assert_eq!(call_in(&mut app, &ctx, "ui.selection", json!({}))["result"]["count"], 0);
}
#[test]
fn hostile_selections_are_refused_or_capped() {
let mut app = ModelApp::new(Session::default());
let ctx = egui::Context::default();
// Before the viewport is drawn, a point has nothing to pick on.
assert_eq!(call_in(&mut app, &ctx, "ui.select", json!({"at": [10, 10]}))["ok"], false);
// An empty scene picks nothing anywhere.
frame(&mut app, &ctx);
for filter in ["faces", "edges", "vertices", "bodies", "nodes", "element_faces", "elements"] {
let r = call_in(&mut app, &ctx, "ui.select", json!({"filter": filter, "at": [700, 450]}));
assert_eq!(r["result"]["count"], 0, "{filter}: {r}");
}
for bad in [
json!({"filter": "atoms"}),
json!({"mode": "xor", "nodes": [1]}),
json!({"at": [1e300, 5]}),
json!({"at": "here"}),
json!({"at": [1, 2, 3]}),
json!({"box": [0, 0, 1]}),
json!({"box": [0, f64::MAX, 1, 1]}),
json!({"filter": "faces", "box": [0, 0, 100, 100]}),
json!({"nodes": [0]}),
json!({"faces": [{"element": 1}]}),
json!({"geometry": [{"body": "B", "face": ""}]}),
json!({"elements": vec![1; crate::selection::MAX_SELECTION + 1]}),
] {
let r = call_in(&mut app, &ctx, "ui.select", bad.clone());
assert_eq!(r["ok"], false, "{bad}: {r}");
}
// A huge selection is capped and says so.
let all: Vec<u32> = (1..=crate::selection::MAX_SELECTION as u32).collect();
call_in(&mut app, &ctx, "ui.select", json!({"nodes": all}));
let r = call_in(&mut app, &ctx, "ui.select", json!({"elements": [1, 2], "mode": "add"}));
assert_eq!((r["result"]["count"].as_u64(), r["result"]["capped"].as_bool()), (Some(crate::selection::MAX_SELECTION as u64), Some(true)));
// The form's Target row: no form, a command without a target.
assert_eq!(call_in(&mut app, &ctx, "ui.dialog", json!({"use_selection": true}))["ok"], false);
call_in(&mut app, &ctx, "ui.start", json!({"command": "mesh.node.add"}));
assert_eq!(call_in(&mut app, &ctx, "ui.dialog", json!({"use_selection": true}))["ok"], false);
// `ui.select` with a definition still selects in the browser; `clear` empties the viewport's.
assert_eq!(call_in(&mut app, &ctx, "ui.select", json!({"clear": true}))["result"]["count"], 0);
frame(&mut app, &ctx);
}
#[test]
fn the_command_form_is_driven_after_ui_start() {
let mut app = ModelApp::new(Session::default());
@@ -533,6 +727,7 @@ mod tests {
"ui.toolbar",
"ui.prompt",
"ui.select",
"ui.selection",
"ui.fullscreen",
] {
for p in &hostile {
+88 -39
View File
@@ -1,7 +1,9 @@
//! GPU viewport: lit triangles and screen-space wide lines with a depth buffer, drawn in an
//! `egui_wgpu` paint callback. Geometry is uploaded when the scene changes; each frame only the
//! camera uniform changes. Nodes are drawn as zero-length lines, which the line shader turns
//! into squares.
//! into squares. The selection highlight is a second, small scene (the overlay) drawn over the
//! first: its faces pass the depth test over the faces they repeat, so a hover change uploads
//! only the overlay.
//!
//! A large mesh does not fit one vertex buffer (wgpu's default `max_buffer_size`, which eframe
//! requests, is 256 MiB): geometry is split into batches of at most [`MAX_BATCH_BYTES`] and the
@@ -79,6 +81,9 @@ pub struct ViewportCallback {
/// Changes whenever the scene does; the slot holds the scene for a new key.
pub key: u64,
pub slot: SceneSlot,
/// The same for the overlay (the selection highlight).
pub overlay_key: u64,
pub overlay_slot: SceneSlot,
/// Column-major view-projection.
pub view_proj: [[f32; 4]; 4],
/// Direction toward the eye (for lighting).
@@ -94,15 +99,61 @@ struct Batch {
count: u32,
}
/// Uploaded geometry: triangle and line batches, and the key they were uploaded for.
#[derive(Default)]
struct Layer {
key: Option<u64>,
tris: Vec<Batch>,
lines: Vec<Batch>,
}
impl Layer {
fn take(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, key: u64, slot: &SceneSlot) {
if self.key == Some(key) {
return;
}
if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) {
// The old buffers go first, so the new ones don't have to fit beside them.
self.tris.clear();
self.lines.clear();
let max = device.limits().max_buffer_size;
self.tris = upload(device, queue, "fc_tris", &sc.tris, TRI_SIZE, 3, max);
self.lines = upload(device, queue, "fc_lines", &sc.lines, LINE_SIZE, 1, max);
self.key = Some(key);
}
}
fn paint_tris(&self, pass: &mut wgpu::RenderPass<'static>, tri: &wgpu::RenderPipeline) {
if !self.tris.is_empty() {
pass.set_pipeline(tri);
for b in &self.tris {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..b.count, 0..1);
}
}
}
fn paint_lines(&self, pass: &mut wgpu::RenderPass<'static>, line: &wgpu::RenderPipeline) {
if !self.lines.is_empty() {
pass.set_pipeline(line);
for b in &self.lines {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..6, 0..b.count);
}
}
}
}
struct Resources {
tri: wgpu::RenderPipeline,
/// The overlay's faces: pulled toward the eye and depth-tested without writing depth.
overlay_tri: wgpu::RenderPipeline,
line: wgpu::RenderPipeline,
uniform: wgpu::Buffer,
bind: wgpu::BindGroup,
linear_out: bool,
key: Option<u64>,
tris: Vec<Batch>,
lines: Vec<Batch>,
scene: Layer,
overlay: Layer,
}
const SHADER: &str = r#"
@@ -166,6 +217,18 @@ fn biased_z(p: vec3<f32>, c: vec4<f32>, s: f32) -> f32 {
return c.z - u.screen.z * s * c.w;
}
/// An overlay face: as `vs_tri`, half a depth bias toward the eye, so it covers the scene face it
/// repeats (or the contour bands that split it).
@vertex
fn vs_overlay_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec4<f32>) -> TOut {
var o: TOut;
let clip = u.vp * vec4<f32>(p, 1.0);
o.pos = vec4<f32>(clip.xy, biased_z(p, clip, 0.5), clip.w);
o.n = n;
o.c = c;
return o;
}
struct LOut {
@builtin(position) pos: vec4<f32>,
@location(0) c: vec4<f32>,
@@ -295,6 +358,7 @@ impl Resources {
};
Resources {
tri: pipeline("fc_tris", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Less, true), None),
overlay_tri: pipeline("fc_overlay_tris", "vs_overlay_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), None),
line: pipeline(
"fc_lines",
"vs_line",
@@ -306,24 +370,8 @@ impl Resources {
uniform,
bind,
linear_out: t.format.is_srgb(),
key: None,
tris: Vec::new(),
lines: Vec::new(),
}
}
fn take(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, key: u64, slot: &SceneSlot) {
if self.key == Some(key) {
return;
}
if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) {
// The old buffers go first, so the new ones don't have to fit beside them.
self.tris.clear();
self.lines.clear();
let max = device.limits().max_buffer_size;
self.tris = upload(device, queue, "fc_tris", &sc.tris, TRI_SIZE, 3, max);
self.lines = upload(device, queue, "fc_lines", &sc.lines, LINE_SIZE, 1, max);
self.key = Some(key);
scene: Layer::default(),
overlay: Layer::default(),
}
}
}
@@ -391,7 +439,8 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
res.take(device, queue, self.key, &self.slot);
res.scene.take(device, queue, self.key, &self.slot);
res.overlay.take(device, queue, self.overlay_key, &self.overlay_slot);
queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, res.linear_out));
Vec::new()
}
@@ -404,20 +453,12 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
}
pass.set_viewport(vp.left_px as f32, vp.top_px as f32, vp.width_px as f32, vp.height_px as f32, 0.0, 1.0);
pass.set_bind_group(0, &res.bind, &[]);
if !res.tris.is_empty() {
pass.set_pipeline(&res.tri);
for b in &res.tris {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..b.count, 0..1);
}
}
if !res.lines.is_empty() {
pass.set_pipeline(&res.line);
for b in &res.lines {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..6, 0..b.count);
}
}
// Faces first, the overlay's over the scene's, then every line, so the mesh edges stay on
// highlighted faces (as the CPU renderer draws them).
res.scene.paint_tris(pass, &res.tri);
res.overlay.paint_tris(pass, &res.overlay_tri);
res.scene.paint_lines(pass, &res.line);
res.overlay.paint_lines(pass, &res.line);
}
}
@@ -492,8 +533,16 @@ mod tests {
// Widths are in physical pixels.
assert_eq!(f32::from_le_bytes([g.lines[28], g.lines[29], g.lines[30], g.lines[31]]), 3.0);
assert_eq!(f32::from_le_bytes([g.lines[60], g.lines[61], g.lines[62], g.lines[63]]), 8.0);
let cb =
ViewportCallback { key: 0, slot: SceneSlot::default(), view_proj: [[0.0; 4]; 4], back: [0.0; 3], eye: [0.0; 4], size_px: [1.0, 1.0] };
let cb = ViewportCallback {
key: 0,
slot: SceneSlot::default(),
overlay_key: 0,
overlay_slot: SceneSlot::default(),
view_proj: [[0.0; 4]; 4],
back: [0.0; 3],
eye: [0.0; 4],
size_px: [1.0, 1.0],
};
assert_eq!(uniform_bytes(&cb, true).len() as u64, UNIFORM_SIZE);
}
}
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff. Show more