kernelcad-fea · git:20261010.308dd5d · 2026-10-10 · sha256 f4b57b61a1fe70e5

kernelcad-fea git:20261010.308dd5dA

Immutable. This exact content never changes and is served at /api/v1/blob/f4b57b61a1fe70e5.

---
name: kernelcad-fea
description: Answer "will this part hold this load?" with solver evidence — declare a linear-static structural study with shape.feaStudy({ material, fixed, loads, minSafetyFactor? }), then run it with run_fea for peak von Mises stress, peak displacement, minimum safety factor against yield, named hot-spot regions, mesh-trust flags and stress-heatmap PNGs. A declared minSafetyFactor turns the study into a gate that fails evaluate_script. Use when sizing a bracket, mount, tab, clevis or any load-bearing part, when a load-capacity check reported beam-not-applicable, or before committing to a wall thickness or material grade.
---

# kernelCAD — structural FEA

Declare the study in the model, run the solver, read the evidence.

```typescript
const bracket = box(8, 40, 90).union(box(90, 40, 8)).fillet(4, { parallel: [0, 1, 0], concave: true });

bracket.feaStudy({
  material: 'aluminum-6061',
  fixed: { atX: 0 },                                        // bolted to the wall
  loads: [{ faces: { atZ: 8 }, force: [0, 0, -400] }],      // 400 N total, downward
  meshSize: 4,
  minSafetyFactor: 2,                                        // makes it a GATE
});

return bracket;
```

```
run_fea({ file: 'bracket.kcad.ts', output_dir: '/tmp/bracket-fea' })
// → { ok: true, summary: { minSafetyFactor: 6.10, maxVonMisesMPa: 44.28,
//     hotSpots: [{ region: '@kc[fillet_1/face/f7]', ... }],
//     trust: { meshTrusted: false, reasons: [...] },
//     refinement: { passes: [4 mm: 42.5 MPa, 3 mm: 44.3 MPa],
//                   stoppedBy: 'still-untrusted' },
//     governingField: 'away-from-supports' },
//     images: ['.../heatmap/iso.png', '.../heatmap/front.png'] }
```

## Prerequisite: the solver toolchain

FEA shells out to two external, open-source programs. Neither is bundled.

```bash
sudo apt-get install -y calculix-ccx          # the ccx solver
python3 -m venv .fea-venv && .fea-venv/bin/pip install gmsh==4.15.2   # the mesher
```

Check before you design around it: `fea_summary({})` reports
`toolchain.available` plus the install hint. Override discovery with
`KERNELCAD_CCX` and `KERNELCAD_FEA_PYTHON`. If apt has no `calculix-ccx`
package, a container works equally well — mount the job directory and run
`ccx` inside it, then point `KERNELCAD_CCX` at a wrapper script that does so:

```bash
#!/bin/sh
exec docker run --rm -v "$PWD:/work" -w /work <ccx-image> ccx "$@"
```

**A missing toolchain is never a pass.** `run_fea` and the gate both fail with
`fea.solver.unavailable`.

## Authoring the study

`shape.feaStudy(spec)` registers a declaration bound to that shape. It returns
a handle, not a Shape — it is not part of the geometry chain.

| Field | Meaning |
| --- | --- |
| `material` | A grade name, or explicit `{ E, nu, yield }` in MPa / – / MPa. |
| `fixed` | Faces held rigid (all 3 translations). A `FaceQuery` or a `@kc[...]` ref. |
| `loads[]` | `{ faces, force: [Fx, Fy, Fz], name? }`. `force` is the TOTAL in newtons over those faces, applied as a uniform traction (area-weighted consistent nodal loads), not per node. |
| `meshSize` | Target element size, mm. Default: bounding-box diagonal / 20. |
| `minSafetyFactor` | Declaring it turns the study into an enforcement gate. |
| `name` | Study name; `run_fea({ study })` selects by it. Default `study`. |

Selectors are the same vocabulary as the rest of the API — `{ atX: 0 }`,
`{ atZ: wall }` (params stay symbolic and resolve at run time), or a
`@kc[...]` ref taken from `inspect({ of: 'faces' })`. Call that first when you
are not sure which face you mean.

