partforge 0.171.0 → 0.173.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1953,9 +1953,9 @@ Every print check is in exactly one tier, decided by what the reading means:
1953
1953
 
1954
1954
  | tier | checks | effect on `verify.ok` |
1955
1955
  | --- | --- | --- |
1956
- | **gate** — a defect in any orientation | bed fit (`bbox` within `bed`), bed contact (`bedContactPct` ≥ 50) | fails it |
1957
- | **cost** — a design choice with a price | overhang (`overhangArea`, id `supports`), footprint (`footprintPct` ≥ 10, id `footprint`) | never |
1958
- | **advisory** — a reading that may not matter | min wall (`minWall`), `wall` | never; a warning |
1956
+ | **gate** — a defect in any orientation | bed fit (`bbox` within `bed`), bed contact (`bedContactPct` ≥ 50), a declared flexing member's root fillet (`flexRoot` ≥ half its thickness) | fails it |
1957
+ | **cost** — a design choice with a price | overhang (`overhangArea`, id `supports`), footprint (`footprintPct` ≥ 10, id `footprint`), a flexing member printed standing (`flexTilt` ≤ 45°, id `standing`) | never |
1958
+ | **advisory** — a reading that may not matter | min wall (`minWall`), `wall`, a flexing member's root strain at its declared deflection (`flexStrain`) | never; a warning |
1959
1959
 
1960
1960
  A **gate** is fixed before the work is done: the piece cannot be printed this way
1961
1961
  whatever the user wants. A **cost** is a fact with a number and a location — mm² of
@@ -1976,7 +1976,7 @@ A threshold the user named ("under 500 mm² of supports") is an `expect`:
1976
1976
  `overhangArea: "<=500"` — still a cost, reported in `costs` and never failing `ok`;
1977
1977
  the user's own limit keeps its meaning, so it is never marked accepted, whatever
1978
1978
  `accept` says (`accept` covers only the process's default check). Lint refuses an `accept` entry the process does not define
1979
- (`verify-unknown-accept`): `fdm` defines `supports` and `footprint`; `resin` prints on
1979
+ (`verify-unknown-accept`): `fdm` defines `supports`, `footprint` and `standing`; `resin` prints on
1980
1980
  supports and defines none.
1981
1981
 
1982
1982
  **What arms what.** Under `fdm-pla`/`fdm-petg` the profile alone arms every check in
@@ -2015,6 +2015,68 @@ pin together) or a pinned clevis, not a socket snapped over a sphere: the snap's
2015
2015
  is a downward cavity that prints on supports nobody can reach. Build the snap only when
2016
2016
  the user wants it and has accepted the supports.
2017
2017
 
2018
+ ### Flexing members
2019
+
2020
+ Anything meant to bend in use — a snap arm, a buckle prong, a clip, a latch hook, a
2021
+ cantilever tab, a living spring — fails in four ways, and the first three read off the
2022
+ same declaration. It must meet the body it grows from through a **fillet at its root of
2023
+ at least half its thickness**: bending puts the highest stress at the root, a sharp
2024
+ inside corner multiplies it several times over, and an FDM print's layers are weakest
2025
+ across exactly that corner, so an unrounded arm snaps off flush with the body, often on
2026
+ the first few flexes. Decide the root fillet with the arm, not after it, and keep it
2027
+ clear of whatever the arm slides into (chamfer that mouth so the fillet seats). Its
2028
+ **root strain** at the travel it is designed for must stay under what the material takes
2029
+ — for a straight cantilever that is 1.5 × thickness × deflection / length², so a short,
2030
+ thick arm with a deep undercut yields or cracks whatever its root looks like: lengthen
2031
+ it, thin it, shorten the travel, or taper it (half the thickness at the tip bends about
2032
+ 60% further). It must **lie flat on the bed**: an arm standing up in the print pose has
2033
+ its layers stacked across its root, and a layer bond is a fraction of the in-plane
2034
+ strength. And it must not be thinner than the process prints (`minWall`, as for anything).
2035
+
2036
+ Declare every such member in `verify.flex`, by its feature label, and label the member
2037
+ **apart from the body** it grows from:
2038
+
2039
+ ```js
2040
+ const arm = k.prism({ points: armPts, h: d.armH }).label("Right prong");
2041
+ let s = k.union([base, arm, otherArm])
2042
+ .fillet({ r: 0.5 * p.armThick, edges: { inPlane: "XZ", at: 0 } }); // the arms' roots on the base face
2043
+ // …
2044
+ verify: {
2045
+ process: "fdm-pla",
2046
+ flex: [
2047
+ { feature: "Right prong", deflection: p.undercut }, // the travel it snaps through
2048
+ { feature: "Left prong", deflection: p.undercut },
2049
+ ["Latch", "Latch barb"], // one member made of two labels
2050
+ ],
2051
+ expect: { … },
2052
+ },
2053
+ ```
2054
+
2055
+ An entry is a label, a list of labels that make one member (an arm and the rib or barb
2056
+ on it, which otherwise counts as "the rest of the part"), or an object with `feature`,
2057
+ optional `labels`, the `deflection` in mm that arms the strain reading, and a
2058
+ `thickness` in mm when the mesh would misread the bending thickness (the member's
2059
+ smallest extent, which a wide hook or a tapered arm can fool). verify then reports, per
2060
+ sub-part carrying a member:
2061
+
2062
+ - `flexRoot` — the root's fillet radius in mm, 0 where any of it is sharp, against the
2063
+ process's `rootFillet` share of the member's thickness (0.5 everywhere). A gate,
2064
+ under any process or none: a fillet's faces are tangent where they meet the member
2065
+ and the body, a face that only continues flush into the body is not a corner, and a
2066
+ 45° chamfer is still a corner.
2067
+ - `flexStrain` — the root strain in percent at the declared deflection, against the
2068
+ process's `strain` (1.5 on PLA, 3 on PETG, 2 on resin). An advisory, since the
2069
+ cantilever formula is a first approximation; a member with no `deflection` is not read.
2070
+ - `flexTilt` — the degrees the member's long axis rises off the bed in the print pose,
2071
+ against the FDM profiles' `flexTilt` (45°). A cost, `standing`, that the user may
2072
+ accept (`accept: ["standing"]`); resin is isotropic and never asks.
2073
+
2074
+ Only what you declare is checked: the oracle cannot tell an arm that bends from a wall,
2075
+ a tooth or a pin of the same shape. A name no build labels is lint's
2076
+ `verify-flex-unknown-label` warning, a member no sub-part of the verified view carries is
2077
+ reported as skipped, and a member whose label meets nothing (the label put on the whole
2078
+ union) withholds the verdict.
2079
+
2018
2080
  ### Small features and scale
2019
2081
 
2020
2082
  A feature thinner than the profile's `minWall` (1.2 mm PLA, 1.5 mm PETG, 0.6 mm
@@ -2028,7 +2090,8 @@ in the control's label ("below 150 mm the pins get thin").
2028
2090
  Size a mating feature from the profile's fit class — `press`, `push`, `slip`,
2029
2091
  `moving`, `printInPlace` — copied into `derive()` with a comment naming it
2030
2092
  (`// slip fit: fdm-pla 0.25`), and a horizontal bore enlarged by `holeUndersize`. A
2031
- snap is a contact; its fit is `push` or `slip`. The numbers, and how a diametral fit
2093
+ snap is a contact; its fit is `push` or `slip`, and its arm gets a root fillet
2094
+ ("Flexing members" above). The numbers, and how a diametral fit
2032
2095
  becomes a `clearance` expectation, are in the `verify` section below.
2033
2096
 
2034
2097
  ### First layer
@@ -4132,8 +4195,9 @@ runtime a throwing probe only degrades quietly to `{ error }`.
4132
4195
 
4133
4196
  **Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
4134
4197
  `verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
4135
- `verify-unknown-orientation`, `verify-unknown-accept`,
4136
- `verify-expect-throws` (all errors). Note `_view` also accepts the pair-wise
4198
+ `verify-unknown-orientation`, `verify-unknown-accept`, `verify-bad-flex`,
4199
+ `verify-expect-throws` (all errors), and `verify-flex-unknown-label` (a warning: a
4200
+ `verify.flex` label no build writes at the default parameters). Note `_view` also accepts the pair-wise
4137
4201
  `contacts` / `clearance` / `held` keys, which are not scalar view metrics; they are
4138
4202
  validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
4139
4203
 
@@ -4435,8 +4499,9 @@ A part can declare how it should be checked, co-located with its schema, so
4435
4499
  ```js
