partforge 0.171.0 → 0.172.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,7 +1953,7 @@ 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 |
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
1957
  | **cost** — a design choice with a price | overhang (`overhangArea`, id `supports`), footprint (`footprintPct` ≥ 10, id `footprint`) | never |
1958
1958
  | **advisory** — a reading that may not matter | min wall (`minWall`), `wall` | never; a warning |
1959
1959
 
@@ -2015,6 +2015,37 @@ 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 — 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).
2027
+
2028
+ Declare every such member in `verify.flex`, by its feature label, and label the member
2029
+ **apart from the body** it grows from:
2030
+
2031
+ ```js
2032
+ const arm = k.prism({ points: armPts, h: d.armH }).label("Right prong");
2033
+ let s = k.union([base, arm, otherArm])
2034
+ .fillet({ r: 0.5 * p.armThick, edges: { inPlane: "XZ", at: 0 } }); // the arms' roots on the base face
2035
+ // …
2036
+ verify: { process: "fdm-pla", flex: ["Right prong", "Left prong"], expect: { … } },
2037
+ ```
2038
+
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.
2048
+
2018
2049
  ### Small features and scale
2019
2050
 
2020
2051
  A feature thinner than the profile's `minWall` (1.2 mm PLA, 1.5 mm PETG, 0.6 mm
@@ -2028,7 +2059,8 @@ in the control's label ("below 150 mm the pins get thin").
2028
2059
  Size a mating feature from the profile's fit class — `press`, `push`, `slip`,
2029
2060
  `moving`, `printInPlace` — copied into `derive()` with a comment naming it
2030
2061
  (`// 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
2062
+ snap is a contact; its fit is `push` or `slip`, and its arm gets a root fillet
2063
+ ("Flexing members" above). The numbers, and how a diametral fit
2032
2064
  becomes a `clearance` expectation, are in the `verify` section below.
2033
2065
 
2034
2066
  ### First layer
@@ -4132,8 +4164,9 @@ runtime a throwing probe only degrades quietly to `{ error }`.
4132
4164
 
4133
4165
  **Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
4134
4166
  `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
4167
+ `verify-unknown-orientation`, `verify-unknown-accept`, `verify-bad-flex`,
4168
+ `verify-expect-throws` (all errors), and `verify-flex-unknown-label` (a warning: a
4169
+ `verify.flex` label no build writes at the default parameters). Note `_view` also accepts the pair-wise
4137
4170
  `contacts` / `clearance` / `held` keys, which are not scalar view metrics; they are
4138
4171
  validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
4139
4172
 
@@ -4534,6 +4567,24 @@ records it. Your own `footprintPct` expectation arms it under any profile; `orie
4534
4567
  does not, and `{ base: "fdm-pla", footprint: null }` switches it off. `exportable: false`
4535
4568
  sub-parts are not judged.
4536
4569
 
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
4580
+ declaration problems are reported per member rather than per sub-part: a member no
4581
+ sub-part of the verified view carries is skipped (it may be switched off in that case);
4582
+ a member carried but meeting nothing — its label put on the whole union, or a piece
4583
+ that never touches the body — withholds the verdict (`unevaluated`), since a clean root
4584
+ and no root must never read alike; and a seed that did not read a member answers
4585
+ unevaluated on a quick lap. See "Flexing members" under "Printed parts" for why, and
4586
+ how to round a root.
4587
+
4537
4588
  **What the profile tells you.** Three more fields carry the numbers a mating
4538
4589
  feature needs. Nothing checks them; they are the one place the value is spelled,
4539
4590
  so `derive()` copies it with a comment naming the fit (`// slip fit: fdm-pla 0.25`).
@@ -4558,7 +4609,7 @@ an inline `{ base: "fdm-pla", fits: { … } }` replaces the whole table.
4558
4609
  **What `expect` gives you:** per-sub-part
4559
4610
  assertions on the facts `measure` already reports — `holes` (through-bores / genus),
4560
4611
  `volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
4561
- `overhangArea`, `bedContactPct`, `footprintPct`, `wall` (a range — see below), `bounds` (the axis-aligned corner
4612
+ `overhangArea`, `bedContactPct`, `footprintPct`, `flexRoot`, `wall` (a range — see below), `bounds` (the axis-aligned corner
4562
4613
  positions, written in the same `{ min, max }` shape `measure` reports them — where
4563
4614
  the geometry sits, vs `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
4564
4615
  centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
@@ -234,6 +234,12 @@ 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": 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".
242
+
237
243
  ## expect-static-across-presets
238
244
 
239
245
  - **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.172.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,7 +2,7 @@
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";
@@ -447,4 +447,54 @@ export const VERIFY_RULES = [
447
447
  return out;
448
448
  },
449
449
  },