### Materials

| Grade | E (MPa) | nu | yield (MPa) |
| --- | --- | --- | --- |
| `mild-steel` | 200000 | 0.29 | 250 |
| `aluminum-6061` | 70000 | 0.33 | 270 |
| `pla` | 3500 | 0.36 | 50 |
| `petg` | 2700 | 0.40 | 55 |
| `abs` | 2300 | 0.35 | 40 |
| `nylon` | 1700 | 0.39 | 45 |

`fea_summary({})` returns this table live. These are the same grade names
`arm.part({ material })` and `inspect({ of: 'mass', material })` take (one
material registry), and the bulk aliases `steel`, `aluminum` / `aluminium`
and `pet` resolve to `mild-steel`, `aluminum-6061` and `petg`. An unknown
grade is refused with the valid list — it is never guessed. For a measured
lot, pass `{ E, nu, yield }` directly.

## Reading the result

- **`minSafetyFactor`** = material yield / peak von Mises. Under 1 means the
  part yields at this load.
- **Support zone.** A `fixed` face is clamped rigidly, and the stress at the
  edge of a rigid clamp is singular: it climbs as the mesh is refined and real
  bolt or washer clamping spreads it. So `maxVonMisesMPa`, `minSafetyFactor`,
  `hotSpots` and the trust estimate are taken AWAY from the fixed-face edges
  (`governingField: 'away-from-supports'`). The excluded zone reaches 0.4 x
  the local wall thickness from each edge (`supportZoneRadiusMm`), the first
  read-out point of the structural hot-spot method. The raw clamp-edge value is
  kept as `peakAtSupportMPa` / `peakAtSupportAt` / `peakAtSupportRegion`, and
  `fea.stress.support-singularity` warns when it is higher than the governing
  peak. When the zone covers more than half the part, nothing is excluded
  (`governingField: 'all-nodes'`) and the warning says so. If the bolt region
  itself is the question, model the washer or head contact as a load face and
  fix the far side instead.
- **`hotSpots[]`** — the peak per region, named by `@kc[...]` face ref, sorted
  worst first. This is where to add material. The peak is very often at a
  fillet or a corner, not at the loaded face.
- **`trust.meshTrusted`** — `false` means the *stress* number is mesh-limited
  (inverted elements, >1% poor elements, or CalculiX's own nodal stress-error
  estimate above 25% in the high-stress region, where von Mises is at least
  half the governing peak). Displacement converges much faster and stays usable.
  `run_fea` and verify already refine the mesh for you (next section);
  when the result is still untrusted, `summary.refinement.stoppedBy` says why.
- **`refinement`** — every pass of the automatic refinement, coarsest
  first: `{ meshSizeMm, elementCount, governingPeakMPa,
  maxStressErrorPercent, meshTrusted }` (one or two), plus `stoppedBy`. The
  summary's own numbers are the last pass.
- **`equilibriumResidual`** — `|reaction + applied| / |applied|`. Near 1e-12 is
  healthy; anything large means the load or the constraint did not land where
  the study said, and the safety factor is meaningless.
- **`images` + `legend`** — banded stress heatmap PNGs from the same offline
  render pipeline as `render_preview`. The bands QUANTIZE the field (8 steps);
  the numbers in `summary` are the continuous truth. Use the picture to locate
  the problem, the numbers to measure it.

## Automatic mesh refinement

`run_fea` and verify mode 'fea' add one finer pass themselves. Pass
`refine: false` to solve exactly once at the study's (or mesh_size's) element
size. The stress-graded infill export does not refine unless asked
(`infill.refine: true`). The rule:

- Only when the stress is untrusted because CalculiX's error estimate in the
  high-stress region is above 25 %. Untrusted for element shape alone
  (inverted elements, >1 % slivers) does not refine (`'quality-limited'`):
  slivers come from the geometry, and smaller elements make more of them.
- The study is re-solved once at x0.75 the element size, if the predicted
  element count N x (1/0.75)^3 fits `KERNELCAD_FEA_MAX_ELEMENTS` (default
  400000, also the hard ceiling for any mesh). The finer result is the
  answer, trusted or not by the normal rule.
