partforge 0.172.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), a declared flexing member's root (`flexRoot`) | 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
@@ -2018,12 +2018,20 @@ the user wants it and has accepted the supports.
2018
2018
  ### Flexing members
2019
2019
 
2020
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 — must meet the body it grows from through a **fillet
2022
- at its root of at least half its thickness**. Bending puts the highest stress at the
2023
- root, a sharp inside corner multiplies it several times over, and an FDM print's layers
2024
- are weakest across exactly that corner: an unrounded arm snaps off flush with the body,
2025
- often on the first few flexes. Decide the root fillet with the arm, not after it, and
2026
- keep it clear of whatever the arm slides into (chamfer that mouth so the fillet seats).
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).
2027
2035
 
2028
2036
  Declare every such member in `verify.flex`, by its feature label, and label the member
2029
2037
  **apart from the body** it grows from:
@@ -2033,18 +2041,41 @@ const arm = k.prism({ points: armPts, h: d.armH }).label("Right prong");
2033
2041
  let s = k.union([base, arm, otherArm])
2034
2042
  .fillet({ r: 0.5 * p.armThick, edges: { inPlane: "XZ", at: 0 } }); // the arms' roots on the base face
2035
2043
  // …
2036
- verify: { process: "fdm-pla", flex: ["Right prong", "Left prong"], expect: { … } },
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
+ },
2037
2053
  ```
2038
2054
 
2039
- verify then fails (`flexRoot`, a gate, under any process or none) wherever a triangle of
2040
- the member meets a triangle of anything else at a sharp inside corner — a fillet's faces
2041
- are new surfaces turning a few degrees per facet, so a rounded root reads clean, and a
2042
- face that only continues flush into the body is not a corner. A rib or barb on the arm
2043
- that carries its own label is part of the member: list them together,
2044
- `flex: [["Right prong", "Right prong rib"]]`. Only what you declare is checked: the
2045
- oracle cannot tell an arm that bends from a wall, a tooth or a pin of the same shape. A
2046
- name no build labels is lint's `verify-flex-unknown-label` warning, and a member no
2047
- sub-part of the verified view carries is reported as skipped.
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.
2048
2079
 
2049
2080
  ### Small features and scale
2050
2081
 
@@ -4468,8 +4499,9 @@ A part can declare how it should be checked, co-located with its schema, so
4468
4499
  ```js
4469
4500
  verify: {
4470
4501
  process: "fdm-pla", // a DFM profile: fdm-pla | fdm-petg | resin, or an inline
4471
- // { base, bed:[x,y,z], minWall, overhang, bedContact, footprint, fits, holeUndersize, bridge } object
4472
- 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)
4473
4505
  orientation: "print", // optional; declares build() is the print pose (Z up, bed at
4474
4506
  // the lowest Z) — with no profile it arms bed contact
4475
4507
  cases: ["defaults", "M3"], // optional; default = defaults + every preset
@@ -4567,16 +4599,25 @@ records it. Your own `footprintPct` expectation arms it under any profile; `orie
4567
4599
  does not, and `{ base: "fdm-pla", footprint: null }` switches it off. `exportable: false`
4568
4600
  sub-parts are not judged.
4569
4601
 
4570
- **Flexing members — a GATE, armed by `verify.flex` alone.** `flex` lists the part's
4571
- flexing members by feature label (`flex: ["Right prong", "Left prong"]`; a list entry,
4572
- `["Arm", "Arm rib"]`, is one member made of several labels). Every printed sub-part
4573
- whose mesh carries a member's label gets one `flexRoot` row (`"0"` by default; your own
4574
- `flexRoot` expectation on that sub-part replaces it): the sharp inside corner where a
4575
- member meets anything else — a concave edge turning more than 30° between one of its
4576
- triangles and one that is not — fails on any. `actual` is the worst member's length in
4577
- mm, `feature` names that member, `location` is its longest stretch, and `note` lists
4578
- every member the sub-part carries. A rounded root reads 0. It is independent of the
4579
- process and of the print pose: the root is the same in any orientation. Three
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
4580
4621
  declaration problems are reported per member rather than per sub-part: a member no
4581
4622
  sub-part of the verified view carries is skipped (it may be switched off in that case);
4582
4623
  a member carried but meeting nothing — its label put on the whole union, or a piece
@@ -4603,13 +4644,16 @@ an inline `{ base: "fdm-pla", fits: { … } }` replaces the whole table.
4603
4644
  | `fits.printInPlace` — printed already assembled | 0.45 | 0.50 | 0.20 |
4604
4645
  | `holeUndersize` | 0.25 | 0.30 | 0.05 |
4605
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 | — |
4606
4650
 
4607
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" }`).
4608
4652
 