4436
4500
  verify: {
4437
4501
  process: "fdm-pla", // a DFM profile: fdm-pla | fdm-petg | resin, or an inline
4438
- // { base, bed:[x,y,z], minWall, overhang, bedContact, footprint, fits, holeUndersize, bridge } object
4439
- accept: ["supports"], // optional; the costs the user chose to pay (fdm: supports, footprint)
4502
+ // { base, bed:[x,y,z], minWall, overhang, bedContact, footprint, fits, holeUndersize, bridge,
4503
+ // rootFillet, strain, flexTilt } object
4504
+ accept: ["supports"], // optional; the costs the user chose to pay (fdm: supports, footprint, standing)
4440
4505
  orientation: "print", // optional; declares build() is the print pose (Z up, bed at
4441
4506
  // the lowest Z) — with no profile it arms bed contact
4442
4507
  cases: ["defaults", "M3"], // optional; default = defaults + every preset
@@ -4534,6 +4599,33 @@ records it. Your own `footprintPct` expectation arms it under any profile; `orie
4534
4599
  does not, and `{ base: "fdm-pla", footprint: null }` switches it off. `exportable: false`
4535
4600
  sub-parts are not judged.
4536
4601
 
4602
+ **Flexing members — `flexRoot` a GATE armed by `verify.flex` alone, `flexStrain` an
4603
+ advisory from a declared deflection, `flexTilt` the `standing` cost under FDM.** `flex`
4604
+ lists the part's flexing members by feature label (`flex: ["Right prong", "Left prong"]`;
4605
+ a list entry, `["Arm", "Arm rib"]`, is one member made of several labels; an object,
4606
+ `{ feature: "Arm", deflection: 1.2, thickness: 2 }`, adds the travel in mm the member
4607
+ is designed to flex through and, when the mesh would misread it, its bending
4608
+ thickness). Every printed sub-part whose mesh carries a member's label gets one row per
4609
+ reading (your own expectation on that sub-part replaces the default): `flexRoot` is the
4610
+ root fillet radius in mm of the member whose root is worst (least radius over what it
4611
+ needs), 0 where any of it is sharp — a concave edge turning more than 30° between one of
4612
+ its triangles and one that is not — against `>=` the profile's `rootFillet` share of that
4613
+ member's thickness (`feature` names the member, `location` is the longest sharp stretch,
4614
+ `note` lists every member the sub-part carries with its radius and need); `flexStrain`
4615
+ is the worst member's root strain in percent at its declared deflection, 1.5 × thickness ×
4616
+ deflection / length² for a straight cantilever, against `<=` the profile's `strain`;
4617
+ `flexTilt` is the degrees the worst member's long axis rises off the bed in the print
4618
+ pose, against `<=` the profile's `flexTilt`, a cost (`standing`). The root and strain
4619
+ readings are independent of the process and of the print pose; the tilt is read in the
4620
+ print pose, like bed contact, and withheld when that pose cannot be read. Three
4621
+ declaration problems are reported per member rather than per sub-part: a member no
4622
+ sub-part of the verified view carries is skipped (it may be switched off in that case);
4623
+ a member carried but meeting nothing — its label put on the whole union, or a piece
4624
+ that never touches the body — withholds the verdict (`unevaluated`), since a clean root
4625
+ and no root must never read alike; and a seed that did not read a member answers
4626
+ unevaluated on a quick lap. See "Flexing members" under "Printed parts" for why, and
4627
+ how to round a root.
4628
+
4537
4629
  **What the profile tells you.** Three more fields carry the numbers a mating
4538
4630
  feature needs. Nothing checks them; they are the one place the value is spelled,
4539
4631
  so `derive()` copies it with a comment naming the fit (`// slip fit: fdm-pla 0.25`).
@@ -4552,13 +4644,16 @@ an inline `{ base: "fdm-pla", fits: { … } }` replaces the whole table.
4552
4644
  | `fits.printInPlace` — printed already assembled | 0.45 | 0.50 | 0.20 |
4553
4645
  | `holeUndersize` | 0.25 | 0.30 | 0.05 |
4554
4646
  | `bridge` (mm) | 10 | 10 | — |
4647
+ | `rootFillet` — a flexing member's root fillet, as a share of its thickness (a gate) | 0.5 | 0.5 | 0.5 |
4648
+ | `strain` — the root strain (%) a one-time snap may reach (an advisory) | 1.5 | 3 | 2 |
4649
+ | `flexTilt` — the steepest a flexing member may stand off the bed, degrees (a cost, `standing`) | 45 | 45 | — |
4555
4650
 
4556
4651
  `fits` are diametral; the `clearance` expectation below reads a pair's minimum surface distance, so a pin in a bore is declared at half the fit (`fits.slip` 0.25 → `clearance: { "pin×body": "0.08..0.18" }`).
4557
4652
 
4558
4653
  **What `expect` gives you:** per-sub-part
4559
4654
  assertions on the facts `measure` already reports — `holes` (through-bores / genus),
4560
4655
  `volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
4561
- `overhangArea`, `bedContactPct`, `footprintPct`, `wall` (a range — see below), `bounds` (the axis-aligned corner
4656
+ `overhangArea`, `bedContactPct`, `footprintPct`, `flexRoot`, `flexStrain`, `flexTilt`, `wall` (a range — see below), `bounds` (the axis-aligned corner
4562
4657
  positions, written in the same `{ min, max }` shape `measure` reports them — where
4563
4658
  the geometry sits, vs `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
4564
4659
  centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
@@ -234,6 +234,24 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
234
234
  - **Cause:** The lowest point of the piece is not a flat face, so almost none of its silhouette on the bed actually touches it (a cylinder on its side reads a few percent, under the floor): the first layer has almost nothing to bond to, and the piece tips or pulls loose unless it prints with a brim, a raft, or in a different orientation.
235
235
  - **Fix:** Lay the piece on its flat face in its print pose, add a flat foot or pad at its lowest point, or print it with a brim. When the user chose the brim, `verify.accept: ["footprint"]` records it and the cost reads accepted. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Printed parts".
236
236
 
237
+ ## flex-root-sharp
238
+
239
+ - **Symptom:** A `✗ … flexRoot` failure from `verify` naming the member in `feature`, whose note reads e.g. `"Right prong": sharp root (90°, 12.2 mm of corner)` or `"Right prong": root radius 0.4 mm, needs ≥ 1 (2 mm thick)`; or, in use, a snap arm, buckle prong, clip or cantilever tab that breaks off flush with the body it grows from.
240
+ - **Cause:** The member declared in `verify.flex` meets the rest of the part at a sharp inside corner, or through a fillet smaller than the process's `rootFillet` share of its thickness (half, on every built-in profile). Bending puts the highest stress at the root, a sharp corner multiplies it several times over, and in FDM the layer bonds are weakest right there — so the member snaps at its root, often on the first few flexes.
241
+ - **Fix:** Round the root with a fillet of at least half the member's thickness: `s.fillet({ r, edges: { near: [x, y, z] } })` on the joined solid, one root edge at a time, or `edges: { inPlane, at }` when every root edge lies on the face the member grows from (fillet before anything else that touches that face joins, so only the roots are selected); or draw the fillet into the 2-D profile the member and body share. Keep the fillet clear of whatever the member slides into — chamfer that mouth to seat it. If a grip rib or barb on the member is labelled apart, list it with the member: `flex: [["Arm", "Arm rib"]]`. If the mesh misreads the member's bending thickness (a wide hook reads its width), declare it: `flex: [{ feature: "Arm", thickness: 2 }]`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Printed parts".
242
+
243
+ ## flex-strain-over
244
+
245
+ - **Symptom:** A `⚠ … flexStrain` warning from `verify`, e.g. `"Arm": 2.67% at 2 mm (15 mm long, 2 mm thick)` against `<=1.5`; or, in use, a snap arm that whitens, takes a set or cracks at its root the first time it is pressed home.
246
+ - **Cause:** At the deflection declared for it (`flex: [{ feature, deflection }]`) the member's root strain — 1.5 × thickness × deflection / length² for a straight cantilever — is over what the process's material takes for a one-time snap (`strain`: 1.5% PLA, 3% PETG, 2% resin). A short, thick arm with a deep undercut is the usual shape.
247
+ - **Fix:** Lengthen the arm, thin it, or reduce its travel (a shallower undercut; a lead-in chamfer so the snap spreads over more of the stroke). Tapering the arm to half its thickness at the tip lets it bend about 60% further at the same root strain. Switch to PETG where the design is fixed. The formula is a first approximation — a curved or hooked member bends differently — so declare the real bending `thickness` when the mesh misreads it. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Printed parts".
248
+
249
+ ## flex-member-standing
250
+
251
+ - **Symptom:** A `flexTilt` cost from `verify` under an FDM profile, e.g. `"Arm": 90° off the bed` against `<=45`; or, in use, a snap arm that breaks cleanly along a layer line at its root.
252
+ - **Cause:** In the print pose the member's long axis stands up off the bed, so its layers are stacked across its root; a layer bond is a fraction of the in-plane strength, and bending loads exactly that bond.
253
+ - **Fix:** Pose the piece in `place(..., { purpose: "export" })` so the arm lies along the bed (its root corner then sits within one layer), or split the part so the arm prints flat and joins afterwards. A standing arm the user keeps is a cost they accept (`accept: ["standing"]`): use PETG or anneal, and round the root generously. Resin is isotropic and never reports this. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Printed parts".
254
+
237
255
  ## expect-static-across-presets
238
256
 
239
257
  - **Symptom:** A `verify` exact gate (`holes`, `volume`, …) fails on SOME presets only — e.g. `✗ planter holes 1 (0 != 1)` on two cases while defaults pass — and the preview looks right for every preset.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.171.0",
3
+ "version": "0.173.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,10 +2,11 @@
2
2
  // throws from verify() mid-run, AFTER measure has printed and the kernel has booted,
3
3
  // which is also the documented reason CLI stdout isn't pure JSON in that case.
4
4
  // Catching them statically removes both the wasted boot and the stdout caveat.
5
- import { err } from "./finding.js";
5
+ import { err, warn } from "./finding.js";
6
6
  import { SUBPART_METRICS, VIEW_METRICS, BOUNDS_FORM, teachableMetrics, sidesSpecError } from "../verify-metrics.js";
7
7
  import { PROFILES, ORIENTATIONS } from "../oracle/dfm-profiles.js";
8
8
  import { parseAssertion, bareVectorHint } from "../oracle/assert-dsl.js";
9
+ import { flexMembers } from "../oracle/flex-members.js";
9
10
  import { suggest } from "../geometry/op-options.js";
10
11
  import { PRINT_PROCESSES, PROCESS_IDS, printProcessFor } from "../process/registry.js";
11
12
  import { SHEET_DOC_ID } from "../sheet/constants.js";
@@ -447,4 +448,70 @@ export const VERIFY_RULES = [
447
448
  return out;
448
449
  },
449
450
  },
451
+ {
452
+ // `verify.flex` names the part's flexing members by feature label (oracle/flex-root.js):
453
+ // a list whose entries are a label, a list of labels read as one member, or a member
454
+ // object `{ feature, labels?, deflection?, thickness? }`. verify() drops a malformed
455
+ // entry silently, so the shape is checked here.
456
+ id: "verify-bad-flex",
457
+ run: ({ part }) => {
458
+ const flex = part?.verify?.flex;
459
+ if (flex == null) return [];
460
+ const form = "Write `flex` as a list of flexing members, each a feature label (`\"Left arm\"`), a list of labels that make one member (`[\"Arm\", \"Arm rib\"]`), or an object `{ feature: \"Arm\", labels?: [...], deflection?: <mm of travel>, thickness?: <mm> }`.";
461
+ if (!Array.isArray(flex)) {
462
+ return [err("verify-bad-flex", `\`verify.flex\` is ${describe(flex)}, not a list of flexing members`, form, "verify.flex")];
463
+ }
464
+ const label = (l) => typeof l === "string" && l.length > 0;
465
+ const labels = (ls) => Array.isArray(ls) && ls.length > 0 && ls.every(label);
466
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
467
+ const out = [];
468
+ flex.forEach((e, i) => {
469
+ if (label(e) || labels(e)) return;
470
+ const at = `verify.flex[${i}]`;
471
+ if (!e || typeof e !== "object" || Array.isArray(e)) {
472
+ out.push(err("verify-bad-flex", `\`${at}\` is ${describe(e)}, not a feature label, a list of them or a member object`, form, at));
473
+ return;
474
+ }
475
+ if (!label(e.feature)) out.push(err("verify-bad-flex", `\`${at}.feature\` is ${describe(e.feature)}, not a feature label`, form, `${at}.feature`));
476
+ if (e.labels !== undefined && !labels(e.labels)) out.push(err("verify-bad-flex", `\`${at}.labels\` is ${describe(e.labels)}, not a list of feature labels`, form, `${at}.labels`));
477
+ for (const k of ["deflection", "thickness"]) {
478
+ if (e[k] !== undefined && !num(e[k])) out.push(err("verify-bad-flex", `\`${at}.${k}\` is ${describe(e[k])}, not a positive number of mm`, form, `${at}.${k}`));
479
+ }
480
+ for (const k of Object.keys(e)) {
481
+ if (!["feature", "labels", "deflection", "thickness"].includes(k)) out.push(err("verify-bad-flex", `\`${at}.${k}\` is not a flexing-member field`, form, `${at}.${k}`));
482
+ }
483
+ });
484
+ return out;
485
+ },
486
+ },
487
+ {
488
+ // A flex label no build writes is a check that never runs: verify skips a member no
489
+ // sub-part carries (it may be switched off in that case), so a typo would pass
490
+ // silently. The geometry-free probe records every `.label()` call at the default
491
+ // params; a label set only under other params reads as missing, hence a warning.
492
+ id: "verify-flex-unknown-label",
493
+ run: ({ part, probe }) => {
494
+ const flex = part?.verify?.flex;
495
+ if (!Array.isArray(flex) || !flex.length) return [];
496
+ const run = probe();
497
+ if (run.runaway || run.throws.length) return []; // an unfinished build saw only some labels
498
+ const seen = new Set(run.calls.filter((c) => c.op === "label")
499
+ .map((c) => { try { return JSON.parse(c.args[0]); } catch { return null; } })
500
+ .filter((l) => typeof l === "string"));
501
+ const known = [...seen];
502
+ const out = [];
503
+ flex.forEach((e, i) => {
504
+ const [m] = flexMembers([e]); // a bad shape is verify-bad-flex's
505
+ for (const l of m?.labels ?? []) {
506
+ if (seen.has(l)) continue;
507
+ const hint = safeSuggest(l, known);
508
+ out.push(warn("verify-flex-unknown-label",
509
+ `\`verify.flex\` names "${l}", but no build labels a feature "${l}" at the default parameters`,
510
+ `Label the flexing member itself — \`.label("${l}")\` on the arm, apart from the body it grows from — or correct the name${hint ? ` (did you mean "${hint}"?)` : known.length ? ` (labels the build writes: ${known.slice(0, 12).map((k) => `"${k}"`).join(", ")})` : ""}. Until it matches, the root check never runs.`,
511
+ `verify.flex[${i}]`));
512
+ }
513
+ });
514
+ return out;
515
+ },
516
+ },
450
517
  ];