450
+ {
451
+ // `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.
454
+ id: "verify-bad-flex",
455
+ run: ({ part }) => {
456
+ const flex = part?.verify?.flex;
457
+ 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\"]`).";
459
+ if (!Array.isArray(flex)) {
460
+ return [err("verify-bad-flex", `\`verify.flex\` is ${describe(flex)}, not a list of feature labels`, form, "verify.flex")];
461
+ }
462
+ const label = (l) => typeof l === "string" && l.length > 0;
463
+ const out = [];
464
+ 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}]`));
467
+ });
468
+ return out;
469
+ },
470
+ },
471
+ {
472
+ // A flex label no build writes is a check that never runs: verify skips a member no
473
+ // sub-part carries (it may be switched off in that case), so a typo would pass
474
+ // silently. The geometry-free probe records every `.label()` call at the default
475
+ // params; a label set only under other params reads as missing, hence a warning.
476
+ id: "verify-flex-unknown-label",
477
+ run: ({ part, probe }) => {
478
+ const flex = part?.verify?.flex;
479
+ if (!Array.isArray(flex) || !flex.length) return [];
480
+ const run = probe();
481
+ if (run.runaway || run.throws.length) return []; // an unfinished build saw only some labels
482
+ const seen = new Set(run.calls.filter((c) => c.op === "label")
483
+ .map((c) => { try { return JSON.parse(c.args[0]); } catch { return null; } })
484
+ .filter((l) => typeof l === "string"));
485
+ const known = [...seen];
486
+ const out = [];
487
+ 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
490
+ const hint = safeSuggest(l, known);
491
+ out.push(warn("verify-flex-unknown-label",
492
+ `\`verify.flex\` names "${l}", but no build labels a feature "${l}" at the default parameters`,
493
+ `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.`,
494
+ `verify.flex[${i}]`));
495
+ }
496
+ });
497
+ return out;
498
+ },
499
+ },
450
500
  ];