4609
4653
  **What `expect` gives you:** per-sub-part
4610
4654
  assertions on the facts `measure` already reports — `holes` (through-bores / genus),
4611
4655
  `volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
4612
- `overhangArea`, `bedContactPct`, `footprintPct`, `flexRoot`, `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
4613
4657
  positions, written in the same `{ min, max }` shape `measure` reports them — where
4614
4658
  the geometry sits, vs `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
4615
4659
  centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
@@ -236,9 +236,21 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
236
236
 
237
237
  ## flex-root-sharp
238
238
 
239
- - **Symptom:** A `✗ … flexRoot` failure from `verify` naming the member in `feature`, whose note reads e.g. `"Right prong": 12.2 mm of sharp inside corner (90°)`; 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. 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"]]`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Printed parts".
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".
242
254
 
243
255
  ## expect-static-across-presets
244
256
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.172.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",
@@ -6,6 +6,7 @@ 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";
@@ -449,21 +450,36 @@ export const VERIFY_RULES = [
449
450
  },
450
451
  {
451
452
  // `verify.flex` names the part's flexing members by feature label (oracle/flex-root.js):
452
- // a list whose entries are a label, or a list of labels read as one member. verify()
453
- // drops a malformed entry silently, so the shape is checked here.
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.
454
456
  id: "verify-bad-flex",
455
457
  run: ({ part }) => {
456
458
  const flex = part?.verify?.flex;
457
459
  if (flex == null) return [];
458
- const form = "Write `flex` as a list of feature labels, e.g. `flex: [\"Left arm\", \"Right arm\"]`; an entry that is itself a list names one member made of several labels (`[\"Arm\", \"Arm rib\"]`).";
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> }`.";
459
461
  if (!Array.isArray(flex)) {
460
- return [err("verify-bad-flex", `\`verify.flex\` is ${describe(flex)}, not a list of feature labels`, form, "verify.flex")];
462
+ return [err("verify-bad-flex", `\`verify.flex\` is ${describe(flex)}, not a list of flexing members`, form, "verify.flex")];
461
463
  }
462
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;
463
467
  const out = [];