@@ -109,3 +109,58 @@ export function footprintFor(part, process, { expanded = [] } = {}) {
109
109
  Object.entries(expect ?? {}).some(([name, o]) => name !== "_view" && o && typeof o === "object" && "footprintPct" in o));
110
110
  return asserted ? DEFAULT_FOOTPRINT : null;
111
111
  }
112
+
113
+ // ── flexing members (`verify.flex`, oracle/flex-root.js) ─────────────────────
114
+ // The root fillet a flexing member needs, as a share of its thickness; the strain its
115
+ // material takes at the root for a one-time snap, in percent; the steepest its long
116
+ // axis may rise off the bed, in degrees. Guide defaults for a part that names no
117
+ // profile — the root rule and the strain rule hold in any material.
118
+ export const DEFAULT_ROOT_FILLET = 0.5;
119
+ export const DEFAULT_STRAIN = 1.5;
120
+ export const DEFAULT_FLEX_TILT = 45;
121
+
122
+ const orientationOk = (part) => {
123
+ const orientation = part?.verify?.orientation;
124
+ if (orientation != null && !ORIENTATIONS.includes(orientation)) {
125
+ throw new Error(`unknown verify.orientation: ${JSON.stringify(orientation)} (known: ${ORIENTATIONS.join(", ")})`);
126
+ }
127
+ };
128
+ const asserts = (expanded, metric) => expanded.some(({ expect }) =>
129
+ Object.entries(expect ?? {}).some(([name, o]) => name !== "_view" && o && typeof o === "object" && metric in o));
130
+
131
+ // The least root fillet, as a share of the member's thickness, a declared flexing
132
+ // member is held to (a GATE), or null when switched off. Armed by `verify.flex` alone —
133
+ // no profile needed, the rule holds in any material — so with no profile it is the
134
+ // default; the profile's `rootFillet` sets it, `{ base, rootFillet: null }` switches it off.
135
+ export function rootFilletFor(part, process) {
136
+ orientationOk(part);
137
+ const spec = process ?? part?.verify?.process;
138
+ const v = spec ? resolveProfile(spec).rootFillet : undefined;
139
+ if (v === null) return null;
140
+ return typeof v === "number" && Number.isFinite(v) ? v : DEFAULT_ROOT_FILLET;
141
+ }
142
+
143
+ // The largest root strain, in percent, a flexing member may see at its declared
144
+ // deflection (an advisory), or null when switched off. Read for a member that declares
145
+ // a `deflection`, or where the author asserts `flexStrain`; the profile's `strain` sets
146
+ // the limit, the default stands in with no profile, `{ base, strain: null }` switches it off.
147
+ export function strainFor(part, process) {
148
+ orientationOk(part);
149
+ const spec = process ?? part?.verify?.process;
150
+ const v = spec ? resolveProfile(spec).strain : undefined;
151
+ if (v === null) return null;
152
+ return typeof v === "number" && Number.isFinite(v) ? v : DEFAULT_STRAIN;
153
+ }
154
+
155
+ // The steepest a flexing member's long axis may rise off the bed, in degrees, before
156
+ // its layers stack across the root (a COST, `standing`), or null when not checked. Like
157
+ // overhangAngleFor: the PROFILE alone arms it (the FDM profiles carry `flexTilt`; resin
158
+ // is isotropic and carries none), or an authored `flexTilt` expectation, read against
159
+ // the default when the profile has none.
160
+ export function flexTiltFor(part, process, { expanded = [] } = {}) {
161
+ orientationOk(part);
162
+ const spec = process ?? part?.verify?.process;
163
+ const v = spec ? resolveProfile(spec).flexTilt : undefined;
164
+ if (typeof v === "number" && Number.isFinite(v)) return v;
165
+ return asserts(expanded, "flexTilt") ? DEFAULT_FLEX_TILT : null;
166
+ }
@@ -0,0 +1,35 @@
1
+ // src/framework/oracle/flex-members.js
2
+ // The shape of `verify.flex`: the part's flexing members, as declared, normalized to one
3
+ // record each. Import-free and pure — the linter reads it at boot (rules-verify.js's
4
+ // verify-bad-flex and verify-flex-unknown-label), so it must stay a leaf the worker can
5
+ // load eagerly, apart from flex-root.js's reading, which loads with the rest of the
6
+ // oracle on the first inspect job (test/worker-layering.test.js).
7
+
8
+ /**
9
+ * The flexing members `verify.flex` declares, one record each: `{ feature, labels,
10
+ * deflection, thickness }`. An entry is a label, a list of labels read as one member
11
+ * (named by its first), or an object `{ feature, labels?, deflection?, thickness? }` —
12
+ * `deflection` the travel in mm the member is designed to flex through (arms the strain
13
+ * reading), `thickness` its bending thickness in mm when the author knows it better than
14
+ * the mesh does. A malformed entry is dropped (lint's verify-bad-flex reports it).
15
+ */
16
+ export function flexMembers(flex) {
17
+ if (!Array.isArray(flex)) return [];
18
+ const label = (l) => typeof l === "string" && l.length > 0;
19
+ const labels = (ls) => Array.isArray(ls) && ls.length > 0 && ls.every(label);
20
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null);
21
+ const out = [];
22
+ for (const e of flex) {
23
+ if (label(e)) out.push({ feature: e, labels: [e], deflection: null, thickness: null });
24
+ else if (labels(e)) out.push({ feature: e[0], labels: [...e], deflection: null, thickness: null });
25
+ else if (e && typeof e === "object" && !Array.isArray(e) && label(e.feature)) {
26
+ out.push({ feature: e.feature, labels: labels(e.labels) ? [...e.labels] : [e.feature],
27
+ deflection: num(e.deflection), thickness: num(e.thickness) });
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /** The label groups of `flex`, as measure() stamps them (`measuredFlex`). */
34
+ export const flexGroups = (flex) => flexMembers(flex).map((m) => m.labels);
35
+
@@ -0,0 +1,308 @@
1
+ // src/framework/oracle/flex-root.js
2
+ // A flexing member, read off the mesh: how it meets the rest of its part, how big it is,
3
+ // and how it lies on the print bed.
4
+ //
5
+ // The failure this exists for: a snap arm, a buckle prong, a clip or a cantilever tab
6
+ // joined to its body at a sharp inside corner. Bending concentrates the stress at the
7
+ // root, and a sharp inside corner multiplies it several times over, so the member snaps
8
+ // there — often on the first flex, in FDM plastic whose layer bonds are weakest across
9
+ // exactly that corner. Seen on a real forge: a side-release buckle whose prongs, each a
10
+ // 2 mm prism unioned onto the base, broke off flush with the base face. A fillet at the
11
+ // root of about half the member's thickness spreads the stress and is what fixes it —
12
+ // and the three other ways such a member fails read off the same labelled triangles:
13
+ // too much strain for its length (verify's flexStrain, from a declared deflection),
14
+ // printed standing so the layers stack across its root (flexTilt), and thinner than
15
+ // the process prints (minWall, already).
16
+ //
17
+ // A mesh cannot tell a member that bends from a wall, a rib, a tooth or a pin — every
18
+ // one of them is a thin feature joined at a sharp corner, and a shape-only detector
19
+ // flagged most parts it was tried on. So the part SAYS which members flex
20
+ // (`verify.flex`, by feature label), and this reads only those: every edge where a
21
+ // triangle of the member meets a triangle of anything else. A root is sharp where that
22
+ // edge is concave and turns by more than SHARP_DEG. A fillet's own faces are new,
23
+ // unlabelled surfaces (or the sub-part's), and a tessellated fillet turns by a few
24
+ // degrees per facet, tangent where it meets the member and the body — so a filleted
25
+ // root reads clean, and so does a member whose faces only continue coplanar into the
26
+ // body (its top face flush with the base's top).
27
+ //
28
+ // THE ROOT RADIUS. A fillet tangent to the member's face is a circle through the
29
+ // member's boundary (where its label ends), tangent to the face there. A ray from that
30
+ // boundary, parallel to the face and lifted a small ε off it into the air, meets the
31
+ // fillet at the point where the arc has risen ε off the face: the chord from the tangent
32
+ // point to that hit is 2·r·sin(φ/2), and 2·r·(1 − cos φ) = ε, so r = chord² / (2·ε),
33
+ // exact on a circle and read off the member and the air alone — whatever the fillet's
34
+ // tessellation (mesh-fillet starts its arc past an extension sliver on the face plane, so
35
+ // nothing about the first chord row can be trusted), and whatever angle the body meets
36
+ // the member at. A root that continues flush meets nothing: it reads as the ray's reach.
37
+ // A sharp edge is r = 0; the member's radius is a low quantile, by length, of its
38
+ // boundary's readings (a corner patch reads noisily), and 0 wherever ROOT_FLOOR of
39
+ // sharp root exists at all. The rays are few — one per boundary edge, a handful per
40
+ // member — and are tested against the triangles their segments' box reaches, no index.
41
+ //
42
+ // One pass over the triangles to weld edges, pure, no BVH. Works on both mesh forms the
43
+ // oracle sees (Manifold's soup, OCCT's indexed vertices) — but only on a mesh carrying
44
+ // `featureIds`/`features` (the feature labels), which both backends attach.
45
+ //
46
+ // The declaration itself (`verify.flex` → member records) is flex-members.js, a leaf
47
+ // the linter shares; this module is the reading, loaded with the rest of the oracle.
48
+ //
49
+ // Known limits, stated: an inside corner WITHIN one label (the member and its body
50
+ // labelled alike, or a barb's own notch) is invisible — label the flexing member apart
51
+ // from the body it grows from. A label that covers the whole sub-part, or a member that
52
+ // never touches the rest of it, therefore has NO root to read: that is `attached` false,
53
+ // which verify withholds a verdict on rather than passing (a clean root and no root
54
+ // must never read alike). And a feature touching the member (a grip rib on the arm)
55
+ // counts as "the rest of the part", so its corners read as roots unless it is listed
56
+ // with the member: `flex: [["Arm", "Arm rib"]]` treats the group as one member.
57
+
58
+ // A concave edge turning more than this (degrees) is a sharp corner. A tessellated
59
+ // fillet turns a few degrees per facet (3.1° per step at the preview's 116 segments;
60
+ // 22.5° at mesh-fillet's 16-segment floor for radii under 1 mm, and the boundary edge
61
+ // turns half of one), a 45° chamfer turns 45° twice — a chamfer is a corner too, if a
62
+ // softer one.
63
+ export const SHARP_DEG = 30;
64
+ const SHARP_COS = Math.cos((SHARP_DEG * Math.PI) / 180);
65
+ // A concave edge turning less than this (degrees) is flush: a face continuing into the
66
+ // body, not a fillet's first chord. Below it the chord formula divides by ~0.
67
+ export const FLUSH_DEG = 0.25;
68
+ const FLUSH_COS = Math.cos((FLUSH_DEG * Math.PI) / 180);
69
+ // Sharp root edges shorter than this in total (mm) are boolean noise, never reported.
70
+ export const ROOT_FLOOR = 0.1;
71
+ // The radius reported is the reading this share of the rounded boundary's length sits
72
+ // under — a low quantile, so a noisy corner patch neither sets nor hides it.
73
+ const RADIUS_QUANTILE = 0.1;
74
+ // A root ray is followed this many member thicknesses (at least RAY_MIN mm): a fillet
75
+ // larger than that passes any ratio a profile asks for, and reads as that length.
76
+ const RAY_REACH = 10, RAY_MIN = 2;
77
+ // The ray's lift off the member's face, as a share of its thickness (at least LIFT_MIN
78
+ // mm): small against any fillet worth having, large against a chord's sagitta.
79
+ const LIFT = 0.05, LIFT_MIN = 0.02;
80
+ // Hits closer than this (mm) are the ray's own edge.
81
+ const HIT_EPS = 1e-4;
82
+
83
+ // Möller–Trumbore: the distance along the ray (o, d) to triangle (a, b, c), or null.
84
+ function rayTri(P, o, d, a, b, c) {
85
+ const e1x = P[b * 3] - P[a * 3], e1y = P[b * 3 + 1] - P[a * 3 + 1], e1z = P[b * 3 + 2] - P[a * 3 + 2];
86
+ const e2x = P[c * 3] - P[a * 3], e2y = P[c * 3 + 1] - P[a * 3 + 1], e2z = P[c * 3 + 2] - P[a * 3 + 2];
87
+ const px = d[1] * e2z - d[2] * e2y, py = d[2] * e2x - d[0] * e2z, pz = d[0] * e2y - d[1] * e2x;
88
+ const det = e1x * px + e1y * py + e1z * pz;
89
+ if (Math.abs(det) < 1e-12) return null;
90
+ const inv = 1 / det;
91
+ const tx = o[0] - P[a * 3], ty = o[1] - P[a * 3 + 1], tz = o[2] - P[a * 3 + 2];
92
+ const u = (tx * px + ty * py + tz * pz) * inv;
93
+ if (u < -1e-9 || u > 1 + 1e-9) return null;
94
+ const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x;
95
+ const v = (d[0] * qx + d[1] * qy + d[2] * qz) * inv;
96
+ if (v < -1e-9 || u + v > 1 + 1e-9) return null;
97
+ const t = (e2x * qx + e2y * qy + e2z * qz) * inv;
98
+ return t > HIT_EPS ? t : null;
99
+ }
100
+
101
+ // The eigenvectors of a symmetric 3×3 (cyclic Jacobi), columns of `v`, with the
102
+ // eigenvalues in `w` — a handful of sweeps on a 3×3 converges to machine precision.
103
+ function eigen3(m) {
104
+ const a = m.map((r) => r.slice());
105
+ const v = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
106
+ for (let sweep = 0; sweep < 32; sweep++) {
107
+ let off = 0;
108
+ for (let p = 0; p < 3; p++) for (let q = p + 1; q < 3; q++) off += a[p][q] * a[p][q];
109
+ if (off < 1e-24) break;
110
+ for (let p = 0; p < 3; p++) for (let q = p + 1; q < 3; q++) {
111
+ if (Math.abs(a[p][q]) < 1e-300) continue;
112
+ const theta = (a[q][q] - a[p][p]) / (2 * a[p][q]);
113
+ const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
114
+ const c = 1 / Math.sqrt(t * t + 1), s = t * c;
115
+ for (let k = 0; k < 3; k++) {
116
+ const akp = a[k][p], akq = a[k][q];
117
+ a[k][p] = c * akp - s * akq; a[k][q] = s * akp + c * akq;
118
+ }
119
+ for (let k = 0; k < 3; k++) {
120
+ const apk = a[p][k], aqk = a[q][k];
121
+ a[p][k] = c * apk - s * aqk; a[q][k] = s * apk + c * aqk;
122
+ }
123
+ for (let k = 0; k < 3; k++) {
124
+ const vkp = v[k][p], vkq = v[k][q];
125
+ v[k][p] = c * vkp - s * vkq; v[k][q] = s * vkp + c * vkq;
126
+ }
127
+ }
128
+ }
129
+ return { w: [a[0][0], a[1][1], a[2][2]], v };
130
+ }
131
+
132
+ // The member's size along its own axes: the principal axes of its vertices, and the
133
+ // extent of the vertices along each — `length` the largest, `thickness` the smallest
134
+ // (the direction a cantilever bends in), `axis` the unit direction of the largest.
135
+ function extents(P, verts) {
136
+ const n = verts.length;
137
+ if (n < 3) return { length: 0, thickness: 0, axis: null };
138
+ const c = [0, 0, 0];
139
+ for (const i of verts) for (let k = 0; k < 3; k++) c[k] += P[i * 3 + k] / n;
140
+ const cov = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
141
+ for (const i of verts) {
142
+ const d = [P[i * 3] - c[0], P[i * 3 + 1] - c[1], P[i * 3 + 2] - c[2]];
143
+ for (let r = 0; r < 3; r++) for (let s = 0; s < 3; s++) cov[r][s] += d[r] * d[s];
144
+ }
145
+ const { w, v } = eigen3(cov);
146
+ const order = [0, 1, 2].sort((i, j) => w[j] - w[i]);
147
+ const ext = order.map((col) => {
148
+ const ax = [v[0][col], v[1][col], v[2][col]];
149
+ let lo = Infinity, hi = -Infinity;
150
+ for (const i of verts) {
151
+ const s = ax[0] * P[i * 3] + ax[1] * P[i * 3 + 1] + ax[2] * P[i * 3 + 2];
152
+ if (s < lo) lo = s;
153
+ if (s > hi) hi = s;
154
+ }
155
+ return { ax, size: hi - lo };
156
+ });
157
+ return { length: ext[0].size, thickness: ext[2].size, axis: ext[0].ax };
158
+ }
159
+
160
+ /**
161
+ * @param {{ positions: ArrayLike<number>, indices?: ArrayLike<number>, featureIds?: ArrayLike<number>, features?: string[] }} mesh
162
+ * @param {string[]} group the labels making up one flexing member
163
+ * @param {{ printMatrix?: number[]|null }} [opts] the display→print mat4 (column-major,
164
+ * pose.js) the member is posed by for `tilt`; null when the display pose is the print pose
165
+ * @returns {{ found: boolean, attached: boolean, sharpLength: number, maxTurn: number|null, at: number[]|null,
166
+ * rootRadius: number|null, thickness: number, length: number, axis: number[]|null, tilt: number|null }}
167
+ * `found` false when no triangle carries any of the labels (the check cannot run);
168
+ * `attached` false when the member shares no edge with the rest of the mesh (nothing
169
+ * to read either — the label covers everything, or the member touches nothing);
170
+ * `sharpLength` the total length (mm) of sharp concave root edges, 0 under ROOT_FLOOR;
171
+ * `maxTurn` the sharpest such edge's turn in degrees and `at` the midpoint of the
172
+ * longest one (both null when the root is clean);
173
+ * `rootRadius` the root's fillet radius in mm — 0 when any of it is sharp, null when
174
+ * the member meets the body flush everywhere (no inside corner at all);
175
+ * `thickness`, `length`, `axis` the member's own extents and long direction;
176
+ * `tilt` the degrees that direction rises off the print bed (0 lying flat, 90
177
+ * standing), in the print pose.
178
+ */
179
+ export function flexRoot(mesh, group, { printMatrix = null } = {}) {
180
+ const { positions: P, indices: I, featureIds, features } = mesh;
181
+ const none = { found: false, attached: false, sharpLength: 0, maxTurn: null, at: null, rootRadius: null, thickness: 0, length: 0, axis: null, tilt: null };
182
+ const ids = new Set();
183
+ if (featureIds && features) features.forEach((f, i) => { if (group.includes(f)) ids.add(i + 1); });
184
+ if (!ids.size) return none;
185
+ const nTri = I ? I.length / 3 : P.length / 9;
186
+ const vi = (t, j) => (I ? I[t * 3 + j] : t * 3 + j);
187
+ const key = (i) => `${Math.round(P[i * 3] * 1e4)},${Math.round(P[i * 3 + 1] * 1e4)},${Math.round(P[i * 3 + 2] * 1e4)}`;
188
+
189
+ // Normals, areas and centroids, and every edge of a member triangle, keyed by welded endpoints.
190
+ const inMember = (t) => ids.has(featureIds[t]);
191
+ const normal = new Float64Array(nTri * 3), cent = new Float64Array(nTri * 3), area2 = new Float64Array(nTri);
192
+ const edges = new Map(); // key -> { a, b, tris: [] }
193
+ const tri = (t) => [vi(t, 0), vi(t, 1), vi(t, 2)];
194
+ for (let t = 0; t < nTri; t++) {
195
+ const [a, b, c] = tri(t);
196
+ const ux = P[b * 3] - P[a * 3], uy = P[b * 3 + 1] - P[a * 3 + 1], uz = P[b * 3 + 2] - P[a * 3 + 2];
197
+ const vx = P[c * 3] - P[a * 3], vy = P[c * 3 + 1] - P[a * 3 + 1], vz = P[c * 3 + 2] - P[a * 3 + 2];
198
+ let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
199
+ const len = Math.hypot(nx, ny, nz) || 1;
200
+ area2[t] = len; // twice the area
201
+ normal[t * 3] = nx / len; normal[t * 3 + 1] = ny / len; normal[t * 3 + 2] = nz / len;
202
+ for (let k = 0; k < 3; k++) cent[t * 3 + k] = (P[a * 3 + k] + P[b * 3 + k] + P[c * 3 + k]) / 3;
203
+ }
204
+ // Only edges touching a member triangle matter; register those first, then let any
205
+ // triangle sharing such an edge join it. The member's welded vertices feed its extents.
206
+ const keys = [];
207
+ const memberVerts = new Map(); // welded key -> index
208
+ for (let t = 0; t < nTri; t++) {
209
+ const v = tri(t);
210
+ keys.push(v.map(key));
211
+ if (!inMember(t)) continue;
212
+ for (let j = 0; j < 3; j++) {
213
+ if (!memberVerts.has(keys[t][j])) memberVerts.set(keys[t][j], v[j]);
214
+ const ka = keys[t][j], kb = keys[t][(j + 1) % 3];
215
+ const ek = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
216
+ if (!edges.has(ek)) edges.set(ek, { a: v[j], b: v[(j + 1) % 3], tris: [] });
217
+ }
218
+ }
219
+ for (let t = 0; t < nTri; t++) {
220
+ for (let j = 0; j < 3; j++) {
221
+ const ka = keys[t][j], kb = keys[t][(j + 1) % 3];
222
+ const e = edges.get(ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`);
223
+ if (e) e.tris.push(t);
224
+ }
225
+ }
226
+
227
+ let sharpLength = 0, maxTurn = null, longest = 0, at = null, attached = false;
228
+ const starts = []; // rounded boundary edges: a root ray each
229
+ for (const e of edges.values()) {
230
+ if (e.tris.length !== 2) continue;
231
+ const [t1, t2] = e.tris;
232
+ if (inMember(t1) === inMember(t2)) continue; // inside the member, or not on it
233
+ attached = true; // a boundary, at any turn
234
+ const d = normal[t1 * 3] * normal[t2 * 3] + normal[t1 * 3 + 1] * normal[t2 * 3 + 1] + normal[t1 * 3 + 2] * normal[t2 * 3 + 2];
235
+ const mx = (P[e.a * 3] + P[e.b * 3]) / 2, my = (P[e.a * 3 + 1] + P[e.b * 3 + 1]) / 2, mz = (P[e.a * 3 + 2] + P[e.b * 3 + 2]) / 2;
236
+ // Concave: the second triangle lies in front of the first one's face. Flush (the
237
+ // face continuing into the body, or mesh-fillet's extension sliver on it) is neither.
238
+ const front = normal[t1 * 3] * (cent[t2 * 3] - mx) + normal[t1 * 3 + 1] * (cent[t2 * 3 + 1] - my) + normal[t1 * 3 + 2] * (cent[t2 * 3 + 2] - mz);
239
+ const flush = d > FLUSH_COS;
240
+ if (!flush && front <= 1e-9) continue; // convex: an outside edge
241
+ const len = Math.hypot(P[e.a * 3] - P[e.b * 3], P[e.a * 3 + 1] - P[e.b * 3 + 1], P[e.a * 3 + 2] - P[e.b * 3 + 2]);
242
+ if (!(len > 0)) continue;
243
+ if (flush || d > SHARP_COS) { // rounded or flush: a root ray
244
+ const tm = inMember(t1) ? t1 : t2;
245
+ const nm = [normal[tm * 3], normal[tm * 3 + 1], normal[tm * 3 + 2]];
246
+ // In the member face's plane, perpendicular to the edge, away from the member:
247
+ // n × e, flipped if it points at the triangle's own centroid.
248
+ const ex = (P[e.b * 3] - P[e.a * 3]) / len, ey = (P[e.b * 3 + 1] - P[e.a * 3 + 1]) / len, ez = (P[e.b * 3 + 2] - P[e.a * 3 + 2]) / len;
249
+ let dx = nm[1] * ez - nm[2] * ey, dy = nm[2] * ex - nm[0] * ez, dz = nm[0] * ey - nm[1] * ex;
250
+ if (dx * (cent[tm * 3] - mx) + dy * (cent[tm * 3 + 1] - my) + dz * (cent[tm * 3 + 2] - mz) > 0) { dx = -dx; dy = -dy; dz = -dz; }
251
+ starts.push({ o: [mx, my, mz], d: [dx, dy, dz], n: nm, len });
252
+ continue;
253
+ }
254
+ const turn = (Math.acos(Math.max(-1, Math.min(1, d))) * 180) / Math.PI;
255
+ sharpLength += len;
256
+ if (maxTurn == null || turn > maxTurn) maxTurn = turn;
257
+ if (len > longest) { longest = len; at = [mx, my, mz]; }
258
+ }
259
+ const size = extents(P, [...memberVerts.values()]);
260
+ let tilt = null;
261
+ if (size.axis) {
262
+ const [x, y, z] = size.axis, m = printMatrix;
263
+ const pz = m ? m[2] * x + m[6] * y + m[10] * z : z;
264
+ tilt = (Math.asin(Math.min(1, Math.abs(pz))) * 180) / Math.PI;
265
+ }
266
+ const base = { found: true, attached, thickness: size.thickness, length: size.length, axis: size.axis, tilt };
267
+ if (!attached) return { ...base, sharpLength: 0, maxTurn: null, at: null, rootRadius: null };
268
+ if (sharpLength >= ROOT_FLOOR) return { ...base, sharpLength, maxTurn, at, rootRadius: 0 };
269
+ let rootRadius = null;
270
+ if (starts.length) {
271
+ const reach = Math.max(RAY_REACH * size.thickness, RAY_MIN);
272
+ const lift = Math.max(LIFT * size.thickness, LIFT_MIN);
273
+ // The non-member triangles any ray's segment box reaches, gathered once.
274
+ const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
275
+ for (const { o, d, n } of starts) for (let k = 0; k < 3; k++) {
276
+ const a = o[k] + n[k] * lift;
277
+ lo[k] = Math.min(lo[k], a, a + d[k] * reach); hi[k] = Math.max(hi[k], a, a + d[k] * reach);
278
+ }
279
+ const near = [];
280
+ for (let t = 0; t < nTri; t++) {
281
+ if (inMember(t)) continue;
282
+ const [a, b, c] = tri(t);
283
+ let out = false;
284
+ for (let k = 0; k < 3 && !out; k++) {
285
+ const x = Math.min(P[a * 3 + k], P[b * 3 + k], P[c * 3 + k]), y = Math.max(P[a * 3 + k], P[b * 3 + k], P[c * 3 + k]);
286
+ out = y < lo[k] - 1e-6 || x > hi[k] + 1e-6;
287
+ }
288
+ if (!out) near.push(t);
289
+ }
290
+ const readings = []; // [radius, length]
291
+ for (const { o, d, n, len } of starts) {
292
+ const origin = [o[0] + n[0] * lift, o[1] + n[1] * lift, o[2] + n[2] * lift];
293
+ let best = Infinity;
294
+ for (const t of near) {
295
+ const [a, b, c] = tri(t);
296
+ const dist = rayTri(P, origin, d, a, b, c);
297
+ if (dist != null && dist < best) best = dist;
298
+ }
299
+ // chord² = s² + lift², and 2·r·lift = chord² on the circle tangent at the boundary.
300
+ readings.push([best > reach ? reach : Math.min(reach, (best * best + lift * lift) / (2 * lift)), len]);
301
+ }
302
+ readings.sort((p, q) => p[0] - q[0]);
303
+ const total = readings.reduce((s, [, l]) => s + l, 0);
304
+ let acc = 0;
305
+ for (const [r, l] of readings) { acc += l; rootRadius = r; if (acc >= RADIUS_QUANTILE * total) break; }
306
+ }
307
+ return { ...base, sharpLength: 0, maxTurn: null, at: null, rootRadius };
308
+ }
@@ -14,6 +14,8 @@ import { bounds, meshArea, meshCentroid } from "./mesh.js";
14
14
  import { minWall, DIAGNOSTIC_SAMPLES } from "./min-wall.js";
15
15
  import { overhang } from "./overhang.js";
16
16
  import { bedContact } from "./bed-contact.js";
17
+ import { flexRoot } from "./flex-root.js";
18
+ import { flexMembers } from "./flex-members.js";
17
19
  import { partBedContact, partFootprint, partGatesMinWall, partHasBed, partOverhangAngle, partWallBands } from "./gates.js";
18
20
  import { summarizeContours } from "./shape-probe.js";
19
21
 
@@ -320,6 +322,16 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
320
322
  // one memo-key bit, covers both readings.
321
323
  const bedArmed = (opts.bedContact !== undefined ? opts.bedContact : (partBedContact(part) ?? partFootprint(part))) != null;
322
324
  const underside = overhangAngle != null || bedArmed;
325
+ // The flexing members the part declares (`verify.flex`, by feature label — flex-root.js):
326
+ // each gets a root reading on every printed sub-part. One pass over the triangles per
327
+ // member, no index, so it runs on every lap. verify passes its own list; alone, measure
328
+ // reads the part's. The list measured is stamped as `measuredFlex`, which verify
329
+ // compares before reusing a seed.
330
+ const members = flexMembers(opts.flex !== undefined ? opts.flex : part.verify?.flex);
331
+ const flex = members.map((m) => m.labels);
332
+ // A flexing member is read in its print pose too (its tilt off the bed), so the pose
333
+ // is resolved whenever one is declared, as it is for the underside readings.
334
+ const posed = underside || members.length > 0;
323
335
  const subBounds = [];
324
336
  // Sheet parts: a view holding one sizes each printed sub-part in its print pose
325
337
  // (verify fits the bed per part there), and every sheet row gets its 2-D facts under
@@ -332,7 +344,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
332
344
  // The overhang reading poses each piece for print (printPoseOverhang), which reads the
333
345
  // resolved params through place() — recorded into the same sink, since the reading
334
346
  // depends on them.
335
- const poseParams = underside ? (sheetParams ?? resolveParams(part, params, undefined, reads)) : null;
347
+ const poseParams = posed ? (sheetParams ?? resolveParams(part, params, undefined, reads)) : null;
336
348
  // The print-pose sizes are read by one check, the process bed (verify passes whether
337
349
  // its profile has one; alone, measure asks the part's own profile), and each costs a
338
350
  // second build of the part — so they are built only for a bed.
@@ -348,7 +360,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
348
360
  // (printPoseOverhang) is read off place() with the resolved params — geometry-free,
349
361
  // so it is resolved here, ahead of the memo, which must key on it.
350
362
  const printed = part.parts[name]?.exportable !== false && !sheet;
351
- const printPose = underside && printed && poseParams?.p
363
+ const printPose = posed && printed && poseParams?.p
352
364
  ? displayToPrintPose(part.parts[name], { view, p: poseParams.p, d: poseParams.d })
353
365
  : null;
354
366
  const printMatrix = printPose?.matrix ?? null;
@@ -372,7 +384,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
372
384
  const memoKey = opts.memo && !sheetView && !sheet && !part.parts[name]?.reference && solid?._hash && !exportBed
373
385
  ? JSON.stringify({
374
386
  mw: !!opts.minWall, s: minWallSamples ?? null, band: wallBands[name] ?? null,
375
- oh: overhangAngle ?? null, bc: bedArmed, ex: printed,
387
+ oh: overhangAngle ?? null, bc: bedArmed, ex: printed, fx: flex,
376
388
  pm: printMatrix ? Array.from(printMatrix) : null,
377
389
  })
378
390
  : null;
@@ -471,6 +483,17 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
471
483
  footprintPct: bc && bc.downArea > 0 ? Math.min(100, (100 * bc.contactArea) / bc.downArea) : null,
472
484
  footprintAt: bc?.lowestAt ?? null,
473
485
  ...(exportBc?.error ? { bedContactError: exportBc.error } : {}),
486
+ // Each declared flexing member's root (flex-root.js), one entry per member group
487
+ // whose label this sub-part's mesh carries: the length of sharp inside corner where
488
+ // the member meets the rest of the part, the sharpest turn, and where the longest
489
+ // stretch sits. Null when nothing is declared; a sheet part is not printed, so none.
490
+ flexRoots: members.length && !sheet
491
+ ? members.map((m) => {
492
+ const r = flexRoot(mesh, m.labels, { printMatrix });
493
+ // A pose the probes could not read is not the display pose: no tilt to report.
494
+ return { feature: m.feature, labels: m.labels, ...r, ...(printPose && !printPose.trusted ? { tilt: null } : {}) };
495
+ }).filter((r) => r.found)
496
+ : null,
474
497
  // The process's 2-D facts on a sheet part (sheetRowFacts), null on every other.
475
498
  sheet: sheet
476
499
  ? sheetRowFacts(kernel, part, name, view, sheetParams, sheetBudget, vol)
@@ -562,6 +585,9 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
562
585
  measuredBedContact: bedArmed,
563
586
  // The footprint is read by the same pass, so the same stamp.
564
587
  measuredFootprint: bedArmed,
588
+ // The flexing-member groups whose roots were read (flex-root.js), as measured —
589
+ // verify reuses a seed only when it read every group this run declares.
590
+ measuredFlex: flex,
565
591
  // Companion stamp to measuredMinWall, and read the same way: whether the pass
566
592
  // ran, said by the pass itself rather than claimed by whoever holds the result.
567
593
  measuredGaps,
@@ -2,10 +2,11 @@ import { parseAssertion, evaluateAssertion } from "./assert-dsl.js";
2
2
  import { measure as defaultMeasure } from "./measure.js";
3
3
  import { pairKey, CONTACT_EPS } from "./gaps.js";
4
4
  import { heldCheck } from "./held.js";
5
- import { resolveProfile, overhangAngleFor, bedContactFor, footprintFor } from "./dfm-profiles.js";
5
+ import { flexMembers } from "./flex-members.js";
6
+ import { resolveProfile, overhangAngleFor, bedContactFor, footprintFor, rootFilletFor, strainFor, flexTiltFor } from "./dfm-profiles.js";
6
7
  import { expandExpectations, partGatesMinWall } from "./gates.js";
7
8
  import { relevanceHash } from "../param-deps.js";
8
- import { SUBPART_METRICS, VIEW_METRICS, sidesSpecError } from "../verify-metrics.js";
9
+ import { SUBPART_METRICS, VIEW_METRICS, sidesSpecError, FLEX_ROOT_HINT, worstFlexRoot } from "../verify-metrics.js";
9
10
  import { processById } from "../process/registry.js";
10
11
  import { SHEET_DOC_ID, SHEET_CHECKS_NOTICE, SHEET_READ_ERROR_HINT } from "../sheet/constants.js";
11
12
  // Re-exported for the callers that read them from here; their one home is sheet/constants.js.
@@ -149,7 +150,7 @@ function check(scope, subpart, metric, spec, registry, factsObj, accept = []) {
149
150
  // did before it had its own branch.
150
151
  if (reg.unavailable) {
151
152
  const out = { ...base, actual, status: "warn", pass: null, message: reg.unavailable,
152
- hint: partHint ?? "no ray read as this wall — the declared band may not match the geometry (check minWall for the real thickness), or a sampled run missed it" };
153
+ hint: partHint ?? reg.unavailableHint ?? "no ray read as this wall — the declared band may not match the geometry (check minWall for the real thickness), or a sampled run missed it" };
153
154
  const note = reg.note?.(factsObj);
154
155
  if (note) out.note = note;
155
156
  return out;
@@ -159,6 +160,9 @@ function check(scope, subpart, metric, spec, registry, factsObj, accept = []) {
159
160
  const { pass, message } = evaluateAssertion(parseAssertion(expr), actual);
160
161
  const status = pass ? "pass" : reg.kind === "warn" ? "warn" : reg.kind === "cost" ? "cost" : "fail";
161
162
  const out = { ...base, actual, status, pass, message };
163
+ // The labelled feature a reading is about (flexRoot's worst member), whatever the verdict.
164
+ const feature = reg.feature?.(factsObj);
165
+ if (feature) out.feature = feature;
162
166
  // A measurement caveat rides along whatever the verdict — a min-wall reading
163
167
  // taken from a sample still passed, but the reader should know it was a sample.
164
168
  const note = reg.note?.(factsObj);
@@ -215,8 +219,8 @@ const SHEET_BUDGET_HINT = "The 2-D sheet checks did not fit their time budget
215
219
  // The print checks: measure() casts no rays and runs no overhang pass on a sheet part.
216
220
  // Declared on one, each is a skip — declared, never evaluated, so a sheet-only forge
217
221
  // that declares only these has no verdict rather than a vacuous pass.
218
- const PRINT_ONLY = new Set(["minWall", "wall", "overhangArea", "bedContactPct", "footprintPct"]);
219
- const PRINT_ONLY_HINT = "minWall, wall, overhangArea, bedContactPct and footprintPct are print checks, and a sheet part is cut, not printed: nothing measures them here. For a laser part declare sheetBridge (the narrowest web or finger) or sheetGap (the narrowest hole or slot) instead.";
222
+ const PRINT_ONLY = new Set(["minWall", "wall", "overhangArea", "bedContactPct", "footprintPct", "flexRoot", "flexStrain", "flexTilt"]);
223
+ const PRINT_ONLY_HINT = "minWall, wall, overhangArea, bedContactPct, footprintPct, flexRoot, flexStrain and flexTilt are print checks, and a sheet part is cut, not printed: nothing measures them here. For a laser part declare sheetBridge (the narrowest web or finger) or sheetGap (the narrowest hole or slot) instead.";
220
224
 
221
225
  // A sheet reading the geometry engine could not take (SheetFacts.readErrors): a
222
226
  // volunteered check warns with the reason; a declared one warns with it too, and is
@@ -278,7 +282,11 @@ function sheetRowChecks(s, declaredExp) {
278
282
  // `footprintPct >= floor` on every printed sub-part.
279
283
  // `accept` the cost ids the user accepted (`verify.accept`), stamped on each cost the
280
284
  // process volunteered (never on one the author wrote in `expect`).
281
- export function evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang = null, bedContact = null, footprint = null, accept = [] }) {
285
+ // `flex` the declared flexing members (flex-root.js flexMembers), `rootFillet` the
286
+ // share of a member's thickness its root fillet must reach (null: off), `strain` the
287
+ // percent root strain allowed at a member's declared deflection (null: off), `flexTilt`
288
+ // the degrees a member's axis may rise off the bed (null: not checked).
289
+ export function evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang = null, bedContact = null, footprint = null, accept = [], flex = [], rootFillet = null, strain = null, flexTilt = null }) {
282
290
  const checks = [];
283
291
  // contacts/clearance/held are pair-derived, not scalar view metrics — peel
284
292
  // them off before the registry loop. held.js explains the exportable bound.
@@ -321,8 +329,27 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
321
329
  message: `not measured: its print (export) pose did not build — ${s.printBboxError}`,
322
330
  hint: "build() should make each piece as it prints — a printed piece shown in an assembly gets its assembled pose from its `views` entry, never from build(). Its export pose failed to build, so its print size — and its STL/3MF export — is unavailable. Fix the pose; the bed check then runs." });
323
331
  }
332
+ // The flexing members this sub-part carries, each stamped with what verify asks of
333
+ // it: the root radius it needs (the process's share of its thickness — declared over
334
+ // measured) and, from a declared deflection, its root strain. The registry's worst-
335
+ // member pickers read these stamps, so the row's expr names that member's need.
336
+ const flexEval = (s.flexRoots ?? []).map((r) => {
337
+ const m = flex.find((x) => sameGroup(x.labels, r.labels));
338
+ const t = m?.thickness ?? r.thickness;
339
+ return { ...r, thicknessUsed: t,
340
+ required: rootFillet != null ? rootFillet * t : null,
341
+ deflection: m?.deflection ?? null,
342
+ strain: m?.deflection != null && r.length > 0 ? (100 * 1.5 * t * m.deflection) / (r.length * r.length) : null };
343
+ });
344
+ const sf = flexEval.length ? { ...s, flexRoots: flexEval } : s;
345
+ const worstRoot = rootFillet != null ? worstFlexRoot(sf) : null;
324
346
  const merged = {
325
347
  ...(profile?.minWall != null ? { minWall: `>=${profile.minWall}` } : {}),
348
+ ...(worstRoot ? { flexRoot: `>=${Math.round(worstRoot.required * 100) / 100}` } : {}),
349
+ ...(strain != null && flexEval.some((r) => r.strain != null) ? { flexStrain: `<=${strain}` } : {}),
350
+ // A member whose print pose could not be read has no tilt: the row then skips
351
+ // "unavailable" rather than vanishing, so the cost is seen to be unread.
352
+ ...(flexTilt != null && flexEval.some((r) => r.attached) ? { flexTilt: `<=${flexTilt}` } : {}),
326
353
  ...(overhang != null ? { overhangArea: "<=1" } : {}),
327
354
  ...(bedContact != null && (!printedNames || printedNames.has(s.name)) ? { bedContactPct: `>=${bedContact}` } : {}),
328
355
  ...(footprint != null && (!printedNames || printedNames.has(s.name)) ? { footprintPct: `>=${footprint}` } : {}),
@@ -332,7 +359,7 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
332
359
  // wrote in `expect` is the user's own limit and keeps its meaning, never accepted.
333
360
  const authored = expect?.[s.name] ?? {};
334
361
  const acceptFor = (metric) => (Object.hasOwn(authored, metric) ? [] : accept);
335
- for (const [metric, expr] of Object.entries(merged)) for (const c of checksFor("subpart", s.name, metric, expr, SUBPART_METRICS, s, acceptFor(metric))) {
362
+ for (const [metric, expr] of Object.entries(merged)) for (const c of checksFor("subpart", s.name, metric, expr, SUBPART_METRICS, sf, acceptFor(metric))) {
336
363
  if (minWallSkipped && (metric === "minWall" || metric === "wall") && c.actual == null) {
337
364
  checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
338
365
  hint: `re-run this check without \`quick\` to measure ${metric === "wall" ? "the wall band" : "min wall"}` });
@@ -363,9 +390,47 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
363
390
  checks.push(c);
364
391
  }
365
392
  }
393
+ checks.push(...flexChecks(facts, flex));
366
394
  return checks;
367
395
  }
368
396
 
397
+ // Flexing members (`verify.flex`, flex-root.js). The reading itself is the registry's
398
+ // `flexRoot` gate, one row per sub-part carrying a member that meets the rest of the
399
+ // part (evaluateCase's `merged`). What is left here is per DECLARED member, the cases
400
+ // where no sub-part row can answer for it: a seed that did not read the member
401
+ // (measuredFlex) answers unevaluated on a quick lap; a member no sub-part of this view
402
+ // carries is a skip, not a fail — a sub-part an `enabled()` turned off, or one shown
403
+ // only in another view — and lint's verify-flex-unknown-label is what catches a label
404
+ // no build ever writes; a member carried but attached nowhere (its label covers the
405
+ // whole sub-part, or the member never touches the body) withholds the verdict: a clean
406
+ // root and no root must never read alike.
407
+ export { FLEX_ROOT_HINT };
408
+ const sameGroup = (a, b) => a.length === b.length && a.every((l, i) => l === b[i]);
409
+ function flexChecks(facts, flex) {
410
+ const out = [];
411
+ for (const { feature, labels: g } of flex) {
412
+ const base = { scope: "subpart", metric: "flexRoot", kind: "gate", feature, expr: "rounded root", subpart: null, actual: null, status: "skip", pass: null };
413
+ if (!(facts.measuredFlex ?? []).some((m) => sameGroup(m, g))) {
414
+ out.push({ ...base, unevaluated: true,
415
+ message: "not measured (quick check)", hint: "re-run this check without `quick` to read the flexing member's root" });
416
+ continue;
417
+ }
418
+ const rows = facts.subparts.flatMap((s) => (s.flexRoots ?? []).filter((r) => sameGroup(r.labels, g)));
419
+ if (!rows.length) {
420
+ out.push({ ...base,
421
+ message: `no sub-part in this view carries the label ${g.map((l) => `"${l}"`).join(" or ")}`,
422
+ hint: "verify.flex names flexing members by their feature label — label the member (`.label(\"Arm\")`) apart from the body it grows from, and spell it the same in both places" });
423
+ continue;
424
+ }
425
+ if (!rows.some((r) => r.attached)) {
426
+ out.push({ ...base, unevaluated: true,
427
+ message: `"${feature}" never meets the rest of the part: its label covers every triangle it touches, so there is no root to read`,
428
+ hint: "label the flexing member apart from the body it grows from (`.label(\"Arm\")` on the arm before the union, not on the union), or, if the member really is a separate piece, it has no root and does not belong in verify.flex" });
429
+ }
430
+ }
431
+ return out;
432
+ }
433
+
369
434
  // `seed` lets a caller that has ALREADY measured this part hand the result in so
370
435
  // verify does not recompute it — see the seeding block below for the shape and
371
436
  // the one correctness rule that governs it.
@@ -391,6 +456,10 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
391
456
  const bedContactFloor = bedContactFor(part, process, { expanded });
392
457
  const footprintFloor = footprintFor(part, process, { expanded });
393
458
  const accept = Array.isArray(part?.verify?.accept) ? part.verify.accept : [];
459
+ const flex = flexMembers(part?.verify?.flex);
460
+ const rootFillet = flex.length ? rootFilletFor(part, process) : null;
461
+ const strainLimit = flex.length ? strainFor(part, process) : null;
462
+ const flexTilt = flexTiltFor(part, process, { expanded });
394
463
  // Reuse rule (spec §4): case B may reuse a measurement taken at params A when
395
464
  // B agrees with A on every param that measurement READ (recorded by measure()
396
465
  // itself). Sound for the same reason the mesh cache is: a deterministic build
@@ -464,7 +533,9 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
464
533
  // sizes cannot answer a bed, so it is admitted only when this run needs no bed.
465
534
  const needPrintBboxes = profile?.bed != null;
466
535
  const printBboxesMatch = !needPrintBboxes || seed?.result?.measuredPrintBboxes !== false;
467
- if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && (quick || bedContactMatches) && printBboxesMatch
536
+ // The flex half: a seed that read every declared member (a superset is free).
537
+ const flexMatches = flex.every(({ labels }) => (seed?.result?.measuredFlex ?? []).some((m) => sameGroup(m, labels)));
538
+ if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && (quick || bedContactMatches) && (quick || flexMatches) && printBboxesMatch
468
539
  && seed.result.view === view) {
469
540
  entries.push({ params: full(seed.params), result: seed.result });
470
541
  }