- If the pass does not fit (`'element-budget'`), fails (memory kill, timeout;
  the first result stands, `'pass-failed'`) or is still untrusted
  (`'still-untrusted'`), `fea.mesh.refine-stopped` lists the passes. The next
  step is a larger budget, a local toolchain with more memory, or reading the
  stress as mesh-limited with a bigger margin.

Peaks are not monotone in the mesh, and coarse meshes can read LOW. On the
cookbook bracket (PETG, 120 N):

| Run | Passes (mesh mm / elements / governing peak MPa / error %) | Result | Wall |
| --- | --- | --- | --- |
| `refine: false` | 2.5 / 11388 / 31.0 / 30.0 | SF 1.77, untrusted | 5 s |
| default | 2.5 / 11388 / 31.0 / 30.0; 1.875 / 28529 / 30.2 / 26.0 | SF 1.82, still untrusted (26.0 %, just over 25 %) | 33 s |

The aluminium bracket at the top of this page reads 42.5 MPa at 4 mm and
44.3 MPa at 3 mm (error 31 % on both): the coarse mesh reads low. When
refinement ends untrusted, size against the finer pass and keep a margin, or
run a finer mesh_size.

## A verdict in the load-capacity shape

When all you need is "does it hold?", ask `verify` instead of reading the
whole summary:

```
verify({ check: 'load-capacity', mode: 'fea', file: 'bracket.kcad.ts' })
```

It solves the study and answers in the same shape as the closed-form beam
check, so one reader handles both methods:

- `ok`: whether `safetyFactor` reaches `threshold`. The threshold is
  `safety_factor_threshold`, else the study's `minSafetyFactor`, else 1.5.
- `elements[]`: one entry per stress region, `{ partName: '@kc[...]',
  stressPa, yieldPa, safetyFactor, at }`, worst first.
- `failures[]`: the regions under the threshold.
- `diagnostics`: carries `fea.safety-factor.below-min` with the region to fix.
- `fea`: `peakStressPa`, `maxDisplacementMm`, `meshTrusted` and
  `trustReasons`, the `refinement` record, plus `peakAtSupportPa` when a
  clamped edge holds a singular peak.

Heatmaps are off by default here (`heatmaps: true` to render them). The
study name and mesh size override work exactly as for `run_fea`.

## The gate

A study with `minSafetyFactor` runs on every `evaluate_script` /
`kernelcad evaluate` and fails it with `fea.safety-factor.below-min` when the
margin is not met — the same seam the `dfmSpec` gates use. A study *without*
`minSafetyFactor` costs nothing at evaluate: it is a report you fetch with
`run_fea`.

`KERNELCAD_FEA_GATE=off` skips the solve, and says so with a warning that the
declared margin is UNVERIFIED — a skipped gate never reads as green.

The gate does NOT refine the mesh: it runs on every evaluate, and one
refinement pass costs several times the first solve (the bracket: 5 s
becomes about 33 s). Instead, a margin met on an untrusted mesh is reported as
`fea.safety-factor.unverified` (warning), not as a pass. Its next step: run
verify mode 'fea', which refines, then set the study's `meshSize` to the
size of the trusted pass in `fea.refinement`, so every evaluate gates on a
trusted mesh at single-solve cost. `KERNELCAD_FEA_GATE_REFINE=on` makes the
gate refine too (CI, or when the wall time is acceptable). A margin that is
NOT met stays an error either way.

## Stress-graded FDM infill