464
468
  flex.forEach((e, i) => {
465
- if (label(e) || (Array.isArray(e) && e.length > 0 && e.every(label))) return;
466
- out.push(err("verify-bad-flex", `\`verify.flex[${i}]\` is ${describe(e)}, not a feature label or a list of them`, form, `verify.flex[${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
+ }
467
483
  });
468
484
  return out;
469
485
  },
@@ -485,8 +501,9 @@ export const VERIFY_RULES = [
485
501
  const known = [...seen];
486
502
  const out = [];
487
503
  flex.forEach((e, i) => {
488
- for (const l of Array.isArray(e) ? e : [e]) {
489
- if (typeof l !== "string" || !l || seen.has(l)) continue; // a bad shape is verify-bad-flex's
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;
490
507
  const hint = safeSuggest(l, known);
491
508
  out.push(warn("verify-flex-unknown-label",
492
509
  `\`verify.flex\` names "${l}", but no build labels a feature "${l}" at the default parameters`,
@@ -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
+
@@ -1,5 +1,6 @@
1
1
  // src/framework/oracle/flex-root.js
2
- // Whether a flexing member meets the rest of its part through a rounded root.
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.
3
4
  //
4
5
  // The failure this exists for: a snap arm, a buckle prong, a clip or a cantilever tab
5
6
  // joined to its body at a sharp inside corner. Bending concentrates the stress at the
@@ -7,7 +8,11 @@
7
8
  // there — often on the first flex, in FDM plastic whose layer bonds are weakest across
8
9
  // exactly that corner. Seen on a real forge: a side-release buckle whose prongs, each a
9
10
  // 2 mm prism unioned onto the base, broke off flush with the base face. A fillet at the
10
- // root of about half the member's thickness spreads the stress and is what fixes it.
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).
11
16
  //
12
17
  // A mesh cannot tell a member that bends from a wall, a rib, a tooth or a pin — every
13
18
  // one of them is a thin feature joined at a sharp corner, and a shape-only detector
@@ -20,10 +25,27 @@
20
25
  // root reads clean, and so does a member whose faces only continue coplanar into the
21
26
  // body (its top face flush with the base's top).
22
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
+ //
23
42
  // One pass over the triangles to weld edges, pure, no BVH. Works on both mesh forms the
24
43
  // oracle sees (Manifold's soup, OCCT's indexed vertices) — but only on a mesh carrying
25
44
  // `featureIds`/`features` (the feature labels), which both backends attach.
26
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
+ //
27
49
  // Known limits, stated: an inside corner WITHIN one label (the member and its body
28
50
  // labelled alike, or a barb's own notch) is invisible — label the flexing member apart
29
51
  // from the body it grows from. A label that covers the whole sub-part, or a member that
@@ -34,45 +56,139 @@
34
56
  // with the member: `flex: [["Arm", "Arm rib"]]` treats the group as one member.
35
57
 
36
58
  // A concave edge turning more than this (degrees) is a sharp corner. A tessellated
37
- // fillet turns a few degrees per facet (3.1° per step at the preview's 116 segments), a
38
- // 45° chamfer turns 45° twice — a chamfer is a corner too, if a softer one.
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.
39
63
  export const SHARP_DEG = 30;
40
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);
41
69
  // Sharp root edges shorter than this in total (mm) are boolean noise, never reported.
42
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;
43
82
 
44
- /** The member groups `verify.flex` declares: each entry a label or a list of labels. */
45
- export function flexGroups(flex) {
46
- if (!Array.isArray(flex)) return [];
47
- return flex.map((e) => (Array.isArray(e) ? e : [e])).filter((g) => g.length && g.every((l) => typeof l === "string" && l));
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;
48
99
  }
49
100
 
50
- /** The name a member group is reported under: its first label. */
51
- export const groupName = (group) => group[0];
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
+ }
52
159
 
53
160
  /**
54
161
  * @param {{ positions: ArrayLike<number>, indices?: ArrayLike<number>, featureIds?: ArrayLike<number>, features?: string[] }} mesh
55
162
  * @param {string[]} group the labels making up one flexing member
56
- * @returns {{ found: boolean, attached: boolean, sharpLength: number, maxTurn: number|null, at: number[]|null }}
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 }}
57
167
  * `found` false when no triangle carries any of the labels (the check cannot run);
58
168
  * `attached` false when the member shares no edge with the rest of the mesh (nothing
59
169
  * to read either — the label covers everything, or the member touches nothing);
60
170
  * `sharpLength` the total length (mm) of sharp concave root edges, 0 under ROOT_FLOOR;
61
171
  * `maxTurn` the sharpest such edge's turn in degrees and `at` the midpoint of the
62
- * longest one (both null when the root is clean).
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.
63
178
  */
64
- export function flexRoot(mesh, group) {
179
+ export function flexRoot(mesh, group, { printMatrix = null } = {}) {
65
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 };
66
182
  const ids = new Set();
67
183
  if (featureIds && features) features.forEach((f, i) => { if (group.includes(f)) ids.add(i + 1); });
68
- if (!ids.size) return { found: false, attached: false, sharpLength: 0, maxTurn: null, at: null };
184
+ if (!ids.size) return none;
69
185
  const nTri = I ? I.length / 3 : P.length / 9;
70
186
  const vi = (t, j) => (I ? I[t * 3 + j] : t * 3 + j);
71
187
  const key = (i) => `${Math.round(P[i * 3] * 1e4)},${Math.round(P[i * 3 + 1] * 1e4)},${Math.round(P[i * 3 + 2] * 1e4)}`;
72
188
 
73
- // Normals and centroids, and every edge of a member triangle, keyed by welded endpoints.
189
+ // Normals, areas and centroids, and every edge of a member triangle, keyed by welded endpoints.
74
190
  const inMember = (t) => ids.has(featureIds[t]);
75
- const normal = new Float64Array(nTri * 3), cent = new Float64Array(nTri * 3);
191
+ const normal = new Float64Array(nTri * 3), cent = new Float64Array(nTri * 3), area2 = new Float64Array(nTri);
76
192
  const edges = new Map(); // key -> { a, b, tris: [] }
77
193
  const tri = (t) => [vi(t, 0), vi(t, 1), vi(t, 2)];
78
194
  for (let t = 0; t < nTri; t++) {
@@ -81,17 +197,20 @@ export function flexRoot(mesh, group) {
81
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];
82
198
  let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
83
199
  const len = Math.hypot(nx, ny, nz) || 1;
200
+ area2[t] = len; // twice the area
84
201
  normal[t * 3] = nx / len; normal[t * 3 + 1] = ny / len; normal[t * 3 + 2] = nz / len;
85
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;
86
203
  }
87
204
  // Only edges touching a member triangle matter; register those first, then let any
88
- // triangle sharing such an edge join it.
205
+ // triangle sharing such an edge join it. The member's welded vertices feed its extents.
89
206
  const keys = [];
207
+ const memberVerts = new Map(); // welded key -> index
90
208
  for (let t = 0; t < nTri; t++) {
91
209
  const v = tri(t);
92
210
  keys.push(v.map(key));
93
211
  if (!inMember(t)) continue;
94
212
  for (let j = 0; j < 3; j++) {
213
+ if (!memberVerts.has(keys[t][j])) memberVerts.set(keys[t][j], v[j]);
95
214
  const ka = keys[t][j], kb = keys[t][(j + 1) % 3];
96
215
  const ek = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
97
216
  if (!edges.has(ek)) edges.set(ek, { a: v[j], b: v[(j + 1) % 3], tris: [] });
@@ -106,23 +225,84 @@ export function flexRoot(mesh, group) {
106
225
  }
107
226
 
108
227
  let sharpLength = 0, maxTurn = null, longest = 0, at = null, attached = false;
228
+ const starts = []; // rounded boundary edges: a root ray each
109
229
  for (const e of edges.values()) {
110
230
  if (e.tris.length !== 2) continue;
111
231
  const [t1, t2] = e.tris;
112
232
  if (inMember(t1) === inMember(t2)) continue; // inside the member, or not on it
113
233
  attached = true; // a boundary, at any turn
114
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];
115
- if (d > SHARP_COS) continue; // shallow: a fillet facet, a flush face
116
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;
117
- // Concave: the second triangle lies in front of the first one's face.
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.
118
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);
119
- if (front <= 1e-9) continue; // convex: an outside edge
239
+ const flush = d > FLUSH_COS;
240
+ if (!flush && front <= 1e-9) continue; // convex: an outside edge
120
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
+ }
121
254
  const turn = (Math.acos(Math.max(-1, Math.min(1, d))) * 180) / Math.PI;
122
255
  sharpLength += len;
123
256
  if (maxTurn == null || turn > maxTurn) maxTurn = turn;
124
257
  if (len > longest) { longest = len; at = [mx, my, mz]; }
125
258
  }
126
- if (!attached || sharpLength < ROOT_FLOOR) return { found: true, attached, sharpLength: 0, maxTurn: null, at: null };
127
- return { found: true, attached, sharpLength, maxTurn, at };
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 };
128
308
  }