@@ -478,7 +549,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
478
549
  // for every case buys nothing. (A seed measured WITH probes is a superset in
479
550
  // the same way a min-wall seed is: the extra key is simply never read here.)
480
551
  const result = measureFn(kernel, part, view, params,
481
- { minWall: needMinWall, probes: false, overhang: overhangAngle, bedContact: armsBedPass, printBboxes: needPrintBboxes });
552
+ { minWall: needMinWall, probes: false, overhang: overhangAngle, bedContact: armsBedPass, printBboxes: needPrintBboxes, flex });
482
553
  entries.push({ params: fp, result });
483
554
  return result;
484
555
  };
@@ -499,7 +570,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
499
570
  }];
500
571
  const caseResults = expanded.map(({ name, params, expect }) => {
501
572
  const facts = measureCase(params);
502
- return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang: overhangAngle, bedContact: bedContactFloor, footprint: footprintFloor, accept }) : notMeasured(name) };
573
+ return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang: overhangAngle, bedContact: bedContactFloor, footprint: footprintFloor, accept, flex, rootFillet, strain: strainLimit, flexTilt }) : notMeasured(name) };
503
574
  });
504
575
  // VACUOUS VERIFY. A part that declares nothing — no profile, an empty or absent
505
576
  // `expect` — used to come back `ok: true` with zero checks, and every reader