The same study drives per-region infill for a 3D print: export a 3MF with
`options.infill { fromFea: '<study>' | true }` and the export solves the study,
bins the part by von Mises / yield (default < 15 % -> 10 %, 15-40 % -> 25 %,
> 40 % -> 60 % gyroid) and writes one modifier volume per dense
band (Orca/Bambu format by default; `options.slicer: 'prusa'` writes PrusaSlicer's
format, which the other two do not read). The bands are voxel unions (2-5 mm cells) grown until watertight, so
they err toward more infill. Elements in the support zone around the fixed
faces (the screw bosses) always get the densest band, because the clamp load
the solve leaves out still acts there. The result reports the band table, a filament
and time saving against uniform infill at the high density, and heatmap /
band / cutaway PNGs. Recipe: `lookup_cookbook('stress-graded-infill-fdm')`.
The export solves once by default: the bands barely move with the mesh (the
bracket: band volumes within 3 points, saving 36.1 vs 36.4 %) and refining
made the export many times slower (the finer solve, banding and render
scripts all scale with the mesh). `infill.refine: true` refines
like `run_fea`; check `fea.trust` in the report either way.
Cura is not supported: it reads per-object settings from a 3MF (an infill mesh with its own
infill density), but that path could not be verified end to end here, so no Cura file is
written; in Cura, add the dense regions as infill meshes by hand.
The study models solid material: treat the safety factor as an upper bound
for a printed part.

## Diagnostics

| Code | What happened | What to do |
| --- | --- | --- |
| `fea.safety-factor.below-min` | Solved SF is under the declared floor. | Add material at `hotSpots[0].region`, pick a stronger grade, spread the load, or lower the floor if it was conservative. |
| `fea.mesh.quality-low` | Stress is mesh-limited, or the mesh has inverted elements / too many slivers; refinement was off or not applicable. | Re-run with a smaller `meshSize`; simplify slivers in the geometry. |
| `fea.mesh.refine-stopped` (warn) | The one finer pass did not fit the element budget, failed, or was still untrusted. | Read the pass table in the message. Raise `KERNELCAD_FEA_MAX_ELEMENTS` where you control it, run locally with more memory, or keep a larger margin. |
| `fea.safety-factor.unverified` (warn) | The gate met `minSafetyFactor` on an untrusted mesh; the margin is not confirmed. | Run verify mode 'fea' (it refines), then pin the study's `meshSize` to the trusted pass. |
| `fea.stress.support-singularity` (warn) | The raw peak sits at a fixed-face edge, where a rigid clamp makes stress singular; it is reported as `peakAtSupportMPa` and left out of the safety factor. | Read `maxVonMisesMPa` / `minSafetyFactor` as the result. To judge the bolt region itself, load the washer face and fix the far side. |
| `fea.mesh.too-large` | The mesh passed the element ceiling, gmsh or CalculiX timed out, or CalculiX was killed (memory limit, exit 255). | RAISE `meshSize` (about 2x) and re-run; the message carries the solver's last output lines. |
| `fea.solver.unavailable` | `ccx` or gmsh not found (or the gate was switched off). | Install the toolchain above, or set `KERNELCAD_CCX` / `KERNELCAD_FEA_PYTHON`. |
| `fea.study.fixed-unresolved` | `fixed` matched no face, or no meshed surface. | `inspect({ of: 'faces' })`, then pass a selector that matches. |
| `fea.study.load-unresolved` | A load's `faces` matched no face, or no meshed surface. | Same — the force would otherwise land on no node and report a false pass. |

## Scope

Linear static only, one solid per study: small displacements, linear elastic
material, bonded (no contact), no pretension, no modal / buckling / thermal
step. If the part visibly deflects (displacement approaching a wall thickness)
the linear answer understates the stress — reduce the load or treat the result
as a lower bound.

For a beam-shaped part with a declared rectangular cross-section,
`verify({ check: 'load-capacity' })` answers in milliseconds without a solver.
Use FEA when that path reports `kinematic.load.beam-not-applicable`, or when
you need to know *where* the part is overloaded.

## Reproducing a run by hand

`run_fea({ output_dir })` keeps the whole deck: `solver/part.brep` (the
geometry handed to the mesher), `solver/mesh.json`, `solver/job.inp` (the
CalculiX deck), `solver/job.frd` / `.dat` (raw results), and
`fea-summary.json`. `cd` into `solver/` and run `ccx job` to reproduce the
exact solve.

## Worked example

`examples/fea/shelf-bracket-fea.kcad.ts` — an L-bracket with the run commands
and measured expected output in its header, including the failing variant.