@@ -14,7 +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, flexGroups, groupName } from "./flex-root.js";
17
+ import { flexRoot } from "./flex-root.js";
18
+ import { flexMembers } from "./flex-members.js";
18
19
  import { partBedContact, partFootprint, partGatesMinWall, partHasBed, partOverhangAngle, partWallBands } from "./gates.js";
19
20
  import { summarizeContours } from "./shape-probe.js";
20
21
 
@@ -326,7 +327,11 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
326
327
  // member, no index, so it runs on every lap. verify passes its own list; alone, measure
327
328
  // reads the part's. The list measured is stamped as `measuredFlex`, which verify
328
329
  // compares before reusing a seed.
329
- const flex = flexGroups(opts.flex !== undefined ? opts.flex : part.verify?.flex);
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;
330
335
  const subBounds = [];
331
336
  // Sheet parts: a view holding one sizes each printed sub-part in its print pose
332
337
  // (verify fits the bed per part there), and every sheet row gets its 2-D facts under
@@ -339,7 +344,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
339
344
  // The overhang reading poses each piece for print (printPoseOverhang), which reads the
340
345
  // resolved params through place() — recorded into the same sink, since the reading
341
346
  // depends on them.
342
- const poseParams = underside ? (sheetParams ?? resolveParams(part, params, undefined, reads)) : null;
347
+ const poseParams = posed ? (sheetParams ?? resolveParams(part, params, undefined, reads)) : null;
343
348
  // The print-pose sizes are read by one check, the process bed (verify passes whether
344
349
  // its profile has one; alone, measure asks the part's own profile), and each costs a