@@ -534,7 +605,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
534
605
  declared++;
535
606
  if (c.status !== "skip" && !c.unevaluated) evaluated++;
536
607
  }
537
- const declaresAnything = profile != null || overhangAngle != null || bedContactFloor != null || footprintFloor != null
608
+ const declaresAnything = profile != null || overhangAngle != null || bedContactFloor != null || footprintFloor != null || flex.length > 0
538
609
  || expanded.some(({ expect }) => Object.values(expect ?? {}).some((o) => o && typeof o === "object" && Object.keys(o).length > 0));
539
610
  const failures = all.filter((c) => c.status === "fail");
540
611
  const unevaluated = all.filter((c) => c.unevaluated);
@@ -23,6 +23,16 @@
23
23
  // `footprint` is the percent of a printed piece's posed base area resting on the
24
24
  // bed below which the footprint cost fires.
25
25
  //
26
+ // A flexing member (`verify.flex`, oracle/flex-root.js) reads three more: `rootFillet`
27
+ // is the least root fillet radius as a share of the member's thickness (a GATE, 0.5
28
+ // from every snap-fit guide; the stress riser is the same in any material, so resin
29
+ // carries it too); `strain` the largest root strain, in percent, the material takes
30
+ // for a one-time snap (an advisory, read from a declared deflection: PLA is brittle,
31
+ // PETG forgiving); `flexTilt` the steepest the member's long axis may rise off the bed,
32
+ // in degrees, before its layers stack across the root (a COST, `standing`: the layer
33
+ // bond is a fraction of the in-plane strength, so a standing arm snaps along a layer;
34
+ // resin is isotropic and carries none).
35
+ //
26
36
  // Import-free, like every process descriptor: lint and the oracle read it through
