partforge 0.120.0 → 0.121.1

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.
@@ -3247,11 +3247,18 @@ A part that opted into the overhang check (see the `verify` block) also carries
3247
3247
  checked or on an `exportable: false` sub-part), `overhangAngle` (the steepest such
3248
3248
  face, degrees from vertical) and `overhangAt` (that face's centroid); the report's
3249
3249
  `measuredOverhang` stamps the angle the pass ran against, or `null`.
3250
+ A sub-part that declares `wall` (see the `verify` section below) also carries
3251
+ `wall` — `{ value, location, band, members }` or `null`: the declared band's
3252
+ worst member, located, with the band it was measured against and how many rays
3253
+ fell inside it. `null` unless the sub-part declares `wall` *and* min wall was
3254
+ measured for this run — the same "declared but not measured" gap `minWall`
3255
+ itself has.
3250
3256
  **The budget depends on whether the reading is checked against anything**: a part
3251
3257
  that declares a min-wall gate — a `verify.process` profile, or an `expect`
3252
- mentioning `minWall` — gets 50,000, because a gate's verdict rides on it; a part
3253
- that declares neither gets 5,000, because there the number is a diagnostic for a
3254
- reader rather than an assertion. Declaring the gate is what buys the resolution.
3258
+ mentioning `minWall` or `wall` — gets 50,000, because a gate's verdict rides on
3259
+ it; a part that declares neither gets 5,000, because there the number is a
3260
+ diagnostic for a reader rather than an assertion. Declaring the gate is what
3261
+ buys the resolution.
3255
3262
  `sampled` is how many triangles the walk *selected*, not how many rays were
3256
3263
  cast: a degenerate (zero-area) triangle has no normal to cast along and is
3257
3264
  skipped. A sampled reading is an **upper bound**: it can miss a thin spot, never
@@ -3372,7 +3379,7 @@ are skipped. Switch it off under an FDM profile with an inline
3372
3379
  `{ base: "fdm-pla", overhang: null }`. **What `expect` gives you:** per-sub-part
3373
3380
  assertions on the facts `measure` already reports — `holes` (through-bores / genus),
3374
3381
  `volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
3375
- `overhangArea`, `boundsMin` / `boundsMax`
3382
+ `overhangArea`, `wall` (a range — see below), `boundsMin` / `boundsMax`
3376
3383
  (the axis-aligned `{min,max}` corner positions — where the geometry sits, vs
3377
3384
  `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
3378
3385
  centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
@@ -3413,6 +3420,27 @@ verify: { expect: {
3413
3420
  and so is `overhangArea` (see above). `holes`/`watertight` are Manifold-only, so those
3414
3421
  assertions **skip** on OCCT parts rather than fail.
3415
3422
 
3423
+ **A wall that must stay one thickness: `wall`.** `minWall` answers "is anything too
3424
+ thin"; `wall` answers "does this wall stay what I declared" — the question a bend, a
3425
+ fillet or an offset silently breaks. Declare it as a range in mm, per sub-part:
3426
+
3427
+ ```js
3428
+ verify: { expect: { tray: { wall: "1.8..2.2" } } }
3429
+ ```
3430
+
3431
+ It rides the same inward rays as `minWall`. A ray reading inside `[0.75 × min,
3432
+ 1.5 × max]` counts as this wall (a 1.2 mm floor under a 2 mm wall is another
3433
+ feature and is ignored) — but the window discriminates by **thickness alone, not
3434
+ intent**: anything else on that sub-part whose thickness falls in the window is
3435
+ counted too, so declare `wall` on a sub-part that is mostly this one wall; a rib
3436
+ or boss inside the window reads as a deviation, and a part that needs two
3437
+ thicknesses declares two sub-parts. The check reports the member farthest from
3438
+ the band — or, when every member is inside it, the one farthest from its
3439
+ midpoint — with its location, and warns when it lies outside — `wall 2.62 out of
3440
+ 1.8..2.2 at (22.8, -2.0, 15.1)` is a bend whose outer arc is not concentric with
3441
+ its inner one. Range form only (lint refuses `"<=2"`), a warning like `minWall`
3442
+ because it is a sampled reading.
3443
+
3416
3444
  **A verify block that declares nothing verifies nothing.** `verify.ok` is tri-state:
3417
3445
  `true` when every declared check passed, `false` on any gate failure, and `null` when
3418
3446
  no verdict can be given — a quick lap that could not measure a gate, or a part with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.120.0",
3
+ "version": "0.121.1",
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",
@@ -0,0 +1,27 @@
1
+ // Pure arc math, paper-free: circumcircle center + signed sweep for the arc through three
2
+ // points. Split out of paper-bridge.js so callers that only need this number crunching (the
3
+ // oracle's shape-probe.js, contour-ops.js's corner/tangent math) don't pull in paper-core —
4
+ // paper-bridge.js still re-exports arcCenterAndSweep so every existing importer of it is
5
+ // unaffected.
6
+
7
+ // Circumcircle center + signed sweep for the arc through (p0, via, to) — the sweep is the
8
+ // one passing through `via` (sign-free, winding-free), same recovery as profile.js's
9
+ // sampleArc. Returns null for a collinear (degenerate) triple. Shared by arcToCubicSegments
10
+ // (paper-bridge.js) and contour-ops.js's jointTangents (arc tangents are ⊥ radius, oriented by dA's sign).
11
+ export function arcCenterAndSweep(p0, via, to) {
12
+ const [ax, ay] = p0, [bx, by] = via, [cx, cy] = to;
13
+ const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
14
+ if (Math.abs(d) < 1e-12) return null;
15
+ const sa = ax*ax + ay*ay, sb = bx*bx + by*by, sc = cx*cx + cy*cy;
16
+ const ux = (sa * (by - cy) + sb * (cy - ay) + sc * (ay - by)) / d;
17
+ const uy = (sa * (cx - bx) + sb * (ax - cx) + sc * (bx - ax)) / d;
18
+ const r = Math.hypot(ax - ux, ay - uy);
19
+ const a0 = Math.atan2(ay - uy, ax - ux);
20
+ const av = Math.atan2(by - uy, bx - ux);
21
+ const a1 = Math.atan2(cy - uy, cx - ux);
22
+ const twoPi = 2 * Math.PI;
23
+ const ccw = (x) => { let v = x % twoPi; if (v < 0) v += twoPi; return v; };
24
+ const dCCW = ccw(a1 - a0), vCCW = ccw(av - a0);
25
+ const dA = vCCW <= dCCW ? dCCW : dCCW - twoPi;
26
+ return { center: [ux, uy], r, dA };
27
+ }
@@ -0,0 +1,130 @@
1
+ // Paper-free corner/tangent math for the contour IR, split out of contour-ops.js so the
2
+ // oracle's shape-probe.js (and anything else that only needs a shape's corners/tangents, not
3
+ // its booleans/transforms) doesn't pull in paper-core through contour-ops.js's own
4
+ // paper-bridge.js import. Imports only profile.js, shape2d-regions.js and arc-math.js — never
5
+ // paper-bridge.js or contour-ops.js, which is what keeps this leaf paper-free.
6
+ import { isPathContour, tessellateContour, pointsToContour, closeContourGap } from "./profile.js";
7
+ import { ringArea } from "./shape2d-regions.js";
8
+ import { arcCenterAndSweep } from "./arc-math.js";
9
+
10
+ export const WINDING_SEGS = 64; // tessellation LOD for orientation/containment sampling
11
+
12
+ export const isPointList = (x) => Array.isArray(x) && x.length > 0 && Array.isArray(x[0]);
13
+ // pointsToContour always closes explicitly; a raw {start,segments} contour (hand-authored
14
+ // via pathProfile, or handed back from a Shape2D's own stored regions) might not —
15
+ // closeContourGap is a no-op when it's already closed, so every ring liftProfile hands to
16
+ // contour-ops' corner/transform/query functions is guaranteed explicitly closed.
17
+ export const liftContour = (c) => (isPointList(c) ? pointsToContour(c) : closeContourGap(c));
18
+
19
+ export function liftProfile(input) {
20
+ if (input && input._shape2d) return { kind: "regions", regions: input.toContours() };
21
+ if (isPointList(input)) return { kind: "points", regions: [{ outer: pointsToContour(input), holes: [] }] };
22
+ if (isPathContour(input)) return { kind: "contour", regions: [{ outer: closeContourGap(input), holes: [] }] };
23
+ if (Array.isArray(input) && input.every((r) => r && r.outer))
24
+ return { kind: "regions", regions: input.map((r) => ({ outer: liftContour(r.outer), holes: (r.holes ?? []).map(liftContour) })) };
25
+ if (input && input.outer)
26
+ return { kind: "region", regions: [{ outer: liftContour(input.outer), holes: (input.holes ?? []).map(liftContour) }] };
27
+ throw new Error("contour-ops: input must be [[x,y],…], a {start,segments} contour, {outer,holes}, or a region array");
28
+ }
29
+
30
+ export const contourIsCCW = (c) => ringArea(tessellateContour(c, WINDING_SEGS)) >= 0;
31
+
32
+ // ── Corner model ────────────────────────────────────────────────────────────
33
+ // profileCorners() walks a contour's joints and reports each non-smooth one: the interior
34
+ // angle, whether it's convex (material-relative — see below), and the segment kinds either
35
+ // side of it. jointTangents() is the shared per-vertex tangent computation, reused by Task 6.
36
+
37
+ export const SMOOTH_JOINT_DEG = 1;
38
+
39
+ // Unit tangent of segment `s` (from `from`) at its start (dir=+1) or end (dir=-1 → arrival direction).
40
+ // Exported for contour-winding.js's _chain: junction ordering at a curved pinch point needs
41
+ // the exact endpoint tangent, not an approximation (via/c1/c2 are NOT control points that
42
+ // happen to sit near the tangent — via in particular is a THROUGH point near mid-sweep, so
43
+ // from->via is systematically biased by about sweep/4). This recovers it exactly for arcs
44
+ // (perpendicular to the radius at the recovered center, oriented by the sweep's sign) and
45
+ // cubics (including the degenerate c1===from case, where the true tangent comes from c2).
46
+ export function segTangent(from, s, atStart) {
47
+ const norm = ([x, y]) => { const L = Math.hypot(x, y) || 1; return [x / L, y / L]; };
48
+ if (s.c1) {
49
+ if (atStart) {
50
+ const d = [s.c1[0] - from[0], s.c1[1] - from[1]];
51
+ return norm(Math.hypot(d[0], d[1]) > 1e-9 ? d : [s.c2[0] - from[0], s.c2[1] - from[1]]);
52
+ }
53
+ const d = [s.to[0] - s.c2[0], s.to[1] - s.c2[1]];
54
+ return norm(Math.hypot(d[0], d[1]) > 1e-9 ? d : [s.to[0] - s.c1[0], s.to[1] - s.c1[1]]);
55
+ }
56
+ if (s.via) {
57
+ // tangent ⊥ radius, oriented along the sweep (recover center like arcToCubicSegments)
58
+ const c = arcCenterAndSweep(from, s.via, s.to); // {center:[x,y], dA} or null
59
+ if (!c) return norm([s.to[0] - from[0], s.to[1] - from[1]]);
60
+ const p = atStart ? from : s.to;
61
+ const r = [p[0] - c.center[0], p[1] - c.center[1]];
62
+ const t = c.dA >= 0 ? [-r[1], r[0]] : [r[1], -r[0]];
63
+ return norm(t);
64
+ }
65
+ return norm([s.to[0] - from[0], s.to[1] - from[1]]);
66
+ }
67
+
68
+ export function jointTangents(contour) { // per vertex i: tangent arriving at and leaving vertex i
69
+ const n = contour.segments.length;
70
+ const pts = [contour.start, ...contour.segments.map((s) => s.to)];
71
+ return contour.segments.map((_, i) => {
72
+ const prevSeg = contour.segments[(i - 1 + n) % n];
73
+ const prevFrom = pts[(i - 1 + n) % n];
74
+ return {
75
+ point: pts[i],
76
+ inTan: segTangent(prevFrom, prevSeg, false),
77
+ outTan: segTangent(pts[i], contour.segments[i], true),
78
+ };
79
+ });
80
+ }
81
+
82
+ export const segType = (s) => (s.c1 ? "cubic" : s.via ? "arc" : "line");
83
+
84
+ export function contourCorners(contour) {
85
+ const ccw = contourIsCCW(contour);
86
+ const n = contour.segments.length;
87
+ const out = [];
88
+ jointTangents(contour).forEach(({ point, inTan, outTan }, i) => {
89
+ const cross = inTan[0] * outTan[1] - inTan[1] * outTan[0];
90
+ const dot = Math.min(1, Math.max(-1, inTan[0] * outTan[0] + inTan[1] * outTan[1]));
91
+ const turnDeg = (Math.atan2(Math.abs(cross), dot) * 180) / Math.PI;
92
+ if (turnDeg < SMOOTH_JOINT_DEG) return;
93
+ const leftTurn = cross > 0;
94
+ out.push({
95
+ index: i, point: [point[0], point[1]],
96
+ interiorAngleDeg: ccw === leftTurn ? 180 - turnDeg : 180 + turnDeg,
97
+ convex: leftTurn === ccw,
98
+ segTypes: [segType(contour.segments[(i - 1 + n) % n]), segType(contour.segments[i])],
99
+ });
100
+ });
101
+ return out;
102
+ }
103
+
104
+ // Two numbers on every corner, and they are NOT interchangeable. `index` is the joint's
105
+ // vertex number within its own contour (what buildCornerOpRing, simplify and the lofts
106
+ // key on); `position` is the corner's place in THIS returned list — the one
107
+ // `{corners: {indices}}` selects by. They diverge as soon as a contour has a smooth
108
+ // joint (a collinear midpoint, a G1 arc-line join), and on region input `index`
109
+ // restarts per ring while `position` runs on through the flattened order.
110
+ const withPositions = (corners) => corners.map((c, position) => ({ ...c, position }));
111
+
112
+ export function profileCorners(input) {
113
+ const { kind, regions } = liftProfile(input);
114
+ if (kind === "points" || kind === "contour") return withPositions(contourCorners(regions[0].outer));
115
+ const out = [];
116
+ regions.forEach((rg, regionIndex) => {
117
+ for (const c of contourCorners(rg.outer)) out.push({ regionIndex, ring: "outer", ...c });
118
+ rg.holes.forEach((h, hi) => { for (const c of contourCorners(h)) out.push({ regionIndex, ring: { hole: hi }, ...c }); });
119
+ });
120
+ return withPositions(out);
121
+ }
122
+
123
+ export function cubicAt(p0, c1, c2, p1, t) {
124
+ const u = 1 - t;
125
+ return [0, 1].map((k) => u * u * u * p0[k] + 3 * u * u * t * c1[k] + 3 * u * t * t * c2[k] + t * t * t * p1[k]);
126
+ }
127
+ export function cubicDeriv(p0, c1, c2, p1, t) {
128
+ const u = 1 - t;
129
+ return [0, 1].map((k) => 3 * u * u * (c1[k] - p0[k]) + 6 * u * t * (c2[k] - c1[k]) + 3 * t * t * (p1[k] - c2[k]));
130
+ }
@@ -1,32 +1,19 @@
1
- import { isPathContour, tessellateContour, pointsToContour, reverseContour, closeContourGap } from "./profile.js";
1
+ import { reverseContour, closeContourGap } from "./profile.js";
2
2
  import { ringArea, pointInRing } from "./shape2d-regions.js";
3
3
  import { arcToCubicSegments, arcCenterAndSweep, paperScope, toPaperPath, toContour, toOpenContour } from "./paper-bridge.js";
4
+ import { liftProfile, contourIsCCW, contourCorners, cubicAt, cubicDeriv } from "./contour-corners.js";
4
5
 
5
6
  // Re-exported so existing importers (test/contour-ops-lift.test.js and others) keep
6
- // working unchanged — the definitions live in profile.js (the contour-IR home) so
7
- // paper-bridge.js can use them without importing this module (which already imports
7
+ // working unchanged — the definition lives in profile.js (the contour-IR home) so
8
+ // paper-bridge.js can use it without importing this module (which already imports
8
9
  // paper-bridge.js; importing back would cycle).
9
- export { pointsToContour, reverseContour };
10
-
11
- const WINDING_SEGS = 64; // tessellation LOD for orientation/containment sampling
12
-
13
- const isPointList = (x) => Array.isArray(x) && x.length > 0 && Array.isArray(x[0]);
14
- // pointsToContour always closes explicitly; a raw {start,segments} contour (hand-authored
15
- // via pathProfile, or handed back from a Shape2D's own stored regions) might not —
16
- // closeContourGap is a no-op when it's already closed, so every ring liftProfile hands to
17
- // contour-ops' corner/transform/query functions is guaranteed explicitly closed.
18
- const liftContour = (c) => (isPointList(c) ? pointsToContour(c) : closeContourGap(c));
19
-
20
- export function liftProfile(input) {
21
- if (input && input._shape2d) return { kind: "regions", regions: input.toContours() };
22
- if (isPointList(input)) return { kind: "points", regions: [{ outer: pointsToContour(input), holes: [] }] };
23
- if (isPathContour(input)) return { kind: "contour", regions: [{ outer: closeContourGap(input), holes: [] }] };
24
- if (Array.isArray(input) && input.every((r) => r && r.outer))
25
- return { kind: "regions", regions: input.map((r) => ({ outer: liftContour(r.outer), holes: (r.holes ?? []).map(liftContour) })) };
26
- if (input && input.outer)
27
- return { kind: "region", regions: [{ outer: liftContour(input.outer), holes: (input.holes ?? []).map(liftContour) }] };
28
- throw new Error("contour-ops: input must be [[x,y],…], a {start,segments} contour, {outer,holes}, or a region array");
29
- }
10
+ export { reverseContour };
11
+
12
+ // The paper-free corner/tangent/lift math now lives in contour-corners.js — so the oracle's
13
+ // shape-probe.js can use profileCorners()/cubicAt() without pulling in paper-core through
14
+ // this module's own paper-bridge.js import below — re-exported here so every existing
15
+ // importer of these names from contour-ops.js keeps working unchanged.
16
+ export { liftProfile, contourIsCCW, SMOOTH_JOINT_DEG, segTangent, jointTangents, profileCorners, cubicAt } from "./contour-corners.js";
30
17
 
31
18
  export function restoreProfile(kind, regions) {
32
19
  if (kind === "regions") return regions;
@@ -43,8 +30,6 @@ export function restoreProfile(kind, regions) {
43
30
  return outer; // curves were introduced — upgrade to a contour
44
31
  }
45
32
 
46
- export const contourIsCCW = (c) => ringArea(tessellateContour(c, WINDING_SEGS)) >= 0;
47
-
48
33
  export function ensureRegionWinding(region) {
49
34
  return {
50
35
  outer: contourIsCCW(region.outer) ? region.outer : reverseContour(region.outer),
@@ -116,97 +101,6 @@ export function mirrorProfile(input, axis) {
116
101
  return transformProfile(input, [a, b, b, -a, px - a * px - b * py, py - b * px + a * py]);
117
102
  }
118
103
 
119
- // ── Corner model ────────────────────────────────────────────────────────────
120
- // profileCorners() walks a contour's joints and reports each non-smooth one: the interior
121
- // angle, whether it's convex (material-relative — see below), and the segment kinds either
122
- // side of it. jointTangents() is the shared per-vertex tangent computation, reused by Task 6.
123
-
124
- export const SMOOTH_JOINT_DEG = 1;
125
-
126
- // Unit tangent of segment `s` (from `from`) at its start (dir=+1) or end (dir=-1 → arrival direction).
127
- // Exported for contour-winding.js's _chain: junction ordering at a curved pinch point needs
128
- // the exact endpoint tangent, not an approximation (via/c1/c2 are NOT control points that
129
- // happen to sit near the tangent — via in particular is a THROUGH point near mid-sweep, so
130
- // from->via is systematically biased by about sweep/4). This recovers it exactly for arcs
131
- // (perpendicular to the radius at the recovered center, oriented by the sweep's sign) and
132
- // cubics (including the degenerate c1===from case, where the true tangent comes from c2).
133
- export function segTangent(from, s, atStart) {
134
- const norm = ([x, y]) => { const L = Math.hypot(x, y) || 1; return [x / L, y / L]; };
135
- if (s.c1) {
136
- if (atStart) {
137
- const d = [s.c1[0] - from[0], s.c1[1] - from[1]];
138
- return norm(Math.hypot(d[0], d[1]) > 1e-9 ? d : [s.c2[0] - from[0], s.c2[1] - from[1]]);
139
- }
140
- const d = [s.to[0] - s.c2[0], s.to[1] - s.c2[1]];
141
- return norm(Math.hypot(d[0], d[1]) > 1e-9 ? d : [s.to[0] - s.c1[0], s.to[1] - s.c1[1]]);
142
- }
143
- if (s.via) {
144
- // tangent ⊥ radius, oriented along the sweep (recover center like arcToCubicSegments)
145
- const c = arcCenterAndSweep(from, s.via, s.to); // {center:[x,y], dA} or null
146
- if (!c) return norm([s.to[0] - from[0], s.to[1] - from[1]]);
147
- const p = atStart ? from : s.to;
148
- const r = [p[0] - c.center[0], p[1] - c.center[1]];
149
- const t = c.dA >= 0 ? [-r[1], r[0]] : [r[1], -r[0]];
150
- return norm(t);
151
- }
152
- return norm([s.to[0] - from[0], s.to[1] - from[1]]);
153
- }
154
-
155
- export function jointTangents(contour) { // per vertex i: tangent arriving at and leaving vertex i
156
- const n = contour.segments.length;
157
- const pts = [contour.start, ...contour.segments.map((s) => s.to)];
158
- return contour.segments.map((_, i) => {
159
- const prevSeg = contour.segments[(i - 1 + n) % n];
160
- const prevFrom = pts[(i - 1 + n) % n];
161
- return {
162
- point: pts[i],
163
- inTan: segTangent(prevFrom, prevSeg, false),
164
- outTan: segTangent(pts[i], contour.segments[i], true),
165
- };
166
- });
167
- }
168
-
169
- const segType = (s) => (s.c1 ? "cubic" : s.via ? "arc" : "line");
170
-
171
- function contourCorners(contour) {
172
- const ccw = contourIsCCW(contour);
173
- const n = contour.segments.length;
174
- const out = [];
175
- jointTangents(contour).forEach(({ point, inTan, outTan }, i) => {
176
- const cross = inTan[0] * outTan[1] - inTan[1] * outTan[0];
177
- const dot = Math.min(1, Math.max(-1, inTan[0] * outTan[0] + inTan[1] * outTan[1]));
178
- const turnDeg = (Math.atan2(Math.abs(cross), dot) * 180) / Math.PI;
179
- if (turnDeg < SMOOTH_JOINT_DEG) return;
180
- const leftTurn = cross > 0;
181
- out.push({
182
- index: i, point: [point[0], point[1]],
183
- interiorAngleDeg: ccw === leftTurn ? 180 - turnDeg : 180 + turnDeg,
184
- convex: leftTurn === ccw,
185
- segTypes: [segType(contour.segments[(i - 1 + n) % n]), segType(contour.segments[i])],
186
- });
187
- });
188
- return out;
189
- }
190
-
191
- // Two numbers on every corner, and they are NOT interchangeable. `index` is the joint's
192
- // vertex number within its own contour (what buildCornerOpRing, simplify and the lofts
193
- // key on); `position` is the corner's place in THIS returned list — the one
194
- // `{corners: {indices}}` selects by. They diverge as soon as a contour has a smooth
195
- // joint (a collinear midpoint, a G1 arc-line join), and on region input `index`
196
- // restarts per ring while `position` runs on through the flattened order.
197
- const withPositions = (corners) => corners.map((c, position) => ({ ...c, position }));
198
-
199
- export function profileCorners(input) {
200
- const { kind, regions } = liftProfile(input);
201
- if (kind === "points" || kind === "contour") return withPositions(contourCorners(regions[0].outer));
202
- const out = [];
203
- regions.forEach((rg, regionIndex) => {
204
- for (const c of contourCorners(rg.outer)) out.push({ regionIndex, ring: "outer", ...c });
205
- rg.holes.forEach((h, hi) => { for (const c of contourCorners(h)) out.push({ regionIndex, ring: { hole: hi }, ...c }); });
206
- });
207
- return withPositions(out);
208
- }
209
-
210
104
  // ── Fillet / chamfer ─────────────────────────────────────────────────────────
211
105
  // Resolve opts.corners against a flat corner list (contourCorners()'s order, or
212
106
  // profileCorners()'s flattened order for region/regions input — {indices} always
@@ -280,14 +174,6 @@ const normalize2 = ([x, y]) => { const L = Math.hypot(x, y) || 1; return [x / L,
280
174
  const rot90 = ([x, y]) => [-y, x];
281
175
  const addScaled = (p, v, s) => [p[0] + v[0] * s, p[1] + v[1] * s];
282
176
 
283
- export function cubicAt(p0, c1, c2, p1, t) {
284
- const u = 1 - t;
285
- return [0, 1].map((k) => u * u * u * p0[k] + 3 * u * u * t * c1[k] + 3 * u * t * t * c2[k] + t * t * t * p1[k]);
286
- }
287
- function cubicDeriv(p0, c1, c2, p1, t) {
288
- const u = 1 - t;
289
- return [0, 1].map((k) => 3 * u * u * (c1[k] - p0[k]) + 6 * u * t * (c2[k] - c1[k]) + 3 * t * t * (p1[k] - c2[k]));
290
- }
291
177
  // Exact de Casteljau split of cubic (p0,c1,c2,p1) at t → two exact cubic pieces.
292
178
  export function splitCubic(p0, c1, c2, p1, t) {
293
179
  const lerp = (a, b) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
@@ -3,6 +3,7 @@
3
3
  // package-global project — another consumer in the same worker may import paper too.
4
4
  import paper from "paper/dist/paper-core.js";
5
5
  import { tessellateContour, reverseContour, closeContourGap } from "./profile.js";
6
+ import { arcCenterAndSweep } from "./arc-math.js";
6
7
 
7
8
  const ORIENT_SEGS = 8; // points/segment for the local orientation sampler below
8
9
 
@@ -12,27 +13,9 @@ function paperScope() {
12
13
  return _scope;
13
14
  }
14
15
 
15
- // Circumcircle center + signed sweep for the arc through (p0, via, to) — the sweep is the
16
- // one passing through `via` (sign-free, winding-free), same recovery as profile.js's
17
- // sampleArc. Returns null for a collinear (degenerate) triple. Shared by arcToCubicSegments
18
- // below and contour-ops.js's jointTangents (arc tangents are ⊥ radius, oriented by dA's sign).
19
- export function arcCenterAndSweep(p0, via, to) {
20
- const [ax, ay] = p0, [bx, by] = via, [cx, cy] = to;
21
- const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
22
- if (Math.abs(d) < 1e-12) return null;
23
- const sa = ax*ax + ay*ay, sb = bx*bx + by*by, sc = cx*cx + cy*cy;
24
- const ux = (sa * (by - cy) + sb * (cy - ay) + sc * (ay - by)) / d;
25
- const uy = (sa * (cx - bx) + sb * (ax - cx) + sc * (bx - ax)) / d;
26
- const r = Math.hypot(ax - ux, ay - uy);
27
- const a0 = Math.atan2(ay - uy, ax - ux);
28
- const av = Math.atan2(by - uy, bx - ux);
29
- const a1 = Math.atan2(cy - uy, cx - ux);
30
- const twoPi = 2 * Math.PI;
31
- const ccw = (x) => { let v = x % twoPi; if (v < 0) v += twoPi; return v; };
32
- const dCCW = ccw(a1 - a0), vCCW = ccw(av - a0);
33
- const dA = vCCW <= dCCW ? dCCW : dCCW - twoPi;
34
- return { center: [ux, uy], r, dA };
35
- }
16
+ // arcCenterAndSweep now lives in arc-math.js (paper-free), re-exported here so every
17
+ // existing importer of it from this module keeps working unchanged.
18
+ export { arcCenterAndSweep } from "./arc-math.js";
36
19
 
37
20
  // Circular arc through (p0, via, to) → cubic Bézier segments, ≤90° each, endpoints
38
21
  // exact. Each piece uses the standard k = (4/3)·tan(θ/4) control-point offset.
@@ -245,13 +245,28 @@ export const VERIFY_RULES = [
245
245
  // from this scalar-metric loop. `clearance`'s values are still assertions
246
246
  // though, so they get their own pass (with a pair-shaped path) below.
247
247
  const metrics = target === "_view" ? peelPairKeys(metricsRaw) : metricsRaw;
248
+ const registry = target === "_view" ? VIEW_METRICS : SUBPART_METRICS;
248
249
  for (const [metric, spec] of Object.entries(metrics)) {
249
- try { parseAssertion(exprOf(spec)); }
250
+ let parsed;
251
+ try { parsed = parseAssertion(exprOf(spec)); }
250
252
  catch (e) {
251
253
  out.push(err("verify-bad-expr",
252
254
  `the expectation for ${target}.${metric} is not a valid assertion: ${e?.message || String(e)}`,
253
255
  "Use the assertion DSL: a bare value for equality, a comparison like `>=3`, a range like `2..5`, or a componentwise vector like `<=[60,60,60]` (with `*` to skip an axis).",
254
256
  `verify.expect.${target}.${metric}`));
257
+ continue;
258
+ }
259
+ const form = registry[metric]?.form;
260
+ if (form === "range" && parsed.op !== "range") {
261
+ out.push(err("verify-bad-expr",
262
+ `the expectation for ${target}.${metric} must be a range like "1.8..2.2" — the ${metric} metric needs both ends of its band`,
263
+ `Write ${metric} as \`"<min>..<max>"\` in mm.`,
264
+ `verify.expect.${target}.${metric}`));
265
+ } else if (form === "range" && parsed.op === "range" && parsed.min > parsed.max) {
266
+ out.push(err("verify-bad-expr",
267
+ `the expectation for ${target}.${metric} must be a range with min <= max, got "${exprOf(spec)}"`,
268
+ `Write ${metric} as \`"<min>..<max>"\` with min <= max, in mm.`,
269
+ `verify.expect.${target}.${metric}`));
255
270
  }
256
271
  }
257
272
  }
@@ -6,13 +6,16 @@
6
6
  // would put a coarse reading behind a real gate, which is exactly the trap verify's
7
7
  // seeding block guards against.
8
8
  //
9
- // Every function here is TOTAL. A part whose profile name is unknown, or whose
10
- // `expect` function throws, is reported as GATED — the conservative direction, since
11
- // that is the full-resolution behaviour every part had before budgets existed. The
12
- // real error surfaces from verify, which is where a reader can act on it.
9
+ // Every function here is TOTAL, with one stated exception: `partWallBands` throws
10
+ // on a malformed `wall` range (see there). Elsewhere, a part whose profile name is
11
+ // unknown, or whose `expect` function throws, is reported as GATED — the
12
+ // conservative direction, since that is the full-resolution behaviour every part
13
+ // had before budgets existed. The real error surfaces from verify, which is where
14
+ // a reader can act on it.
13
15
  import { resolveProfile, overhangAngleFor } from "./dfm-profiles.js";
14
16
  import { expandCases } from "./cases.js";
15
17
  import { resolveParams } from "../part-model.js";
18
+ import { parseAssertion } from "./assert-dsl.js";
16
19
 
17
20
  // `expect` may be a function of a case's resolved params, so the answer is a property
18
21
  // of the EXPANDED cases, not of the raw spec — which means answering it costs a call
@@ -52,8 +55,46 @@ export function partGatesMinWall(part, { process, expanded } = {}) {
52
55
  const spec = process ?? part?.verify?.process;
53
56
  if (spec && resolveProfile(spec)?.minWall != null) return true;
54
57
  return (expanded ?? expandExpectations(part)).some(({ expect }) =>
55
- Object.values(expect ?? {}).some((o) => o && typeof o === "object" && "minWall" in o));
58
+ Object.values(expect ?? {}).some((o) => o && typeof o === "object" && ("minWall" in o || "wall" in o)));
56
59
  } catch {
57
60
  return true; // unresolvable → measure it properly and let verify report the error
58
61
  }
59
62
  }
63
+
64
+ // The wall bands a measurement of `params` must track, per sub-part, from the part's
65
+ // own `verify.expect` — resolved for exactly these params (a function expect may
66
+ // change the band per case). Range form only: the membership window needs both ends.
67
+ // Throws on a bad form (a non-range expression, or min > max) so verify reports it
68
+ // where a reader can act — measure's caller (Task 6) lets that throw surface as the
69
+ // measure error it is; this is the one place in this file that is not total.
70
+ // A throw from `spec(p, d)` ITSELF is a different, incidental case — the part's own
71
+ // `expect` function failed for a reason that has nothing to do with wall bands (it
72
+ // read a param that does not exist for this preset, say). That is not this
73
+ // function's throw to report: verify's own `expandExpectations` path calls the same
74
+ // spec and already lets that throw surface from `verify()` — reporting it a second
75
+ // time here, mid-measure, would be a confusing duplicate. Swallow it and answer "no
76
+ // bands", the same as a part that declares none.
77
+ export function partWallBands(part, params = {}) {
78
+ const spec = part?.verify?.expect;
79
+ if (!spec) return {};
80
+ let expect;
81
+ if (typeof spec === "function") {
82
+ const { p, d } = resolveParams(part, params);
83
+ try { expect = spec(p, d) ?? {}; } catch { return {}; }
84
+ } else {
85
+ expect = spec;
86
+ }
87
+ const out = {};
88
+ for (const [name, metrics] of Object.entries(expect)) {
89
+ if (name === "_view" || !metrics || typeof metrics !== "object" || !("wall" in metrics)) continue;
90
+ const raw = metrics.wall;
91
+ const expr = raw && typeof raw === "object" && "expr" in raw ? raw.expr : raw;
92
+ let parsed = null;
93
+ try { parsed = parseAssertion(expr); } catch { parsed = null; }
94
+ if (!parsed || parsed.op !== "range" || parsed.min > parsed.max) {
95
+ throw new Error(`wall expectation for "${name}" must be a range like "1.8..2.2" with min <= max`);
96
+ }
97
+ out[name] = { min: parsed.min, max: parsed.max };
98
+ }
99
+ return out;
100
+ }
@@ -6,7 +6,7 @@ import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
6
6
  import { bounds, meshArea, meshCentroid } from "./mesh.js";
7
7
  import { minWall, DIAGNOSTIC_SAMPLES } from "./min-wall.js";
8
8
  import { overhang } from "./overhang.js";
9
- import { partGatesMinWall, partOverhangAngle } from "./gates.js";
9
+ import { partGatesMinWall, partOverhangAngle, partWallBands } from "./gates.js";
10
10
  import { summarizeContours } from "./shape-probe.js";
11
11
 
12
12
  const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
@@ -143,6 +143,9 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
143
143
  // inward ray per sampled triangle plus the BVH those rays need — and on an
144
144
  // ungated part it buys a fact nobody checks, at full price, on every agent edit.
145
145
  const minWallSamples = partGatesMinWall(part) ? undefined : DIAGNOSTIC_SAMPLES;
146
+ // Declared wall bands, per sub-part, for exactly these params (Task 5). Resolved
147
+ // once per measure; a bad declaration throws here, which is a measure error.
148
+ const wallBands = opts.minWall ? partWallBands(part, params) : {};
146
149
  // Overhang is measured only for a part that opted in (dfm-profiles.js
147
150
  // overhangAngleFor) — everything else reads null. verify hands the angle it
148
151
  // resolved in (a `process` override changes it); `null` there is an explicit
@@ -157,7 +160,9 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
157
160
  subBounds.push(b);
158
161
  // Resolved lazily and only when asked for: without min-wall, a single-sub-part
159
162
  // view (no meshGaps) must still build no index at all.
160
- const mw = opts.minWall ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache), maxSamples: minWallSamples }) : null;
163
+ const mw = opts.minWall
164
+ ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache), maxSamples: minWallSamples, band: wallBands[name] ?? null })
165
+ : null;
161
166
  // One pass over the triangles, no index — cheap enough for every lap. The bed
162
167
  // is this sub-part's own lowest Z, already in hand from bounds(). A sub-part
163
168
  // that is never printed (`exportable: false` — a reference ghost, a probe
@@ -208,6 +213,11 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
208
213
  // `measuredMinWall` false is "never looked".
209
214
  minWallSampled: mw?.sampled ?? false,
210
215
  minWallSamples: mw ? { sampled: mw.sampledTriangles, total: mw.totalTriangles } : null,
216
+ // The declared wall band's worst member (min-wall.js): null unless this
217
+ // sub-part declared `wall` and the pass ran.
218
+ wall: mw?.band && wallBands[name]
219
+ ? { value: mw.band.value, location: mw.band.location, band: wallBands[name], members: mw.band.members }
220
+ : null,
211
221
  // Unsupported downward-facing area in mm² (overhang.js), null when the part
212
222
  // is not laid out for a bed: bridges and bore ceilings count, by design.
213
223
  overhangArea: oh ? oh.area : null,
@@ -65,12 +65,19 @@ function sampleStride(n) {
65
65
  return s;
66
66
  }
67
67
 
68
+ // Membership window for a declared wall band: a ray reading inside
69
+ // [BAND_FLOOR × min, BAND_CEIL × max] belongs to that wall; anything outside is another
70
+ // feature (a thinner floor, a solid boss) and is ignored. Constants, not knobs: a part
71
+ // that needs two bands declares two sub-parts.
72
+ export const BAND_FLOOR = 0.75;
73
+ export const BAND_CEIL = 1.5;
74
+
68
75
  // `bvh` is an already-built index for THIS mesh — one mesh, one index, so there is
69
76
  // nothing to key here: measure() resolves it out of the Map it shares with meshGaps
70
77
  // (see cachedBVH for why that Map is the caller's) and passes the value. Omit it and
71
78
  // one is built. It does not interact with sampling — sampling picks WHICH rays to
72
79
  // cast, not how the index is built, so a shared BVH is equally valid sampled or exact.
73
- export function minWall(mesh, { maxThickness, maxSamples = MAX_SAMPLES, bvh = buildBVH(mesh) } = {}) {
80
+ export function minWall(mesh, { maxThickness, maxSamples = MAX_SAMPLES, bvh = buildBVH(mesh), band = null } = {}) {
74
81
  const n = bvh.triangleCount;
75
82
  if (n === 0) return null;
76
83
  const V = bvh.vertices;
@@ -91,6 +98,19 @@ export function minWall(mesh, { maxThickness, maxSamples = MAX_SAMPLES, bvh = bu
91
98
  const sampled = budget < n;
92
99
  const stride = sampled ? sampleStride(n) : 1; // stride 1 = every triangle, in mesh order
93
100
 
101
+ // Band tracking: the member farthest from the band, or — while every member is
102
+ // inside it — farthest from its midpoint. One comparison per ray, no new rays.
103
+ // All four locals below are meaningless without a band, so they stay undefined
104
+ // on the no-band path rather than holding dead zeroes nothing reads.
105
+ let bandLo, bandHi, bandMid, bandScore;
106
+ if (band) {
107
+ bandLo = BAND_FLOOR * band.min;
108
+ bandHi = BAND_CEIL * band.max;
109
+ bandMid = (band.min + band.max) / 2;
110
+ bandScore = (x) => x > band.max ? 1 + (x - band.max) : x < band.min ? 1 + (band.min - x) : Math.abs(x - bandMid) / (band.max - band.min + 1e-9);
111
+ }
112
+ let bandWorst = -1, bandValue = null, bandLoc = null, members = 0;
113
+
94
114
  let best = Infinity, loc = null, t = 0;
95
115
  const tri = new Float64Array(9); // reused per triangle; no per-ray garbage
96
116
  for (let s = 0; s < budget; s++, t = t + stride < n ? t + stride : t + stride - n) {
@@ -107,9 +127,17 @@ export function minWall(mesh, { maxThickness, maxSamples = MAX_SAMPLES, bvh = bu
107
127
  const origin = [c[0] + dir[0] * 1e-4, c[1] + dir[1] * 1e-4, c[2] + dir[2] * 1e-4];
108
128
  const hit = bvh.raycast(origin, dir, { tMax: maxThickness, skipTri: t });
109
129
  if (hit && hit.t < best) { best = hit.t; loc = c; }
130
+ if (band && hit && hit.t >= bandLo && hit.t <= bandHi) {
131
+ members++;
132
+ const score = bandScore(hit.t);
133
+ if (score > bandWorst) { bandWorst = score; bandValue = hit.t; bandLoc = c; }
134
+ }
110
135
  }
111
136
  // No hit anywhere still reports HOW it looked (see the header): a `value: null`
112
137
  // with the sampling accounting intact, never a bare null that reads downstream as
113
138
  // "min wall was never measured".
114
- return { value: best === Infinity ? null : best, location: loc, sampled, sampledTriangles: budget, totalTriangles: n };
139
+ return {
140
+ value: best === Infinity ? null : best, location: loc, sampled, sampledTriangles: budget, totalTriangles: n,
141
+ band: band ? { value: bandValue, location: bandLoc, members } : null,
142
+ };
115
143
  }
@@ -40,8 +40,8 @@
40
40
  // out at rings[] → ring → arcs[] → arc → center[] — 4 containers below the summary
41
41
  // object itself (5 including it), against the cloud's `DESCRIBE_MAX_DEPTH = 8`, with
42
42
  // headroom even wrapped in the paired-probe `{pair: {mine, ref}}` form.
43
- import { arcCenterAndSweep } from "../geometry/paper-bridge.js";
44
- import { cubicAt, profileCorners } from "../geometry/contour-ops.js";
43
+ import { arcCenterAndSweep } from "../geometry/arc-math.js";
44
+ import { cubicAt, profileCorners } from "../geometry/contour-corners.js";
45
45
 
46
46
  export const MAX_ARCS = 64;
47
47
  export const MAX_CORNERS = 64;
@@ -134,6 +134,21 @@ function check(scope, subpart, metric, spec, registry, factsObj) {
134
134
  if (note) out.note = note;
135
135
  return out;
136
136
  }
137
+ // `reg.unavailable` marks a metric whose "no reading" is a real, actionable
138
+ // finding rather than an inert skip — today only `wall`: a declared band with
139
+ // zero members means the wall the part is tracking may have drifted away from
140
+ // the window entirely, which is exactly the case a silent skip would hide (a
141
+ // 2 mm wall collapsed to 0.9 mm under "1.8..2.2" has no members, and `ok` must
142
+ // not stay true for that). Data-driven on the registry entry, not a second
143
+ // `metric === "wall"` literal, so a future metric opts in the same way minWall
144
+ // did before it had its own branch.
145
+ if (reg.unavailable) {
146
+ const out = { ...base, actual, status: "warn", pass: null, message: reg.unavailable,
147
+ 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" };
148
+ const note = reg.note?.(factsObj);
149
+ if (note) out.note = note;
150
+ return out;
151
+ }
137
152
  return { ...base, actual, status: "skip", pass: null, message: "unavailable" };
138
153
  }
139
154
  const { pass, message } = evaluateAssertion(parseAssertion(expr), actual);
@@ -185,9 +200,9 @@ export function evaluateCase(facts, { profile, expect, subPartNames, overhang =
185
200
  };
186
201
  for (const [metric, expr] of Object.entries(merged)) {
187
202
  const c = check("subpart", s.name, metric, expr, SUBPART_METRICS, s);
188
- if (minWallSkipped && metric === "minWall" && c.actual == null) {
203
+ if (minWallSkipped && (metric === "minWall" || metric === "wall") && c.actual == null) {
189
204
  checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
190
- hint: "re-run this check without `quick` to measure min wall" });
205
+ hint: `re-run this check without \`quick\` to measure ${metric === "wall" ? "the wall band" : "min wall"}` });
191
206
  continue;
192
207
  }
193
208
  if (overhangSkipped && metric === "overhangArea" && c.actual == null) {
@@ -58,6 +58,20 @@ export const SUBPART_METRICS = {
58
58
  // "<import name>"`; on every other sub-part `extract` returns null, which
59
59
  // `check()` already reports as status "skip" rather than a fail — the
60
60
  // no-reference path needs nothing here.
61
+ // The declared wall band's worst member (min-wall.js `band`): the member thickness
62
+ // farthest from the range the part declared, located. Range form only — `form` is
63
+ // read by the linter's verify-bad-expr rule only. A warning like minWall,
64
+ // because it is a sampled ray reading: a sample can miss the widest spot, never
65
+ // invent one. `unavailable` is the skip message when no ray fell in the window.
66
+ wall: { kind: "warn", form: "range", extract: (s) => s.wall?.value ?? null,
67
+ hint: "wall thickness drifts from the declared band at the reported location — a fillet radius, an offset or a boolean tool there is not tracking the wall",
68
+ locate: (s) => s.wall?.location ?? null,
69
+ unavailable: "no wall in band",
70
+ note: (s) => {
71
+ if (!s.wall) return null;
72
+ const sampled = s.minWallSampled && s.minWallSamples ? ` (sampled ${s.minWallSamples.sampled} of ${s.minWallSamples.total} triangles)` : "";
73
+ return `${s.wall.members} rays read as this wall${sampled}`;
74
+ } },
61
75
  refXorVolume: { kind: "gate", extract: (s) => s.deviation?.xorVolume ?? null,
62
76
  hint: "the rebuild's symmetric difference vs its reference import is too large — compare the ghost overlay, then adjust the governing dimensions toward the measured reference" },
63
77
  refVolumeDeltaPct: { kind: "gate", extract: (s) => s.deviation?.volumeDeltaPct ?? null,
package/types/part.d.ts CHANGED
@@ -457,6 +457,13 @@ export interface SubPartExpectations {
457
457
  boundsMin?: Expectation;
458
458
  boundsMax?: Expectation;
459
459
  minWall?: Expectation;
460
+ /**
461
+ * A wall that must stay one thickness, as a range in mm (`"1.8..2.2"`; range
462
+ * form only). Rides the same inward rays as `minWall` — a ray reading inside
463
+ * `[0.75 × min, 1.5 × max]` counts as this wall — and warns when the member
464
+ * farthest from the band lies outside it. A warning, never a gate.
465
+ */
466
+ wall?: Expectation;
460
467
  /**
461
468
  * Unsupported downward-facing surface in mm² (faces steeper than the profile's
462
469
  * `overhang` angle, bed at the sub-part's own lowest Z). Measured only under
@@ -283,6 +283,15 @@ export interface SubPartFacts {
283
283
  minWallAt: number[] | null;
284
284
  minWallSampled: boolean;
285
285
  minWallSamples: { sampled: number; total: number } | null;
286
+ /**
287
+ * The declared wall band's worst member: the sampled thickness farthest from the
288
+ * range the part's `verify.expect.<name>.wall` declared (or, when every member is
289
+ * inside it, farthest from its midpoint), where it was read, the band, and how many
290
+ * rays fell in the membership window `[0.75 × min, 1.5 × max]`. `null` when the
291
+ * sub-part declares no band or min wall was not measured; `value` null when no ray
292
+ * read as this wall.
293
+ */
294
+ wall: { value: number | null; location: number[] | null; band: { min: number; max: number }; members: number } | null;
286
295
  /**
287
296
  * Unsupported downward-facing area in mm² (oracle/overhang.js), `null` when the
288
297
  * part is not laid out for a bed (`verify.orientation: "print"` under a