345
350
  // second build of the part — so they are built only for a bed.
@@ -355,7 +360,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
355
360
  // (printPoseOverhang) is read off place() with the resolved params — geometry-free,
356
361
  // so it is resolved here, ahead of the memo, which must key on it.
357
362
  const printed = part.parts[name]?.exportable !== false && !sheet;
358
- const printPose = underside && printed && poseParams?.p
363
+ const printPose = posed && printed && poseParams?.p
359
364
  ? displayToPrintPose(part.parts[name], { view, p: poseParams.p, d: poseParams.d })
360
365
  : null;
361
366
  const printMatrix = printPose?.matrix ?? null;
@@ -482,8 +487,12 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
482
487
  // whose label this sub-part's mesh carries: the length of sharp inside corner where
483
488
  // the member meets the rest of the part, the sharpest turn, and where the longest
484
489
  // stretch sits. Null when nothing is declared; a sheet part is not printed, so none.
485
- flexRoots: flex.length && !sheet
486
- ? flex.map((g) => ({ feature: groupName(g), labels: g, ...flexRoot(mesh, g) })).filter((r) => r.found)
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)
487
496
  : null,
488
497
  // The process's 2-D facts on a sheet part (sheetRowFacts), null on every other.
489
498
  sheet: sheet
@@ -2,11 +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 { flexGroups, groupName } from "./flex-root.js";
6
- 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";
7
7
  import { expandExpectations, partGatesMinWall } from "./gates.js";
8
8
  import { relevanceHash } from "../param-deps.js";
9
- import { SUBPART_METRICS, VIEW_METRICS, sidesSpecError, FLEX_ROOT_HINT } from "../verify-metrics.js";
9
+ import { SUBPART_METRICS, VIEW_METRICS, sidesSpecError, FLEX_ROOT_HINT, worstFlexRoot } from "../verify-metrics.js";
10
10
  import { processById } from "../process/registry.js";
11
11
  import { SHEET_DOC_ID, SHEET_CHECKS_NOTICE, SHEET_READ_ERROR_HINT } from "../sheet/constants.js";
12
12
  // Re-exported for the callers that read them from here; their one home is sheet/constants.js.
@@ -219,8 +219,8 @@ const SHEET_BUDGET_HINT = "The 2-D sheet checks did not fit their time budget
219
219
  // The print checks: measure() casts no rays and runs no overhang pass on a sheet part.
220
220
  // Declared on one, each is a skip — declared, never evaluated, so a sheet-only forge
221
221
  // that declares only these has no verdict rather than a vacuous pass.
222
- const PRINT_ONLY = new Set(["minWall", "wall", "overhangArea", "bedContactPct", "footprintPct", "flexRoot"]);
223
- const PRINT_ONLY_HINT = "minWall, wall, overhangArea, bedContactPct, footprintPct and flexRoot 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.";
224
224
 
225
225
  // A sheet reading the geometry engine could not take (SheetFacts.readErrors): a
226
226
  // volunteered check warns with the reason; a declared one warns with it too, and is
@@ -282,7 +282,11 @@ function sheetRowChecks(s, declaredExp) {
282
282
  // `footprintPct >= floor` on every printed sub-part.
283
283
  // `accept` the cost ids the user accepted (`verify.accept`), stamped on each cost the
284
284
  // process volunteered (never on one the author wrote in `expect`).
285
- export function evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang = null, bedContact = null, footprint = null, accept = [], flex = [] }) {
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 }) {
286
290
  const checks = [];
287
291
  // contacts/clearance/held are pair-derived, not scalar view metrics — peel
288
292
  // them off before the registry loop. held.js explains the exportable bound.
@@ -325,21 +329,37 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
325
329
  message: `not measured: its print (export) pose did not build — ${s.printBboxError}`,
326
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." });
327
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;
328
346
  const merged = {
329
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}` } : {}),
330
353
  ...(overhang != null ? { overhangArea: "<=1" } : {}),
331
354
  ...(bedContact != null && (!printedNames || printedNames.has(s.name)) ? { bedContactPct: `>=${bedContact}` } : {}),
332
355
  ...(footprint != null && (!printedNames || printedNames.has(s.name)) ? { footprintPct: `>=${footprint}` } : {}),
333
- // A declared flexing member this sub-part carries AND meets (flex-root.js): its
334
- // root must be rounded. A carried member that meets nothing is flexChecks' row.
335
- ...(flex.length && s.flexRoots?.some((r) => r.attached) ? { flexRoot: "0" } : {}),
336
356
  ...(expect?.[s.name] ?? {}),
337
357
  };
338
358
  // `accept` covers only the process's own default check: a cost metric the author
339
359
  // wrote in `expect` is the user's own limit and keeps its meaning, never accepted.
340
360
  const authored = expect?.[s.name] ?? {};
341
361
  const acceptFor = (metric) => (Object.hasOwn(authored, metric) ? [] : accept);
342
- 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))) {
343
363
  if (minWallSkipped && (metric === "minWall" || metric === "wall") && c.actual == null) {
344
364
  checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
345
365
  hint: `re-run this check without \`quick\` to measure ${metric === "wall" ? "the wall band" : "min wall"}` });