27
37
  // process/registry.js.
28
38
  export const FDM = {
@@ -35,14 +45,16 @@ export const FDM = {
35
45
  bed: [220, 220, 250], minWall: 1.2, clearance: 0.2, overhang: 45, bedContact: 50, footprint: 10,
36
46
  fits: { press: -0.1, push: 0.05, slip: 0.25, moving: 0.35, printInPlace: 0.45 },
37
47
  holeUndersize: 0.25, bridge: 10,
48
+ rootFillet: 0.5, strain: 1.5, flexTilt: 45,
38
49
  },
39
50
  "fdm-petg": {
40
51
  bed: [220, 220, 250], minWall: 1.5, clearance: 0.3, overhang: 45, bedContact: 50, footprint: 10,
41
52
  fits: { press: -0.1, push: 0.05, slip: 0.3, moving: 0.4, printInPlace: 0.5 },
42
53
  holeUndersize: 0.3, bridge: 10,
54
+ rootFillet: 0.5, strain: 3, flexTilt: 45,
43
55
  },
44
56
  },
45
- costs: { supports: "overhangArea", footprint: "footprintPct" },
57
+ costs: { supports: "overhangArea", footprint: "footprintPct", standing: "flexTilt" },
46
58
  metrics: {},
47
59
  lintRules: [],