@@ -0,0 +1,128 @@
1
+ // src/framework/oracle/flex-root.js
2
+ // Whether a flexing member meets the rest of its part through a rounded root.
3
+ //
4
+ // The failure this exists for: a snap arm, a buckle prong, a clip or a cantilever tab
5
+ // joined to its body at a sharp inside corner. Bending concentrates the stress at the
6
+ // root, and a sharp inside corner multiplies it several times over, so the member snaps
7
+ // there — often on the first flex, in FDM plastic whose layer bonds are weakest across
8
+ // exactly that corner. Seen on a real forge: a side-release buckle whose prongs, each a
9
+ // 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
+ //
12
+ // A mesh cannot tell a member that bends from a wall, a rib, a tooth or a pin — every
13
+ // one of them is a thin feature joined at a sharp corner, and a shape-only detector
14
+ // flagged most parts it was tried on. So the part SAYS which members flex
15
+ // (`verify.flex`, by feature label), and this reads only those: every edge where a
16
+ // triangle of the member meets a triangle of anything else. A root is sharp where that
17
+ // edge is concave and turns by more than SHARP_DEG. A fillet's own faces are new,
18
+ // unlabelled surfaces (or the sub-part's), and a tessellated fillet turns by a few
19
+ // degrees per facet, tangent where it meets the member and the body — so a filleted
20
+ // root reads clean, and so does a member whose faces only continue coplanar into the
21
+ // body (its top face flush with the base's top).
22
+ //
23
+ // One pass over the triangles to weld edges, pure, no BVH. Works on both mesh forms the
24
+ // oracle sees (Manifold's soup, OCCT's indexed vertices) — but only on a mesh carrying
25
+ // `featureIds`/`features` (the feature labels), which both backends attach.
26
+ //
27
+ // Known limits, stated: an inside corner WITHIN one label (the member and its body
28
+ // labelled alike, or a barb's own notch) is invisible — label the flexing member apart
29
+ // from the body it grows from. A label that covers the whole sub-part, or a member that
30
+ // never touches the rest of it, therefore has NO root to read: that is `attached` false,
31
+ // which verify withholds a verdict on rather than passing (a clean root and no root
32
+ // must never read alike). And a feature touching the member (a grip rib on the arm)
33
+ // counts as "the rest of the part", so its corners read as roots unless it is listed
34
+ // with the member: `flex: [["Arm", "Arm rib"]]` treats the group as one member.
35
+
36
+ // 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.
39
+ export const SHARP_DEG = 30;
40
+ const SHARP_COS = Math.cos((SHARP_DEG * Math.PI) / 180);
41
+ // Sharp root edges shorter than this in total (mm) are boolean noise, never reported.
42
+ export const ROOT_FLOOR = 0.1;
43
+
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));
48
+ }
49
+
50
+ /** The name a member group is reported under: its first label. */
51
+ export const groupName = (group) => group[0];
52
+
53
+ /**
54
+ * @param {{ positions: ArrayLike<number>, indices?: ArrayLike<number>, featureIds?: ArrayLike<number>, features?: string[] }} mesh
55
+ * @param {string[]} group the labels making up one flexing member
56
+ * @returns {{ found: boolean, attached: boolean, sharpLength: number, maxTurn: number|null, at: number[]|null }}
57
+ * `found` false when no triangle carries any of the labels (the check cannot run);
58
+ * `attached` false when the member shares no edge with the rest of the mesh (nothing
59
+ * to read either — the label covers everything, or the member touches nothing);
60
+ * `sharpLength` the total length (mm) of sharp concave root edges, 0 under ROOT_FLOOR;
61
+ * `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).
63
+ */
64
+ export function flexRoot(mesh, group) {
65
+ const { positions: P, indices: I, featureIds, features } = mesh;
66
+ const ids = new Set();
67
+ 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 };
69
+ const nTri = I ? I.length / 3 : P.length / 9;
70
+ const vi = (t, j) => (I ? I[t * 3 + j] : t * 3 + j);
71
+ const key = (i) => `${Math.round(P[i * 3] * 1e4)},${Math.round(P[i * 3 + 1] * 1e4)},${Math.round(P[i * 3 + 2] * 1e4)}`;
72
+
73
+ // Normals and centroids, and every edge of a member triangle, keyed by welded endpoints.
74
+ const inMember = (t) => ids.has(featureIds[t]);
75
+ const normal = new Float64Array(nTri * 3), cent = new Float64Array(nTri * 3);
76
+ const edges = new Map(); // key -> { a, b, tris: [] }
77
+ const tri = (t) => [vi(t, 0), vi(t, 1), vi(t, 2)];
78
+ for (let t = 0; t < nTri; t++) {
79
+ const [a, b, c] = tri(t);
80
+ 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];
81
+ 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
+ let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
83
+ const len = Math.hypot(nx, ny, nz) || 1;
84
+ normal[t * 3] = nx / len; normal[t * 3 + 1] = ny / len; normal[t * 3 + 2] = nz / len;
85
+ 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
+ }
87
+ // Only edges touching a member triangle matter; register those first, then let any
88
+ // triangle sharing such an edge join it.
89
+ const keys = [];
90
+ for (let t = 0; t < nTri; t++) {
91
+ const v = tri(t);
92
+ keys.push(v.map(key));
93
+ if (!inMember(t)) continue;
94
+ for (let j = 0; j < 3; j++) {
95
+ const ka = keys[t][j], kb = keys[t][(j + 1) % 3];
96
+ const ek = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
97
+ if (!edges.has(ek)) edges.set(ek, { a: v[j], b: v[(j + 1) % 3], tris: [] });
98
+ }
99
+ }
100
+ for (let t = 0; t < nTri; t++) {
101
+ for (let j = 0; j < 3; j++) {
102
+ const ka = keys[t][j], kb = keys[t][(j + 1) % 3];
103
+ const e = edges.get(ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`);
104
+ if (e) e.tris.push(t);
105
+ }
106
+ }
107
+
108
+ let sharpLength = 0, maxTurn = null, longest = 0, at = null, attached = false;
109
+ for (const e of edges.values()) {
110
+ if (e.tris.length !== 2) continue;
111
+ const [t1, t2] = e.tris;
112
+ if (inMember(t1) === inMember(t2)) continue; // inside the member, or not on it
113
+ attached = true; // a boundary, at any turn
114
+ 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
+ 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.
118
+ 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
120
+ 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]);
121
+ const turn = (Math.acos(Math.max(-1, Math.min(1, d))) * 180) / Math.PI;
122
+ sharpLength += len;
123
+ if (maxTurn == null || turn > maxTurn) maxTurn = turn;
124
+ if (len > longest) { longest = len; at = [mx, my, mz]; }
125
+ }
126
+ if (!attached || sharpLength < ROOT_FLOOR) return { found: true, attached, sharpLength: 0, maxTurn: null, at: null };
127
+ return { found: true, attached, sharpLength, maxTurn, at };
128
+ }
@@ -14,6 +14,7 @@ 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
18
  import { partBedContact, partFootprint, partGatesMinWall, partHasBed, partOverhangAngle, partWallBands } from "./gates.js";
18
19
  import { summarizeContours } from "./shape-probe.js";
19
20
 
@@ -320,6 +321,12 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
320
321
  // one memo-key bit, covers both readings.
321
322
  const bedArmed = (opts.bedContact !== undefined ? opts.bedContact : (partBedContact(part) ?? partFootprint(part))) != null;
322
323
  const underside = overhangAngle != null || bedArmed;
324
+ // The flexing members the part declares (`verify.flex`, by feature label — flex-root.js):
325
+ // each gets a root reading on every printed sub-part. One pass over the triangles per
326
+ // member, no index, so it runs on every lap. verify passes its own list; alone, measure
327
+ // reads the part's. The list measured is stamped as `measuredFlex`, which verify
328
+ // compares before reusing a seed.
329
+ const flex = flexGroups(opts.flex !== undefined ? opts.flex : part.verify?.flex);
323
330
  const subBounds = [];
324
331
  // Sheet parts: a view holding one sizes each printed sub-part in its print pose
325
332
  // (verify fits the bed per part there), and every sheet row gets its 2-D facts under
@@ -372,7 +379,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
372
379
  const memoKey = opts.memo && !sheetView && !sheet && !part.parts[name]?.reference && solid?._hash && !exportBed
373
380
  ? JSON.stringify({
374
381
  mw: !!opts.minWall, s: minWallSamples ?? null, band: wallBands[name] ?? null,
375
- oh: overhangAngle ?? null, bc: bedArmed, ex: printed,
382
+ oh: overhangAngle ?? null, bc: bedArmed, ex: printed, fx: flex,
376
383
  pm: printMatrix ? Array.from(printMatrix) : null,
377
384
  })
378
385
  : null;
@@ -471,6 +478,13 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
471
478
  footprintPct: bc && bc.downArea > 0 ? Math.min(100, (100 * bc.contactArea) / bc.downArea) : null,
472
479
  footprintAt: bc?.lowestAt ?? null,
473
480
  ...(exportBc?.error ? { bedContactError: exportBc.error } : {}),
481
+ // Each declared flexing member's root (flex-root.js), one entry per member group
482
+ // whose label this sub-part's mesh carries: the length of sharp inside corner where
483
+ // the member meets the rest of the part, the sharpest turn, and where the longest
484
+ // 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)
487
+ : null,
474
488
  // The process's 2-D facts on a sheet part (sheetRowFacts), null on every other.
475
489
  sheet: sheet
476
490
  ? sheetRowFacts(kernel, part, name, view, sheetParams, sheetBudget, vol)
@@ -562,6 +576,9 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
562
576
  measuredBedContact: bedArmed,
563
577
  // The footprint is read by the same pass, so the same stamp.
564
578
  measuredFootprint: bedArmed,
579
+ // The flexing-member groups whose roots were read (flex-root.js), as measured —
580
+ // verify reuses a seed only when it read every group this run declares.
581
+ measuredFlex: flex,
565
582
  // Companion stamp to measuredMinWall, and read the same way: whether the pass
566
583
  // ran, said by the pass itself rather than claimed by whoever holds the result.
567
584
  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 { flexGroups, groupName } from "./flex-root.js";
5
6
  import { resolveProfile, overhangAngleFor, bedContactFor, footprintFor } 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 } 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"]);
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.";
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,7 @@ 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
+ export function evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang = null, bedContact = null, footprint = null, accept = [], flex = [] }) {
282
286
  const checks = [];
283
287
  // contacts/clearance/held are pair-derived, not scalar view metrics — peel
284
288
  // them off before the registry loop. held.js explains the exportable bound.
@@ -326,6 +330,9 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
326
330
  ...(overhang != null ? { overhangArea: "<=1" } : {}),
327
331
  ...(bedContact != null && (!printedNames || printedNames.has(s.name)) ? { bedContactPct: `>=${bedContact}` } : {}),
328
332
  ...(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" } : {}),
329
336
  ...(expect?.[s.name] ?? {}),
330
337
  };
331
338
  // `accept` covers only the process's own default check: a cost metric the author
@@ -363,9 +370,48 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
363
370
  checks.push(c);
364
371
  }
365
372
  }
373
+ checks.push(...flexChecks(facts, flex));
366
374
  return checks;
367
375
  }
368
376
 
377
+ // Flexing members (`verify.flex`, flex-root.js). The reading itself is the registry's
378
+ // `flexRoot` gate, one row per sub-part carrying a member that meets the rest of the
379
+ // part (evaluateCase's `merged`). What is left here is per DECLARED member, the cases
380
+ // where no sub-part row can answer for it: a seed that did not read the member
381
+ // (measuredFlex) answers unevaluated on a quick lap; a member no sub-part of this view
382
+ // carries is a skip, not a fail — a sub-part an `enabled()` turned off, or one shown
383
+ // only in another view — and lint's verify-flex-unknown-label is what catches a label
384
+ // no build ever writes; a member carried but attached nowhere (its label covers the
385
+ // whole sub-part, or the member never touches the body) withholds the verdict: a clean
386
+ // root and no root must never read alike.
387
+ export { FLEX_ROOT_HINT };
388
+ const sameGroup = (a, b) => a.length === b.length && a.every((l, i) => l === b[i]);
389
+ function flexChecks(facts, flex) {
390
+ 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 };
394
+ if (!(facts.measuredFlex ?? []).some((m) => sameGroup(m, g))) {
395
+ out.push({ ...base, unevaluated: true,
396
+ message: "not measured (quick check)", hint: "re-run this check without `quick` to read the flexing member's root" });
397
+ continue;
398
+ }
399
+ const rows = facts.subparts.flatMap((s) => (s.flexRoots ?? []).filter((r) => sameGroup(r.labels, g)));
400
+ if (!rows.length) {
401
+ out.push({ ...base,
402
+ message: `no sub-part in this view carries the label ${g.map((l) => `"${l}"`).join(" or ")}`,
403
+ 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" });
404
+ continue;
405
+ }
406
+ if (!rows.some((r) => r.attached)) {
407
+ out.push({ ...base, unevaluated: true,
408
+ message: `"${feature}" never meets the rest of the part: its label covers every triangle it touches, so there is no root to read`,
409
+ 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" });
410
+ }
411
+ }
412
+ return out;
413
+ }
414
+
369
415
  // `seed` lets a caller that has ALREADY measured this part hand the result in so
370
416
  // verify does not recompute it — see the seeding block below for the shape and
371
417
  // the one correctness rule that governs it.
@@ -391,6 +437,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
391
437
  const bedContactFloor = bedContactFor(part, process, { expanded });
392
438
  const footprintFloor = footprintFor(part, process, { expanded });
393
439
  const accept = Array.isArray(part?.verify?.accept) ? part.verify.accept : [];
440
+ const flex = flexGroups(part?.verify?.flex);
394
441
  // Reuse rule (spec §4): case B may reuse a measurement taken at params A when
395
442
  // B agrees with A on every param that measurement READ (recorded by measure()
396
443
  // itself). Sound for the same reason the mesh cache is: a deterministic build
@@ -464,7 +511,9 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
464
511
  // sizes cannot answer a bed, so it is admitted only when this run needs no bed.
465
512
  const needPrintBboxes = profile?.bed != null;
466
513
  const printBboxesMatch = !needPrintBboxes || seed?.result?.measuredPrintBboxes !== false;
467
- if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && (quick || bedContactMatches) && printBboxesMatch
514
+ // 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)));
516
+ if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && (quick || bedContactMatches) && (quick || flexMatches) && printBboxesMatch
468
517
  && seed.result.view === view) {
469
518
  entries.push({ params: full(seed.params), result: seed.result });
470
519
  }
@@ -478,7 +527,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
478
527
  // for every case buys nothing. (A seed measured WITH probes is a superset in
479
528
  // the same way a min-wall seed is: the extra key is simply never read here.)
480
529
  const result = measureFn(kernel, part, view, params,
481
- { minWall: needMinWall, probes: false, overhang: overhangAngle, bedContact: armsBedPass, printBboxes: needPrintBboxes });
530
+ { minWall: needMinWall, probes: false, overhang: overhangAngle, bedContact: armsBedPass, printBboxes: needPrintBboxes, flex });
482
531
  entries.push({ params: fp, result });
483
532
  return result;
484
533
  };
@@ -499,7 +548,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
499
548
  }];
500
549
  const caseResults = expanded.map(({ name, params, expect }) => {
501
550
  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) };
551
+ return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang: overhangAngle, bedContact: bedContactFloor, footprint: footprintFloor, accept, flex }) : notMeasured(name) };
503
552
  });
504
553
  // VACUOUS VERIFY. A part that declares nothing — no profile, an empty or absent
505
554
  // `expect` — used to come back `ok: true` with zero checks, and every reader
@@ -534,7 +583,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
534
583
  declared++;
535
584
  if (c.status !== "skip" && !c.unevaluated) evaluated++;
536
585
  }
537
- const declaresAnything = profile != null || overhangAngle != null || bedContactFloor != null || footprintFloor != null
586
+ const declaresAnything = profile != null || overhangAngle != null || bedContactFloor != null || footprintFloor != null || flex.length > 0
538
587
  || expanded.some(({ expect }) => Object.values(expect ?? {}).some((o) => o && typeof o === "object" && Object.keys(o).length > 0));
539
588
  const failures = all.filter((c) => c.status === "fail");
540
589
  const unevaluated = all.filter((c) => c.unevaluated);
@@ -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,13 @@ export function sidesSpecError(metric, reg, spec) {
63
66
  return null;
64
67
  }
65
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);
75
+
66
76
  export const SUBPART_METRICS = {
67
77
  holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
68
78
  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 +142,27 @@ export const SUBPART_METRICS = {
132
142
  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
143
  pattern: "piece-stands-on-nothing",
134
144
  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,
155
+ hint: FLEX_ROOT_HINT,
156
+ pattern: "flex-root-sharp",
157
+ locate: (s) => worstFlexRoot(s)?.at ?? null,
158
+ feature: (s) => worstFlexRoot(s)?.feature ?? null,
159
+ unavailable: "no root to read",
160
+ 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
+ note: (s) => {
162
+ 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("; ");
165
+ } },
135
166
  // `s.deviation` (measure.js) exists only for a sub-part declaring `reference:
136
167
  // "<import name>"`; on every other sub-part `extract` returns null, which
137
168
  // `check()` already reports as status "skip" rather than a fail — the
package/types/part.d.ts CHANGED
@@ -581,6 +581,12 @@ export interface SubPartExpectations {
581
581
  * a cost (`verify.costs`), never a failure; `accept: ["footprint"]` records a brim.
582
582
  */
583
583
  footprintPct?: Expectation;
584
+ /**
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.
588
+ */
589
+ flexRoot?: Expectation;
584
590
  /** Symmetric-difference volume vs. the sub-part's declared `reference` import. */
585
591
  refXorVolume?: Expectation;
586
592
  /** Percent volume delta vs. the sub-part's declared `reference` import. */
@@ -660,6 +666,15 @@ export interface VerifyBlock<P = ResolvedParams, D = Derived> {
660
666
  * (`overhangArea: "<=500"`) keeps its meaning and is never marked accepted.
661
667
  */
662
668
  accept?: ("supports" | "footprint")[];
669
+ /**
670
+ * 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.
676
+ */
677
+ flex?: (string | string[])[];
663
678
  /** Which cases to check; default is `"defaults"` plus every preset name. */
664
679
  cases?: string[];
665
680
  /**
@@ -441,6 +441,13 @@ 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: { feature: string; labels: string[]; found: boolean; attached: boolean; sharpLength: number; maxTurn: number | null; at: number[] | null }[] | null;
444
451
  /**
445
452
  * Present only when bed contact had to be read off the piece's export build (its
446
453
  * print pose could not be read from place() alone) and that build failed.