@@ -388,9 +408,8 @@ export { FLEX_ROOT_HINT };
388
408
  const sameGroup = (a, b) => a.length === b.length && a.every((l, i) => l === b[i]);
389
409
  function flexChecks(facts, flex) {
390
410
  const out = [];
391
- for (const g of flex) {
392
- const feature = groupName(g);
393
- const base = { scope: "subpart", metric: "flexRoot", kind: "gate", feature, expr: "0", subpart: null, actual: null, status: "skip", pass: null };
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 };
394
413
  if (!(facts.measuredFlex ?? []).some((m) => sameGroup(m, g))) {
395
414
  out.push({ ...base, unevaluated: true,
396
415
  message: "not measured (quick check)", hint: "re-run this check without `quick` to read the flexing member's root" });
@@ -437,7 +456,10 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
437
456
  const bedContactFloor = bedContactFor(part, process, { expanded });
438
457
  const footprintFloor = footprintFor(part, process, { expanded });
439
458
  const accept = Array.isArray(part?.verify?.accept) ? part.verify.accept : [];
440
- const flex = flexGroups(part?.verify?.flex);
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 });
441
463
  // Reuse rule (spec §4): case B may reuse a measurement taken at params A when
442
464
  // B agrees with A on every param that measurement READ (recorded by measure()
443
465
  // itself). Sound for the same reason the mesh cache is: a deterministic build
@@ -512,7 +534,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
512
534
  const needPrintBboxes = profile?.bed != null;
513
535
  const printBboxesMatch = !needPrintBboxes || seed?.result?.measuredPrintBboxes !== false;
514
536
  // The flex half: a seed that read every declared member (a superset is free).
515
- const flexMatches = flex.every((g) => (seed?.result?.measuredFlex ?? []).some((m) => sameGroup(m, g)));
537
+ const flexMatches = flex.every(({ labels }) => (seed?.result?.measuredFlex ?? []).some((m) => sameGroup(m, labels)));
516
538
  if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && (quick || bedContactMatches) && (quick || flexMatches) && printBboxesMatch
517
539
  && seed.result.view === view) {
518
540
  entries.push({ params: full(seed.params), result: seed.result });
@@ -548,7 +570,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
548
570
  }];
549
571
  const caseResults = expanded.map(({ name, params, expect }) => {
550
572
  const facts = measureCase(params);
551
- return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang: overhangAngle, bedContact: bedContactFloor, footprint: footprintFloor, accept, flex }) : 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) };
552
574
  });
553
575
  // VACUOUS VERIFY. A part that declares nothing — no profile, an empty or absent
554
576
  // `expect` — used to come back `ok: true` with zero checks, and every reader
@@ -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: {},
@@ -66,12 +66,29 @@ export function sidesSpecError(metric, reg, spec) {
66
66
  return null;
67
67
  }
68
68
 
69
- // The flexing-member root (oracle/flex-root.js): what the failure means and what fixes it.
70
- export const FLEX_ROOT_HINT = "a part that flexes (a snap arm, buckle prong, clip or cantilever tab) meets the rest of the part at a sharp inside corner here — bending concentrates the stress at that corner and the member snaps off at its root. Round the root: a fillet of at least half the member's thickness where it joins the body (`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\"]]`";
71
- // The member whose root is worst on a sub-part, among those that meet the rest of it;
72
- // null when none does (nothing declared, no label carried, or every member detached).
73
- const worstFlexRoot = (s) => (s.flexRoots ?? []).filter((r) => r.attached)
74
- .reduce((w, r) => (w == null || r.sharpLength > w.sharpLength ? r : w), null);
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
+ };
75
92
 