48
60
  };
@@ -1,6 +1,7 @@
1
1
  // Resin printing as DATA: see process/fdm/descriptor.js for what each profile field
2
2
  // means. Resin prints on supports, so it carries no overhang, bedContact, footprint
3
- // or bridge, and no costs. Import-free, like every process descriptor.
3
+ // or bridge, and no costs; it is isotropic, so no flexTilt either. A flexing member's
4
+ // root fillet and strain apply in any material. Import-free, like every process descriptor.
4
5
  export const RESIN = {
5
6
  id: "resin",
6
7
  label: "Resin 3D printing",
@@ -11,6 +12,7 @@ export const RESIN = {
11
12
  bed: [120, 68, 160], minWall: 0.6, clearance: 0.1,
12
13
  fits: { press: -0.05, push: 0.05, slip: 0.1, moving: 0.15, printInPlace: 0.2 },
13
14
  holeUndersize: 0.05,
15
+ rootFillet: 0.5, strain: 2,
14
16
  },
15
17
  },
16
18
  costs: {},
@@ -4,7 +4,10 @@
4
4
  // `pattern` (optional stable ERROR-PATTERNS.md#<id>), `locate` (optional
5
5
  // [x,y,z] source), `note` (optional caveat about HOW the value was measured,
6
6
  // attached whatever the status — a passing-but-sampled reading is exactly the
7
- // case a reader needs told about). `manifoldOnly` facts are null on OCCT parts.
7
+ // case a reader needs told about), `feature` (optional: the labelled feature the
8
+ // reading is about, stamped whatever the status), `unavailable` (+ `unavailableHint`:
9
+ // a null reading is a finding to warn about, not an inert skip). `manifoldOnly`
10
+ // facts are null on OCCT parts.
8
11
  //
9
12
  // This lives one level above framework/oracle/ deliberately: the set of legal
10
13
  // `verify.expect` metrics is part of the PartDefinition CONTRACT, which both the
@@ -63,6 +66,30 @@ export function sidesSpecError(metric, reg, spec) {
63
66
  return null;
64
67
  }
65
68
 
69
+ // The flexing-member readings (oracle/flex-root.js, `verify.flex`): what each failure
70
+ // means and what fixes it.
71
+ export const FLEX_ROOT_HINT = "a part that flexes (a snap arm, buckle prong, clip or cantilever tab) meets the rest of the part through too small a root fillet, or none — bending concentrates the stress at that corner and the member snaps off at its root. Round the root to at least the process's share of the member's thickness (half, on every built-in profile): `s.fillet({ r, edges: { near: [x, y, z] } })` on the joined solid, or a filleted corner in the 2-D profile both share, and keep that fillet clear of whatever the member slides into. A grip rib or barb on the member is part of it: list its label with the member's, `flex: [[\"Arm\", \"Arm rib\"]]`";
72
+ export const FLEX_STRAIN_HINT = "at its declared deflection this member's root strain (1.5 × thickness × deflection / length², a straight cantilever) is over what the material takes for a one-time snap — it yields or cracks at the root. Lengthen the member, thin it, or reduce its travel (a shallower undercut, a lead-in that spreads the snap over more of the stroke); a member tapered to half its thickness at the tip bends ~60% further at the same root strain. If the mesh misreads its bending thickness, declare it: `flex: [{ feature, deflection, thickness }]`";
73
+ export const FLEX_TILT_HINT = "this member's long axis stands up off the bed in the print pose, so its layers stack across its root — and a layer bond is a fraction of the in-plane strength, so it snaps along a layer line. Lay the member flat: pose the piece in `place(..., { purpose: \"export\" })` so the arm lies along the bed (its root corner then sits in one layer), or split the part so the arm prints flat; a standing arm the user keeps is a cost (`accept: [\"standing\"]`) — use PETG or anneal, and round the root generously";
74
+ // The flexing member a sub-part's row answers for: the one whose root is worst (least
75
+ // radius over what it needs — `required`, stamped by verify from the profile's share of
76
+ // its thickness), among those that meet the rest of the sub-part and have a corner to
77
+ // read; null when none does. Exported for verify, whose expr must name that member's need.
78
+ export const worstFlexRoot = (s) => (s.flexRoots ?? []).filter((r) => r.attached && r.rootRadius != null)
79
+ .reduce((w, r) => (w == null || (r.required ?? 1) > 0 && r.rootRadius / (r.required ?? 1) < w.rootRadius / (w.required ?? 1) ? r : w), null);
80
+ const worstFlexStrain = (s) => (s.flexRoots ?? []).filter((r) => r.strain != null)
81
+ .reduce((w, r) => (w == null || r.strain > w.strain ? r : w), null);
82
+ const worstFlexTilt = (s) => (s.flexRoots ?? []).filter((r) => r.attached && r.tilt != null)
83
+ .reduce((w, r) => (w == null || r.tilt > w.tilt ? r : w), null);
84
+ const mm = (v) => Math.round(v * 100) / 100;
85
+ const memberLine = (r) => {
86
+ if (!r.attached) return "never meets the rest of this sub-part";
87
+ if (r.rootRadius === 0) return `sharp root (${Math.round(r.maxTurn)}°, ${r.sharpLength.toFixed(1)} mm of corner)`;
88
+ if (r.rootRadius == null) return "no inside corner at its root";
89
+ const need = r.required != null ? `, needs ≥ ${mm(r.required)} (${mm(r.thicknessUsed ?? r.thickness)} mm thick)` : "";
90
+ return `root radius ${mm(r.rootRadius)} mm${need}`;
91
+ };
92
+
66
93
  export const SUBPART_METRICS = {
67
94
  holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
68
95
  hint: "genus is wrong — an unintended tunnel exists or an intended bore is blocked; make cut tools pierce fully (overcut past the faces)" },
