Imported from Teased-oChroid-orrA/powershell_tool (
fea-core/AGENTS.md). Install upstream withnpx skills add Teased-oChroid-orrA/powershell_tool --skill fea-core. Copyright stays with the author.
fea-core
TL;DR: General finite-element kernel: 2D (plane stress / plane strain / axisymmetric) and 3D solid elements (Tri3/6, Quad4/8/9, Tet4/10, Hex8/20/27), colour-parallel symmetric block assembly, Dirichlet elimination, faer sparse Cholesky (AMD or geometric nested dissection) or PCG + smoothed-aggregation AMG for large 3D, multi-point constraints and reference-point rigid coupling, isotropic and anisotropic elasticity, point / surface (constant or field) / body / thermal loads, nodal stress (averaged or SPR) with a ZZ error estimate, boundary-face extraction,
.vtuwriter, structured mesh generators. Pure function of inputs; no file I/O, no UI. Plan, validation and measured speed:docs/fea-core.md, decision:docs/adr/ADR-012-general-fea-kernel.md.
Purpose
Owns: element library, element kernels, assembly, linear solve, loads, stress recovery, mesh generators.
Does not own: any UI, material tables (mechanics-core), the lug physics (lug-solver: FeaLug in lug-solver/src/fea*.rs runs the lug on this crate and is the toolbox's main solver; its condensed / finite-strain solvers are the legacy comparison, see docs/fea-core.md Phase 7). lug-solver depends on this crate, never the reverse. The app-tui FEA Workbench (problem definition, mesh preview, contours) is fea-problem + app-tui/src/toolboxes/fea_workbench/ (docs/fea-core.md Phase 8).
Code Map
| Looking for... | Go to |
|---|---|
| Shape functions, Gauss rules, tabulated per element type (VTK node order) | src/element.rs |
| Geometry (Jacobian, gradients), stiffness (node-pair and fast component-major), thermal/body loads, strain/stress at a point | src/kernel.rs |
| Sparsity pattern, element colouring, parallel scatter assembly, symmetric matvec | src/assembly.rs |
| Dirichlet elimination, symbolic + numeric Cholesky (faer), solve | src/linear.rs |
| Geometric nested dissection (2D/3D) | src/ordering.rs |
| Point loads, edge/face traction and pressure, body force, thermal | src/loads.rs |
Model (mesh + pattern), solve_static, solve_static_many (several cases, one factorisation), check_constrained, Gauss and nodal stresses |
src/analysis.rs |
| Boundary faces / edges of any element mix in load orientation | src/topology.rs |
| SPR nodal stress recovery, ZZ error estimate, true-error energy for verification | src/recover.rs |
| Multi-point constraints, reference-point rigid coupling, constrained solve | src/mpc.rs |
| CSR kit; smoothed-aggregation AMG + PCG | src/sparse.rs, src/amg.rs (selected by SolveMethod in analysis.rs) |
.vtu text writer |
src/vtu.rs |
Stress at an arbitrary point (Model::locator, Locator::stress_at) |
src/locate.rs, tests/locate.rs |
| Nonlinear material points (small / finite strain J2, plane stress) | src/material.rs |
Nonlinear assembly, Newton / arc-length driver, NlState |
src/nonlinear.rs |
Contact: rigid / deformable masters, AL normal + Coulomb friction, BVH, free rigid translation / (2D circle) rotation, traction output (NlSolution::contact_tractions) |
src/contact.rs (Model::solve_nonlinear_contact) |
| Follower (non-dead) pressure with its load stiffness | src/loads.rs::follower_face, applied in Ctx::eval (nonlinear.rs) |
| Anisotropic elastic-plastic update (small strain), thermal expansion tensor | src/material.rs::small_strain_update_aniso, src/mesh.rs (Aniso::alpha, Elastic::with_alpha_tensor, thermal_strain) |
Bushing-in-housing interference-fit contacts + the speed/tightness Tuning shared by lug-solver and eccentric-bushing |
src/fit.rs |
| 2D region geometry (lines + arcs, holes, named segments) | src/geometry.rs |
| Delaunay + Ruppert triangulation with a size function | src/delaunay.rs |
| Region -> Tri3/Tri6/Quad4/8/9 mesh with curved boundary nodes, named sets and surfaces | src/mesh2d.rs |
ZZ-error-driven size field for adaptive remeshing, and the shared refine/coarsen loop (adapt::refine, Pass; 3D: SizeField3, adapted_size_field_3d) every consumer calls with its own mesher and solver |
src/adapt.rs |
| Extrude / revolve quad meshes into hex and tri meshes into wedge (Wedge6/15) meshes; a hybrid 2D mesh sweeps into a conforming Hex+Wedge mesh (ADR-022 addendum) | src/sweep.rs |
Tetrahedral volume mesh of a solid from a signed distance + size field (Kuhn lattice, longest-edge bisection with Rivara propagation, feature-preserving snapping (reconnect-and-snap-again rounds, RECONNECT_ROUNDS; Phase 73), Tet10 curved boundary edges, named outward faces); the 3D size-field remesher for adaptive refinement |
src/tetmesh.rs (mesh_solid, Solid, FaceHit), tests/tetmesh.rs |
| Gmsh MSH and Abaqus INP import | src/import.rs |
| Structured grids of any element type through a coordinate map (curved boundaries, node sets, surfaces) | src/generate.rs |
| Closed-form validation (patch tests, Lame plane strain/axisymmetric/3D, thermal, reactions) | tests/linear_static.rs |
| Timoshenko cantilever, Kirsch plate, pressurised sphere | tests/classic.rs |
| SPR exactness, ZZ effectivity | tests/recovery.rs |
| MPC / rigid coupling vs beam theory | tests/mpc.rs |
| Iterative solver vs direct, mesh-independent iteration counts | tests/iterative.rs |
| Anisotropic law (equivalence, rotation, patch, thermal, bending, frame invariance) | tests/anisotropic.rs |
| Nonlinear: uniaxial analytic, thick cylinder / sphere limit loads, Hill, finite-strain collapse | tests/nonlinear.rs |
| Contact: Hertz 2D/3D, friction, shrink fit, patch test, contact + plasticity | tests/contact.rs |
| Contact on random stacked blocks (2D/3D, matching and non-matching meshes, friction below the limit) | tests/contact_sweep.rs |
| Unstructured meshes vs Lame / Kirsch, multi-hole regions | tests/mesh2d.rs |
| Adaptive vs uniform refinement, size field, the shared loop | tests/adapt.rs |
| Sweeps vs 2D / axisymmetric solutions | tests/sweep.rs |
| Wedge elements, Hex+Wedge hybrid patch test and 2D equivalence | tests/wedge.rs |
Size prediction before any solution (geometry + point/line sources, g-Lipschitz) |
src/meshing/predictor.rs |
| Multi-material planar meshes (per-region hybrid or unstructured, forced-unstructured interface cells, shared boundaries cut at both sides' nodes until the weld conforms, conformity gate) | src/meshing/multimaterial.rs |
| Gmsh / Abaqus import and round trip | tests/import.rs |
| Solve report, condition estimate | src/report.rs, tests/report.rs |
| Validation, adaptive solve, nonlinear ladder | src/validate.rs, src/strategy.rs, tests/strategy.rs |
| Modal / buckling / transient, mass and geometric stiffness | src/dynamics.rs, src/dense.rs, tests/dynamics.rs (decision: docs/adr/ADR-013-eigen-and-transient-methods.md) |
| Heat conduction, temperature field as a structural load | src/thermal.rs, tests/thermal.rs |
Frame local axes (structural::member_axes, used by fea-problem::members for distributed-load balance) |
src/structural.rs |
| Adjoint sensitivities, block-sizing optimiser | src/sensitivity.rs, src/optimize.rs, tests/sensitivity.rs, tests/optimize.rs |
| Neo-Hookean hyperelasticity | src/material.rs::neo_hookean_update, tests/hyperelastic.rs |
Mesh adjacency, conformity check, element quality, welding, quality-guarded smoothing, solver-cost model (epic #25, docs/hybrid-meshing-amr-evidence.md) |
src/meshing/{adjacency,quality,weld,smooth,cost}.rs |
| 2D hybrid structured / unstructured mesher (cell partition of a region with a rectilinear, angled or curved outline, boundary-layer cut cells, stretch guard, welded Quad + Tri blocks, explicit fallback, candidate selection by predicted cost) | src/meshing/{partition,hybrid}.rs, tests/hybrid.rs |
| Goal-oriented adaptivity: estimators (ZZ, recovered-stress DWR, enriched-dual DWR, equilibrated H(div) stress), goals, zones, fusion, Doerfler marking, the solve-estimate-mark-remesh loop, eigenvalue loop, snapshots / transfer / rollback | src/adaptivity/, tests/{dwr,equilibrated,spr_interface,amr,eigen}.rs (design: docs/adr/ADR-022-hybrid-meshing-and-goal-oriented-adaptivity.md) |
Speed baseline (ignored tests; the faer-vs-lug-solver gate lives in lug-solver/tests/sparse_speed_gate.rs) |
tests/bench.rs |
Derivative engine (src/derivative/, epic #39)
Generic Scalar kernels (dual / reverse tape / hyper-dual / complex), a policy engine that picks the method with a reason, exact linear, plastic-path,
contact, transient and second-order sensitivities. Read docs/derivative-engine.md (architecture, support matrix, limits) before changing it.
Rules: derivatives are of the computed response, not the continuous one; never loosen a finite-difference tolerance to pass (use Richardson steps);
refuse or flag nonsmooth points instead of returning a classical derivative; any new kernel registers its capabilities in kernels.rs; keep ordered
(deterministic) reductions; CostCalibration defaults come from tests/derivative_bench.rs.
Contracts
- Long contact solves are steered through
NlOptions:interrupt(Interrupt: wall-clock deadline and/or sharedAtomicBool, checked at every Newton iteration and step; ends withStop::Interrupted, notcomplete()),observer(StepObserver, called after every converged load step with aStepEvent: live progress).NlOptionsisClone, notCopy. stick_slip_guard(on inTuning::friction(), off elsewhere) is for frictional contact whose Newton iteration cycles. Root cause found (eccentric bushing, 2026-10-08): points at the edge of a contact patch, at ~zero pressure, enter and leave the contact from one iteration to the next (NL_TRACEshows the active set of the first contact changing between iterations); each flip jumps the residual by more than the tolerance, so Newton alternates forever, and which way it falls depends on round-off (the case passed on macOS and failed on Linux CI).NlSolution::held_solvescounts the solves that needed it (65 % of the random eccentric-bushing cases: it is the workhorse, not a rare net). When 8 iterations bring no 3 % gain of the best residual the solve holds the active set (ContactSet::hold_active_set: a point is in contact iff it was at that iterate, its pressure following the smooth branch; released when the solve ends) and gets 15 more iterations: every case that had failed all attempts (fine mesh, concentric thick wall, soft parts, the reported 8856 lbf case) then converged on the first attempt, 2-10 x faster. Only if that also stagnates: withstall_tolthe lowest iterate is accepted when its residual is belowstall_tolx force scale (NlSolution::stalled_solves; the force scale includes large constant forces, so check what the answer must still satisfy), else the solve fails (STAGNATED, 3 step cuts).step_memory(each Newton step started from the last accepted backtrack fraction) was removed: it rescued one case, ruined another, and no case needed it once the hold existed.- Node order is VTK (corners, edge midsides, face centres, volume centre); a mesh writes to
.vtuwithout renumbering. Boundary faces/edges are listed counter-clockwise seen from outside (2D: body on the left):Pressure(p)pushes into the body. - Units are free (the kernel is unit-free); conventionally inch / psi / lbf. Plane problems carry a thickness in
Physics; axisymmetric loads and stiffness are for the full 360 degrees (x= radius). - Isotropic law is
sigma = lambda tr(eps) I + 2 mu epsin every case; plane stress useslambda* = E nu / (1 - nu^2). Thermal:sigma_th = -(3 lambda + 2 mu) alpha dT(plane stress:2 lambda* + 2 mu). - Matrices are the lower block triangle of
d x dnode blocks (BlockMatrix,Pattern); a node no element touches still gets a diagonal block and must be constrained. - Assembly writes disjoint blocks per colour class through a raw pointer (
assembly.rs, oneunsafeblock): do not break the invariant "elements of one colour share no node". - Constrained dofs are eliminated, never penalised (Dirichlet in
linear.rs, multi-point constraints byT^T K Tinmpc.rs; extra dofs>= mesh.n_dofs()carry reference-point translations/rotations and have no stiffness of their own). - Anisotropic
Elastic(aniso: Some) uses the B-matrix kernel andLayout::NodeBlock;e/nuare then only a Voigt-average display value, so never readmat.e/mat.nu/lambda()/mu()outside the isotropic branches (stiffness,stiffness_fast,thermal_load,stress_from_strain,complementary_energyall branch onaniso). Plane problems need a material symmetric aboutz; axisymmetric axes are(r, z, theta). solve_static/solve_static_manyrefuse a model that leaves a rigid-body motion of any connected piece free (Model::check_constrained: the rigid modes against the constrained dofs): a singular system can factorise anyway and return the answer plus an arbitrary rigid motion. A node no element touches is ignored by the check and still needs its own constraint. Reference-point / MPC solves (mpc.rs) and the nonlinear driver do their own constraining and do not call it.SolveMethod::Autofactors (faer) except 3D systems whose factor holdsAUTO_ITERATIVE_FACTOR_NNZ(60M) entries or more (looked at fromAUTO_ANALYSE_DOFS= 20k free dofs) or exceedingAUTO_ITERATIVE_DOFS(150k), where it runs PCG + AMG; an unconverged iteration is anErr, never a wrong answer, andAutofalls back to the direct solve (Solution::fallbacksays why). The numeric factorisation is parallel from 1M (3D) / 8M (2D) factor entries (linear.rs).
Pitfalls
-
Eigen residuals (
dynamics.rs): the reported residual is a backward error less a100 eps max(K_ii)floating-point allowance, so a rigid-body mode of a free structure (K x= round-off) reads ~0 rather than ~1; it is a verification, not a convergence flag, and a pair above the tolerance is anErr. Buckling shifts below the smallest positive factor (the proof is a successful Cholesky ofK - sigma B); a pencil with a single sign needs no shift trouble, a bending stress state (both signs) does. -
Temperature fields:
Loads::temperatureis a nodal field on top of the uniformdelta_t; stresses must be recovered with the same field (gauss_stresses_field/nodal_stresses_field), otherwise the thermal strain is missing from the stress (a test shows the error is the fullE alpha dT). The nonlinear driver refuses it with plasticity. -
Tiny strains in finite-strain laws: write the law in the displacement gradient (
H), not inF = I + H:F - F^-Tlosesepsabsolute digits and Newton cannot reach a relative tolerance belowG eps / force(neo-Hookean fixed this way; the Hencky J2 path has the same floor in principle). -
stagnated_cuts(frictionalstick_slip_guard) counts per load step and resets after a converged step. -
Serendipity and quadratic faces: consistent nodal forces of a uniform traction are not equal (corners carry negative share on Quad8/Hex20). Apply end loads as
SurfaceLoad::Tractionon a face, never as equal nodal forces (a test did this wrong and showed +4.6 % tip deflection). -
Plane strain free thermal expansion is
(1 + nu) alpha dT, notalpha dT(eps_zz = 0). -
Hex8 under self weight is only exact to ~5e-7: the exact field needs
x^2terms trilinear elements lack. -
Tri3 (CST) and Tet4 are poor on pressure-driven problems (Lame at 16x16: 2 % displacement error); they are in the library for meshers, not recommended for results.
-
Ordering: AMD wins in 2D; geometric nested dissection wins 2-3x on trilinear hexahedra, ties on quadratic hex/tets.
Ordering::Autoanalyses both in 3D and keeps the sparser factor.faersolve is parallel by default (global rayon parallelism). -
Dev profile is optimised (
[profile.dev.package.fea-core]in the rootCargo.toml). -
Speed: element stiffness is not the bottleneck (< 1 % of a 3D linear solve); the factorization is. A register-tiled GEMM-style stiffness kernel and
pulpruntime SIMD dispatch (x86 Windows builds are SSE2-only by default) are deferred until the Phase 3 nonlinear loop re-assembles every Newton iteration and a profile shows assembly matters. -
SPR needs
nt + 2samples per patch (a two-triangle Tri6 patch has 6 samples for 6 unknowns and interpolates noise); boundary nodes take an interior neighbour's polynomial (one-sided boundary patches are inaccurate). The ZZ estimate is only meaningful (effectivity ~1) on smooth solutions; near a stress concentration the coarse-mesh estimate overshoots. -
Mesh grading toward a hole: an exponent above ~1.2 makes the Jacobian vanish at the hole edge and ruins the first element ring (found validating Kirsch).
-
AMG: 2D and strongly anisotropic-aspect meshes converge slowly (~100 iterations); a strength-of-connection filter cuts iterations but raises setup (coarsening stalls into a big coarse factorization), so default is no filter.
AmgOptionsis the tuning surface. -
Test-design lesson: a quadratic displacement field is only an FE-exact solution if its
-div(sigma)is applied as body force. -
Meshing: boundary vertices come from the geometry's curves (not the chord);
Regionouter loop is CCW and holes CW whatever order is given. The mesher conforms segments by splitting (never by flipping), so every boundary subsegment is a triangle edge; a line piece splits at the midpoint of its actual chord, an arc piece at the curve point. Ray casting must use one closed polyline (resampling each segment independently leaves a rounding gap that swallows a crossing). -
Quad meshes come from splitting each triangle into three quads (Catmull-Clark style): quad edge ~ half the triangle edge, so
adapt::adapted_size_field(.., quad_split = true)triples the element area to recover the triangle size. -
Importers verify quadratic node order geometrically (midside nodes within 0.3 element sizes of the position their corners imply); keep that check when adding element types, and build test files from the format's own edge/face definitions, not from the permutation table under test.
-
Hex sweeps cannot touch the revolution axis (
x = 0nodes would create zero-length edges); use axisymmetric elements there. -
Nonlinear:
Block::plasticity(J2) switches a block to the plastic path; the linearsolve_staticignores it. Elastic blocks in a nonlinear model use the linear kernels (small strain), so a finite-strain analysis needsJ2::elastic_finite()on its elastic blocks. Stress/tangent conventions:S= Cauchy (small) / first Piola-Kirchhoff (finite),A = dS/dHas a 9x9[3i+J][3k+L]; the 4th-order tangent contracts with the shape-function gradients (plus the hoop entryu_r / rfor axisymmetry). Plane-stress convergence ofsigma_zz = 0must be scaled by the elastic stress scale, never by the yield stress (an elastic law hassigma_y = 1e30; that bug shipped to the first test run). -
Displacement control needs the tangent predictor (done in
Ctx::newton); arc-length uses a secant predictor (the sign ofK^-1 fis meaningless where the tangent is singular) and falls back to LDL^T when Cholesky fails; load control must NOT fall back (an indefinite tangent there is an unstable equilibrium and Newton would converge to it). -
The nonlinear driver factorizes with direct faer only; do not point it at the AMG path without a plan for indefinite tangents.
-
Contact: a slave point's gap/normal come from the exact master (rigid analytic or Newton projection onto the quadratic face), weights from the reference slave surface. The collocation rule matters: full Gauss on quadratic edges oscillates (over-constrained), nodal is smooth against rigid masters in 2D and 3D, reduced Gauss is right for deformable masters (nodal fails the patch test there). Mean pressure, transmitted force and contact extent are reliable; pointwise Gauss-point pressures on non-matching deformable meshes scatter.
-
Friction: keep
eps_n,eps_tnearE / h; the symmetrised sliding tangent is indefinite, so contact analyses factor with LDL^T fallback and freeze the sliding friction in the tangent. The slip history must be seeded for points touching at the start (initial_state) and committed for points within the activation gap, not only active ones, or the first step transmits no friction. -
Contact needs load control; rigid masters move by
shift + lambda * travel; the matrix pattern is extended once withContactSet::pattern_pairsfrom the initial proximity (margin): a contact reaching a node pair outside it is anErr. -
Free rigid translations (
RigidMaster::with_free_translation): the translation components are extra unknowns after the mesh dofs;ContactSet::evalfillsExtraOut { rq, kuq, kqq }andCtx::newton_stepcondenses them with a Schur complement over the existing factorization ofK_uu. Residual sign:r_y = -(force on slave), force on master= sum r_y,R_q = sum r_q - lambda loadwithr_q = -r_y. A free body with no contact is singular (error), so start it touching (shift). The first tangent of a step uses the near-contact stabiliser and is deliberately not consistent; every later one is exact. -
Never take a central difference of
max(0, .)(found cross-checking the lug): the contact tangent differentiates the smooth active branch (PointCtx::smooth). UseNL_FDCHECK=1(NL_FDH,NL_TRACE,NL_DEBUG) to compare any analysis' tangent with finite differences; expect ~1e-11 from the second iteration on. -
Loads::block_bodyloads one block only (a pin inside an assembly of bodies);Loads::bodyloads all blocks. -
Editing hazard (cost one lost test module): never replace a file tail with
s[:i] + newwhen the original had content afteri; checkgit diff --stat/ test counts after scripted edits. -
Contact additions (lug integration):
ContactSpec::with_overlap(an interference fit: gap = projected gap - overlap, deformable masters);solve_nonlinear_contact_from+Start(continue from a converged state, e.g. the fit, with a shorter displacement vector for bodies added later);Loads::ground(weak springs(dof, k)that hold a body only contact holds; leakk u);NlOptions::{stop_on_plateau, stop_on_fall, stall_tol, first_step}.stop_on_plateaureads travel not steps (load_flat: gained < 1.2 % of the top over the last 5 % of the travel; also a failed step at < 4 % gain is a limit state), because tiny post-cut steps made any load look flat. -
Friction tangent: a smooth-branch evaluation can see
p < 0at a point on the active-set boundary: the capacity ismax(mu p, 0)(a negative one flipped the traction and 0/0 made the tangent NaN: frictional runs near full load died withZeroPivot).factor_ldltretries an exactly zero pivot with a1e-10..1e-6diagonal shift and refuses a non-finite tangent. Adding themu dpcoupling to the symmetrised sliding tangent stalls Newton, and a load-ratio predictor made friction worse (both tried, reverted). -
Speed facts (10k-dof 2D lug): faer is sequential below 40k free dofs (
parallelism_for; its threads cost more than they save), the assembly keeps rayon; the 2D plastic tangent usesaccumulate_tangent_2d(5x fewer operations); AMD beats geometric nested dissection ~2x in 2D; chord Newton did not help once evaluation was cheap;NL_PROFILE=1prints evaluation / factorisation / solve time. -
Kernel upgrades of the eccentric-bushing effort (record:
docs/fea-core.mdPhase 9, evidence indocs/eccentric-bushing.mdsection 9):- Arc-length works with deformable contact (not with free rigid masters): after every converged arc step the AL passes run at the fixed load, every step starts from a load-controlled Newton polish, and a step that overshoots
lambda_maxis replaced by a load-controlled step that lands on it exactly.Start::lambdacontinues a run that already applied part of its load;NlOptions::stop_at_forcepresses a body in by prescribed displacement until the first master carries a target force (an overshooting step is halved). - The frictional tangent is made consistent by defect correction: the factorised matrix stays symmetric with sliding friction frozen; the exact tangent minus it (the
mu dpcoupling and the unsymmetric part, per frictional point,ContactStats::defect; likewise the follower-load stiffness,Eval::load_defect) is applied byrefine_consistent(iterative refinement over the existing factorisation, <= 6 back-solves). Restricting the defect to sliding points was tried: Newton stalls again. Not available with free rigid translations / rotation (the Schur complement assumes symmetry: near the friction limit a moment-controlled run slows). - Follower pressure (
Loads::followers): the load follows the deformed surface; verified against the exact Hencky compression (to 1e-9, 2D and 3D) and finite differences of the load stiffness. LDL^T is used for any run with followers (the symmetric part of the load stiffness is indefinite well before the body is). - Anisotropic elasticity with J2 plasticity (small strain; finite strain stays isotropic) solves the return mapping for stress and multiplier together (the flow direction changes during the return because
D nis not parallel ton); with an isotropic stiffness it reproduces the isotropic law to 1e-9. Thermal expansion tensor for anisotropic materials; plasticity with a temperature change is still refused. - Convergence is measured against the elastic internal force (
Eval::f_elem) and an absolute floor1e-10 |f_ext|; a run whose bodies are held by nothing is refused (check_not_floating);NlSolution::ground_leakis the net force the weak grounding springs carry; a node pair outside the contact matrix pattern retries with doubled margins (4 times). - The closest-point projection's convergence tolerance is round-off aware (
eps |x| / |tau|); a fixed1e-14made contact points silently leave contact once coordinates were of order one (a hole at (1, 1) failed, at the origin it solved):a_contact_solution_does_not_depend_on_where_the_model_sits. - The tangent predictor of prescribed displacements is evaluated without the near-contact stabiliser (
ContactSet::set_stabilise), which would drag an approaching body along. - Contact robustness found by random corpora (2026-10-08,
fea-problem/tests/sweep.rs,lug-solver/tests/kernel_sweep.rs,fea-core/tests/contact_sweep.rs; each failure was traced before it was fixed, and each fix was tried on the other corpora):- Master-face choice is sticky. A slave point exactly on an element edge of the master surface (matching meshes, nodal collocation: every vertex of a concentric fit) lies on two faces whose normals differ (the surface is only C0). The minimum-gap rule picked either on round-off, so the residual jumped ~5 lbf at those points between iterations and Newton stagnated at every step size (the active-set hold cannot help: the set was constant). A point now stays on its committed face while its projection is within
STICKY_TOLof that face's domain, andhold_active_setalso freezes the master face of every point in contact (a point crossing an edge for real, in a stagnating solve, flipped faces again:tests/data/face_hold_eccentric.json). Diagnosis recipe:NL_TRACEprints per iteration the residual,|dx|, min gap, per contact the points in contact / slipping / off their committed face, and the two largest residual dofs; equal and opposite residuals at interface nodes that do not move withdx, plus "off their face" flickering, is this. Regressions:a_slave_point_on_a_master_element_edge_does_not_flip_faces,every_corpus_case_solves(--ignored). - The state a load step returns is an equilibrated pair.
refine_contactends the augmented-Lagrangian passes with a multiplier update that is not re-solved; the state then paired the displacements with the updated multipliers, so a force read off it (NlSolution::contact.master_force, support reactions) missed the pin load by the update (4.5 % of a small pin load next to a large fit pressure;Verification::force_balancefailed 8 of 24 random lugs). The normal multiplier is now the one the displacements were solved for; the friction multiplier is the friction of the iterate (committing the slip history zeroes the slip, so a re-evaluation must reproduce it). All 24 pass. - One tangential penalty ratio (
fit::EPS_T_RATIO= 0.03,Tuning::eps_t): stacked 3D blocks sheared near the friction limit stagnated at 0.1, converged at 0.03; 0.01 and 0.003 fail other cases. The two hand-written 0.03 inlug-solverandfit.rsnow use the constant. - Tried and rejected: holding the active set in every stagnating contact solve (not only frictional ones) breaks
a_lightly_loaded_stiff_penalty_contact_converges_in_few_factorisations(a free pin that has not touched yet is held out of contact: singular rigid-body dofs); a stricter convergence test on the free body's own force scale breaksthe_kernel_solves_every_load_angle_symmetrically(singular first step); history of a point that crossed to another master face evaluated at the current position of its committed material point (dropped: the tangent ignores that point's motion and Newton diverged on 2 of 14 stacked-block cases that pass without it). - 3D partial slip: fixed (was: 1/3 of random stacked-block cases above 0.4 of the friction limit did not converge). Root cause:
refine_consistentsolved(K + D) x = bby plain Richardson refinement over the symmetric factorisation, which diverges when most points are at their friction limit (the defectDis then as large asK) and left the inconsistent symmetric step. It is now right-preconditioned GMRES (30 vectors), kept only if the true residual falls. 0 of 24 failures, the sweep is 6x faster;contact_sweepgates 3D to 0.8. Fixing it exposed a latent wrong answer: a hold taken at a stagnating iterate that had lost the pin's contact (seehold_plausibleinnonlinear.rs).
- Master-face choice is sticky. A slave point exactly on an element edge of the master surface (matching meshes, nodal collocation: every vertex of a concentric fit) lies on two faces whose normals differ (the surface is only C0). The minimum-gap rule picked either on round-off, so the residual jumped ~5 lbf at those points between iterations and Newton stagnated at every step size (the active-set hold cannot help: the set was constant). A point now stays on its committed face while its projection is within
- Debug aids:
NL_TOPRES=1prints the four largest initial-residual dofs (node, component, position, displacement) of every step;NL_PROFILE=1splits evaluation into element assembly and contact.
- Arc-length works with deformable contact (not with free rigid masters): after every converged arc step the AL passes run at the fixed load, every step starts from a load-controlled Newton polish, and a step that overshoots
-
Stress recovery (
gauss_stresses_field,recover_spr,zz_error_field) runs its per-element / per-node stages with rayon above 1000 elements / 2000 nodes. Each item is computed independently and the sums are taken in element order, so the result is bit-identical for any thread count (tests/recovery.rs::recovery_is_bit_identical_on_one_thread_and_many); keep it that way (no parallelsum()/reduceof floats).tests/bench.rs::stress_recovery_throughputprints timings plus an SPR checksum to compare an optimisation against (Quad9 100x100: 66 -> 18 ms, Hex20 14^3: 101 -> 30 ms, Tet10 10^3: 29 -> 11 ms for gauss + SPR + ZZ on 8 cores).Mesh::boundary_facessorts (sorted-corner-key, owner) pairs instead of hashing (it was 7 of 12 ms of SPR on a Tet10 mesh); the testthe_sorted_face_search_equals_counting_every_facekeeps the old counting method as the reference. -
Epic #25 lessons (details and numbers:
docs/hybrid-meshing-amr-evidence.md):- Simplex consistent mass: the stiffness rule of Tri3 / Tet4 (one point) and Tri6 / Tet10 (degree 2) is too poor for
int rho N_a N_b;kernel::massusesElementKind::mass_table(collapsed Gauss, exact to the product degree). Before, a Tri3 element mass was rank one and Tri6 frequencies carried a quadrature error larger than the discretisation error. - Error integrals are not stiffness integrals: the Gauss points of the stiffness rule are (nearly) the superconvergent points of the finite-element stress, so
sigma* - sigma_hsampled there misses the error between them.zz_error_fieldandstress_error_energyintegrate withrecover::error_rule; the ZZ estimate then equals the true energy error on a smooth field (tests/recovery.rs). - SPR across materials:
recover_sprpatches span all elements around a node; for several materials userecover_spr_by_material(what ZZ and DWR call). A layered bar with an exact solution gives an estimate of zero only that way. - Which goal-oriented estimate: the recovered-stress DWR is dominated by singular corners and does not fall with the error on adapted meshes; a residual DWR with a polynomial-fit dual had the wrong sign (removed); the h-enriched dual (
adaptivity::enrich, 2D triangles and quadrilaterals) is the controller's estimate (effectivity 0.7-1.0). None of them sees boundary-approximation error (a circle by quadratic edges). - Controller: refinement is size-field remeshing (
NodalSize::Minso one marked element is honoured at its nodes); withoutmax_growthan unreachable physics tolerance makes every element mandatory and the mesh grows fourfold per cycle. - Nonlinear:
NlSolution::prev_stateis the state at the start of the last accepted step (the consistent tangent of a return map needs it);plastic_update_atis the single constitutive path of the element kernel and of every stress read-out. Plastic history interpolation between meshes is approximate and, for monotone proportional loading, worse than no history: usewarm_start_displacement. The nonlinear loop judges nonlinear and contact quantities by their change between meshes, not by an error estimate. - Hybrid mesher, outlines: the cells a non-axis-aligned boundary crosses (or comes within
BOUNDARY_PADsizes of) are unstructured andtrace_regionsfollows the true boundary pieces cut at the grid lines (nodes keyed by lattice index or rounded position; a failure to close is an explicit fallback). The grid subdivision must count only structured and boundary cells of a strip: the smallest size over the whole other axis sliced regular blocks into slivers (the template plate with a hole fell back at size jump 8.1).balanced_divisionshands cells that would stretch past 3x to the unstructured mesher;remove_pinchesgives up the smaller cell of a checkerboard corner (the unstructured loop would touch itself). - Hybrid mesher, multi-material: the unstructured mesher splits an encroached boundary segment where the interior asks for it, so two regions do not necessarily place the same interface nodes;
split_sharedcuts the shared segments at the union of both sides' corner nodes and meshes again. - Hybrid mesher: isotropic structured blocks are not cheaper than a graded Delaunay mesh; only an anisotropic block on a slender member with a local feature is (38 % fewer unknowns at equal energy error). Selection is by predicted cost (
meshing::cost), never by default.
- Simplex consistent mass: the stiffness rule of Tri3 / Tet4 (one point) and Tri6 / Tet10 (degree 2) is too poor for
Boundaries
Always
- Add or extend a closed-form / convergence-rate test for every numerical change (
tests/linear_static.rs); speed claims need atests/bench.rsmeasurement.
Never
- Add UI, material tables, or file I/O here (the
.vtuwriter must return aString). - Weaken a test tolerance to make a failing case pass; find the cause.
Navigation
Plan and measured numbers: docs/fea-core.md. Sibling solvers: lug-solver/AGENTS.md, edge-check/AGENTS.md.
- Native-meshing foundations:
meshing::{tet_topology,tet_delaunay,tet_flip,tet_smooth,metric,metric_refine,curved,compatibility}.tetrahedralizemeshes a point-set hull;Delaunayadds checked incremental insertion; PLC recovery lives insolid-kernel/src/plc_conform.rs, not here.tet_flip(exact 2-3 flips / edge removal, optional caller quality viaimprove_by, boundary and protected faces locked unless the opt-inplanar_boundaryflips edges of exactly coplanar convex boundary quadrilaterals: Phase 86, face names are not tracked) never moves vertices.transition::{tetrahedralize,tetrahedralize_wedges}convert selected Wedge6 (and Hex8) into Tet4 with a global-node diagonal rule (interface_diagonal; conforming interface; refuses quad-separating selections, first order only; no Pyramid5: converted regions only, an unconverted hex next to tets needs a pyramid element or the node tie ofweld::merge, see Phase 94).check_conformitycannot see a crack between blocks that triangulate a shared quad differently: count faces against the exterior.adaptivity::MeshStorekeeps the converged mesh perWorkloadKeyas the starting-mesh recommendation (0.56x time, Phase 98);adaptivity::feedbacksummarises finished AMR runs (regional demand) and advises initial size multipliers per exactWorkloadKeyfrom converged runs only (never coarsening;run_amruntouched).learned_costis the optional, per-machine learned stage-time model (log-linear ridge, 90 % leave-one-out interval, explicitFallback(reason)tocost; Phase 88);costprices a mesh (CAD/generation/adaptation/transfer once per mesh, solver terms per analysis, memory/time limits,break_even_analyses). Its rates are single-mesh calibrations (off by 0.2-7x across sizes: calibrate near the target size) and model the direct solve only:MeshCost::iterativeflags meshesAutosolves with PCG+AMG, whichchoose_withinrefuses to check against limits.motion::move_mesh/move_mesh_sliding(axis-alignedSlides) relocate nodes (any element mix) by size-stiffened pseudo-elastic sub-steps with inversion/quality gates (Gauss-point Jacobians included: a warped Hex8 can be negative between corners), rollback and aNeedsRemeshhand-over;adaptivity::moving::advanceloops motion, the caller's remesher andNodalInterpolationof a nodal field; it is not ALE;ale::transport_stepis the conservative upwind frame on a moving mesh (swept-volume fluxes, GCL exact; Phase 92).tet_collapseremoves interior vertices by edge collapse (exact orientation, no duplicate cells, length and quality guards, returns the old-to-new map; boundary and protected vertices stay).tet_smoothmoves only interior unprotected vertices (orientation-exact, monotone worst radius ratio;optimizealternates it withtet_flip);slide_planar_boundary(off by default) also slides boundary vertices inside an exactly planar patch; edges, corners and curved boundaries stay locked.metricis SPD fields (validation, gradation, intersection, complexity),metric_refinesplits by Rivara terminal edges then flips in the metric then smooths interior and planar-boundary vertices in the metric (refine_projectedprojects inserted boundary vertices onto a curved surface with an orientation guard: Phase 90; strong anisotropy needsboundary_first_passeswithFlipOptions::planar_boundary: Phase 89 converges 6:1 to 40:1, the defaults do not above 6:1) (size-penalised so grading survives;RefineOptions::smooth_passes); then collapses short edges (coarsen_passes, never below the current worst cell or the length target); curved boundaries stay polyhedral.curved::simplex_validityproves Tet10/Tri6 Jacobian positivity by Bernstein bound;tetmesh::tet10_okrequires it (snap_to_featuresmust keep every violated face within reach active, not only those within the edge reach: Phase 85; some vertices are still refused by the quality guard on a thick cylinder,tests/high_order_convergence.rs), because its 11 sample points alone accept ~0.5 % of folded elements.compatibilityregistry v2 gatesNativeConstrainedon the shell's smallest angle (NATIVE_MIN_SHELL_ANGLE_DEG); none of this is selected by production strategy (OCCT stays production). Exact-sign degeneracies and quality gates must remain intact. Evidence:docs/issue-46-phase-1.md,-58..-79.