76
93
  export const SUBPART_METRICS = {
77
94
  holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
@@ -142,16 +159,16 @@ export const SUBPART_METRICS = {
142
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)",
143
160
  pattern: "piece-stands-on-nothing",
144
161
  locate: (s) => s.footprintAt },
145
- // A declared flexing member's root (oracle/flex-root.js, `verify.flex`): the length in
146
- // mm of sharp inside corner where the member meets the rest of the sub-part. One row
147
- // per sub-part, like every print metric: `actual` is the WORST member's reading,
148
- // `feature` names it, and `note` lists every member the sub-part carries. Measured on
149
- // a sub-part carrying a declared label; elsewhere `extract` is null — "no root to
150
- // read" rather than an inert skip, since an authored expectation with nothing
151
- // declared, or on a member that never meets the body, is a mistake to point at. A
152
- // GATE under any process or none: the root is the same in any orientation and the
153
- // stress concentration is the same in any material.
154
- flexRoot: { kind: "gate", extract: (s) => worstFlexRoot(s)?.sharpLength ?? null,
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; },
155
172
  hint: FLEX_ROOT_HINT,
156
173
  pattern: "flex-root-sharp",
157
174
  locate: (s) => worstFlexRoot(s)?.at ?? null,
@@ -160,8 +177,32 @@ export const SUBPART_METRICS = {
160
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",
161
178
  note: (s) => {
162
179
  const rows = s.flexRoots ?? [];
163
- if (!rows.length) return null;
164
- return rows.map((r) => `"${r.feature}": ${!r.attached ? "never meets the rest of this sub-part" : r.sharpLength > 0 ? `${r.sharpLength.toFixed(1)} mm of sharp inside corner (${Math.round(r.maxTurn)}°)` : "rounded root"}`).join("; ");
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;
165
206
  } },
166
207
  // `s.deviation` (measure.js) exists only for a sub-part declaring `reference:
167
208
  // "<import name>"`; on every other sub-part `extract` returns null, which
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
  }
@@ -582,11 +600,24 @@ export interface SubPartExpectations {
582
600
  */
583
601
  footprintPct?: Expectation;
584
602
  /**
585
- * Length (mm) of sharp inside corner where a flexing member named in `verify.flex`
586
- * meets the rest of this sub-part — `actual` is the worst member's, `feature` names
587
- * it. Every sub-part carrying a declared member gets `"0"` by default; a gate.
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.
588
607
  */
589
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;
590
621
  /** Symmetric-difference volume vs. the sub-part's declared `reference` import. */
591
622
  refXorVolume?: Expectation;
592
623
  /** Percent volume delta vs. the sub-part's declared `reference` import. */
@@ -665,16 +696,19 @@ export interface VerifyBlock<P = ResolvedParams, D = Derived> {
665
696
  * process's own default check is marked accepted: a limit you wrote in `expect`
666
697
  * (`overhangArea: "<=500"`) keeps its meaning and is never marked accepted.
667
698
  */
668
- accept?: ("supports" | "footprint")[];
699
+ accept?: ("supports" | "footprint" | "standing")[];
669
700
  /**
670
701
  * The part's flexing members — snap arms, buckle prongs, clips, cantilever tabs —
671
- * by feature label. Each must meet the rest of its part through a rounded root:
672
- * verify fails on a sharp inside corner where a triangle of the member meets a
673
- * triangle of anything else. An entry that is a list names one member made of
674
- * several labels (an arm and the rib on it). Label the member apart from the body
675
- * it grows from.
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.
676
710
  */
677
- flex?: (string | string[])[];
711
+ flex?: (string | string[] | { feature: string; labels?: string[]; deflection?: number; thickness?: number })[];
678
712
  /** Which cases to check; default is `"defaults"` plus every preset name. */
679
713
  cases?: string[];
680
714
  /**
@@ -447,7 +447,16 @@ export interface SubPartFacts {
447
447
  * a sheet part. `attached` is false when the member shares no edge with the rest of the
448
448
  * mesh (nothing to read); `sharpLength` the mm of sharp inside corner at its root.
449
449
  */
450
- flexRoots: { feature: string; labels: string[]; found: boolean; attached: boolean; sharpLength: number; maxTurn: number | null; at: number[] | null }[] | null;
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;
451
460
  /**
452
461
  * Present only when bed contact had to be read off the piece's export build (its
453
462
  * print pose could not be read from place() alone) and that build failed.