@@ -132,6 +159,51 @@ export const SUBPART_METRICS = {
132
159
  hint: "the share of this piece's silhouette on the bed (its outline in the print pose) that actually touches it is small — it stands on a point, a line or a curve, which reads near zero, so the first layer has almost nothing to hold on to and the piece can tip or pull loose. Lay it on its flat face, add a flat foot or pad at its lowest point, or print it with a brim (accept: [\"footprint\"] records that choice)",
133
160
  pattern: "piece-stands-on-nothing",
134
161
  locate: (s) => s.footprintAt },
162
+ // A declared flexing member's root (oracle/flex-root.js, `verify.flex`): its fillet
163
+ // radius in mm, 0 where it is sharp. One row per sub-part, like every print metric:
164
+ // `actual` is the WORST member's reading (least radius over what it needs), `feature`
165
+ // names it, and `note` lists every member the sub-part carries. Measured on a sub-part
166
+ // carrying a declared label; elsewhere `extract` is null — "no root to read" rather than
167
+ // an inert skip, since an authored expectation with nothing declared, or on a member
168
+ // that never meets the body, is a mistake to point at. A GATE under any process or
169
+ // none: the root is the same in any orientation and the stress concentration is the
170
+ // same in any material.
171
+ flexRoot: { kind: "gate", extract: (s) => { const w = worstFlexRoot(s); return w ? mm(w.rootRadius) : null; },
172
+ hint: FLEX_ROOT_HINT,
173
+ pattern: "flex-root-sharp",
174
+ locate: (s) => worstFlexRoot(s)?.at ?? null,
175
+ feature: (s) => worstFlexRoot(s)?.feature ?? null,
176
+ unavailable: "no root to read",
177
+ unavailableHint: "flexRoot reads the root of a member named in `verify.flex` — declare the flexing member there by its feature label, and label it (`.label(\"Arm\")`) apart from the body it grows from, so this sub-part carries a member that meets the rest of the part",
178
+ note: (s) => {
179
+ const rows = s.flexRoots ?? [];
180
+ return rows.length ? rows.map((r) => `"${r.feature}": ${memberLine(r)}`).join("; ") : null;
181
+ } },
182
+ // A declared flexing member's root strain at its declared deflection, in percent
183
+ // (verify stamps `strain` from the member's measured length and thickness — 1.5·t·y/L²,
184
+ // a straight cantilever). The worst member per sub-part. An ADVISORY: the formula is a
185
+ // first approximation (a taper, a curve or a hook changes it), read against the
186
+ // process's `strain`.
187
+ flexStrain: { kind: "warn", extract: (s) => { const w = worstFlexStrain(s); return w ? Math.round(w.strain * 100) / 100 : null; },
188
+ hint: FLEX_STRAIN_HINT,
189
+ pattern: "flex-strain-over",
190
+ feature: (s) => worstFlexStrain(s)?.feature ?? null,
191
+ note: (s) => {
192
+ const rows = (s.flexRoots ?? []).filter((r) => r.strain != null);
193
+ return rows.length ? rows.map((r) => `"${r.feature}": ${(Math.round(r.strain * 100) / 100).toFixed(2)}% at ${r.deflection} mm (${mm(r.length)} mm long, ${mm(r.thicknessUsed ?? r.thickness)} mm thick)`).join("; ") : null;
194
+ } },
195
+ // How far a declared flexing member's long axis rises off the bed in the print pose,
196
+ // in degrees (0 lying flat, 90 standing) — the worst member per sub-part. A COST
197
+ // (`standing`), armed by an FDM profile's `flexTilt`: an arm the user prints standing is
198
+ // a choice with a price, not a defect, and resin (isotropic) never asks.
199
+ flexTilt: { kind: "cost", costId: "standing", extract: (s) => { const w = worstFlexTilt(s); return w ? Math.round(w.tilt * 10) / 10 : null; },
200
+ hint: FLEX_TILT_HINT,
201
+ pattern: "flex-member-standing",
202
+ feature: (s) => worstFlexTilt(s)?.feature ?? null,
203
+ note: (s) => {
204
+ const rows = (s.flexRoots ?? []).filter((r) => r.attached && r.tilt != null);
205
+ return rows.length ? rows.map((r) => `"${r.feature}": ${Math.round(r.tilt)}° off the bed`).join("; ") : null;
206
+ } },
135
207
  // `s.deviation` (measure.js) exists only for a sub-part declaring `reference:
136
208
  // "<import name>"`; on every other sub-part `extract` returns null, which
137
209
  // `check()` already reports as status "skip" rather than a fail — the
package/types/part.d.ts CHANGED
@@ -510,6 +510,24 @@ export interface DfmProfile {
510
510
  holeUndersize?: number;
511
511
  /** Longest flat span printed unsupported, mm. Absent on a process that prints on supports. */
512
512
  bridge?: number;
513
+ /**
514
+ * The least root fillet a flexing member (`verify.flex`) must meet its body through,
515
+ * as a share of the member's thickness (0.5 on every built-in profile) — a GATE,
516
+ * armed by `verify.flex` alone; `null` switches it off under a named base.
517
+ */
518
+ rootFillet?: number | null;
519
+ /**
520
+ * The largest root strain, in percent, the material takes for a one-time snap (1.5 on
521
+ * PLA, 3 on PETG, 2 on resin) — an advisory, read for a flexing member that declares
522
+ * a `deflection`; `null` switches it off under a named base.
523
+ */
524
+ strain?: number | null;
525
+ /**
526
+ * The steepest a flexing member's long axis may rise off the bed in the print pose, in
527
+ * degrees (45 on the FDM profiles; absent on resin, which is isotropic), before its
528
+ * layers stack across the root. A cost (`standing`), armed by the profile alone.
529
+ */
530
+ flexTilt?: number | null;
513
531
  /** Inherit from a named profile and override the rest. */
514
532
  base?: DfmProfileName | DfmProfile;
515
533
  }
@@ -581,6 +599,25 @@ export interface SubPartExpectations {
581
599
  * a cost (`verify.costs`), never a failure; `accept: ["footprint"]` records a brim.
582
600
  */
583
601
  footprintPct?: Expectation;
602
+ /**
603
+ * The root fillet radius (mm, 0 where sharp) of the flexing member named in
604
+ * `verify.flex` whose root is worst on this sub-part — `feature` names it. Every
605
+ * sub-part carrying a declared member gets `>=` the profile's share of the member's
606
+ * thickness by default; a gate.
607
+ */
608
+ flexRoot?: Expectation;
609
+ /**
610
+ * Root strain (%) of the worst declared flexing member on this sub-part at its declared
611
+ * deflection — 1.5 × thickness × deflection / length², a straight cantilever. Under a
612
+ * profile every member with a `deflection` gets `<=` the profile's `strain`; a warning.
613
+ */
614
+ flexStrain?: Expectation;
615
+ /**
616
+ * Degrees the worst declared flexing member's long axis rises off the bed in the
617
+ * print pose (0 lying flat, 90 standing). Under an FDM profile every member gets
618
+ * `<=45`; reported as a cost (`standing`), never a failure.
619
+ */
620
+ flexTilt?: Expectation;
584
621
  /** Symmetric-difference volume vs. the sub-part's declared `reference` import. */
585
622
  refXorVolume?: Expectation;
586
623
  /** Percent volume delta vs. the sub-part's declared `reference` import. */
@@ -659,7 +696,19 @@ export interface VerifyBlock<P = ResolvedParams, D = Derived> {
659
696
  * process's own default check is marked accepted: a limit you wrote in `expect`
660
697
  * (`overhangArea: "<=500"`) keeps its meaning and is never marked accepted.
661
698
  */
662
- accept?: ("supports" | "footprint")[];
699
+ accept?: ("supports" | "footprint" | "standing")[];
700
+ /**
701
+ * The part's flexing members — snap arms, buckle prongs, clips, cantilever tabs —
702
+ * by feature label. Each must meet the rest of its part through a root fillet of at
703
+ * least the process's share of its thickness (`flexRoot`, a gate); one that declares
704
+ * its `deflection` (mm of travel) is also read for root strain (`flexStrain`, an
705
+ * advisory), and under an FDM profile every member is read for how far it stands up
706
+ * off the bed (`flexTilt`, the `standing` cost). An entry is a label, a list of labels
707
+ * that make one member (an arm and the rib on it), or a member object; `thickness`
708
+ * (mm) overrides the bending thickness the mesh reads. Label the member apart from
709
+ * the body it grows from.
710
+ */
711
+ flex?: (string | string[] | { feature: string; labels?: string[]; deflection?: number; thickness?: number })[];
663
712
  /** Which cases to check; default is `"defaults"` plus every preset name. */
664
713
  cases?: string[];
665
714
  /**
@@ -441,6 +441,22 @@ export interface SubPartFacts {
441
441
  footprintPct: number | null;
442
442
  /** The piece's lowest point, in display coordinates; `null` when not checked. */
443
443
  footprintAt: number[] | null;
444
+ /**
445
+ * The root of each flexing member (`verify.flex`) this sub-part carries, one entry per
446
+ * declared member group whose label its mesh holds; `null` when none is declared, or on
447
+ * a sheet part. `attached` is false when the member shares no edge with the rest of the
448
+ * mesh (nothing to read); `sharpLength` the mm of sharp inside corner at its root.
449
+ */
450
+ flexRoots: {
451
+ feature: string; labels: string[]; found: boolean; attached: boolean;
452
+ sharpLength: number; maxTurn: number | null; at: number[] | null;
453
+ /** The root's fillet radius in mm: 0 where sharp, `null` where the member meets the body flush everywhere. */
454
+ rootRadius: number | null;
455
+ /** The member's own extents (mm) along its principal axes, and the unit direction of the longest. */
456
+ thickness: number; length: number; axis: number[] | null;
457
+ /** Degrees the long axis rises off the bed in the print pose; `null` when that pose could not be read. */
458
+ tilt: number | null;
459
+ }[] | null;
444
460
  /**
445
461
  * Present only when bed contact had to be read off the piece's export build (its
446
462
  * print pose could not be read from place() alone) and that build failed.