partforge 0.54.0 → 0.55.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.54.0",
3
+ "version": "0.55.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",
@@ -9,6 +9,9 @@ import { safeName } from "./safe-name.js";
9
9
  import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
10
10
  import { measure } from "./oracle/measure.js";
11
11
  import { verify } from "./oracle/verify.js";
12
+ import { buildView } from "./oracle/build.js";
13
+ import { MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask } from "./oracle/silhouette.js";
14
+ import { matchViews } from "./oracle/match.js";
12
15
 
13
16
  // Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
14
17
  // Backend-agnostic and part-agnostic: every part specific comes through `part`.
@@ -26,6 +29,67 @@ import { verify } from "./oracle/verify.js";
26
29
  // preview generates stay quiet (no callback) to avoid flicker during slider drags.
27
30
  const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
28
31
 
32
+ // One caller-supplied match target as a reference mask, or null when it is not one we
33
+ // can score. Every field here is untrusted wire data, so shape is checked rather than
34
+ // assumed — a target the caller got wrong is skipped, never thrown, so one bad entry
35
+ // cannot cost the others their scores (or the caller their geometry report).
36
+ // {kind: "profile", rings: [[[x,y], ...], ...]} — millimetres, so it carries scale
37
+ // {kind: "image", mask: {data, width, height}} — a photo, so it carries none
38
+ function referenceMask(target) {
39
+ if (target?.kind === "profile") return Array.isArray(target.rings) ? rasterizeRingsMask(target.rings) : null;
40
+ if (target?.kind === "image") {
41
+ const m = target.mask;
42
+ if (!m?.data || !(m.width > 0) || !(m.height > 0)) return null;
43
+ // mmPerPx is deliberately absent: a photograph has no millimetres, which is what
44
+ // keeps the scale-aware comparison off for an image target no matter what is asked.
45
+ return { data: m.data, width: m.width, height: m.height };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ // Score the part's six canonical silhouettes against each match target.
51
+ // `built` is the view's already-built sub-parts — the meshes are JS-owned, so this
52
+ // reads correctly after the kernel has been cleaned up.
53
+ //
54
+ // Total by construction: match scoring is an EXTRA on top of the geometric report, so
55
+ // any failure here omits `match` and leaves the report itself intact. A caller who
56
+ // asked for a score and got none can ask again; a caller who lost their measurement
57
+ // because a mask blew up has lost the thing they actually came for.
58
+ //
59
+ // The six mesh masks are rasterized ONCE and shared across every target — the targets
60
+ // are the cheap side of this (a couple of reference masks), the part is not.
61
+ function scoreMatchTargets(built, targets, onProgress) {
62
+ if (!targets?.length) return null;
63
+ try {
64
+ const meshes = built.map((b) => b.mesh);
65
+ const viewMasks = {};
66
+ for (const view of MATCH_VIEWS) viewMasks[view] = rasterizeMeshMask(meshes, view);
67
+
68
+ const out = [];
69
+ for (const target of targets) {
70
+ try {
71
+ const reference = referenceMask(target);
72
+ if (!reference) continue;
73
+ // scaleAware is the CALLER's promise that both sides are in millimetres, and
74
+ // this is the caller: rings are mm and the mesh masks carry mmPerPx, so a
75
+ // profile target gets the absolute-size score (`iouScale`, contourDist in mm)
76
+ // while an image target gets the pose-normalized one.
77
+ const scoreOpts = { scaleAware: target.kind === "profile" };
78
+ const { best, views } = matchViews(viewMasks, reference, scoreOpts);
79
+ if (!best) continue; // nothing scoreable — a dropped target, never a zero score
80
+ const { delta, ...scores } = best;
81
+ out.push({ kind: target.kind, best: scores, views, delta: { view: best.view, ...delta } });
82
+ } catch (err) {
83
+ onProgress(`match target skipped: ${String(err?.message || err)}`);
84
+ }
85
+ }
86
+ return out.length ? out : null;
87
+ } catch (err) {
88
+ onProgress(`match scoring skipped: ${String(err?.message || err)}`);
89
+ return null;
90
+ }
91
+ }
92
+
29
93
  export async function handle(kernel, part, msg, post, opts = {}) {
30
94
  const isStale = opts.isStale ?? (() => false);
31
95
  const onProgress = (phase) => post({ type: "progress", phase, jobId: msg.jobId });
@@ -147,15 +211,37 @@ export async function handle(kernel, part, msg, post, opts = {}) {
147
211
  // true }` here is what makes the seed usable by any verify run, min-wall
148
212
  // gated or not — the result says so itself (`measuredMinWall`), so this
149
213
  // call and the seed cannot drift apart.
150
- const measured = measure(kernel, part, msg.view, msg.params ?? {}, { minWall: true });
214
+ //
215
+ // The view is built HERE rather than inside measure, and handed down through
216
+ // `opts.built`, because optional match scoring needs the same meshes: one build
217
+ // feeds the measurement and the six silhouette rasterizations both.
218
+ //
219
+ // The default matters and must match measure()'s own: buildView has NO view
220
+ // default — viewSubParts(part, undefined) returns [] without erroring — so an
221
+ // inspect with no `view` (partforge-cloud's requestReport has always sent
222
+ // undefined, meaning "the current view") built an EMPTY view here, and every
223
+ // downstream consumer degraded silently: measure reported zero subparts and a
224
+ // [0,0,0] bbox, match scored nothing, and verify still passed. Pre-0.55,
225
+ // measure built internally and its own signature default hid this. Found by a
226
+ // live browser check, not by tests: this suite passes explicit views, and the
227
+ // cloud's unit tests fake the worker.
228
+ const view = msg.view ?? Object.keys(part.views)[0];
229
+ const built = buildView(kernel, part, view, msg.params ?? {});
230
+ const measured = measure(kernel, part, view, msg.params ?? {}, { minWall: true, built });
151
231
  const report = {
152
232
  measure: measured,
153
233
  verify: verify(kernel, part, {
154
- view: msg.view,
234
+ // The defaulted view, not msg.view: the seed below was measured on it, and
235
+ // verify's seed reuse is only sound when both name the same view.
236
+ view,
155
237
  seed: { params: msg.params ?? {}, result: measured },
156
238
  }),
157
239
  };
158
- post({ type: "report", ...report });
240
+ // `match` is present only when the caller asked for it AND something scored, so
241
+ // an inspect with no `matchTargets` answers on exactly the shape it always has.
242
+ const match = scoreMatchTargets(built, msg.matchTargets, onProgress);
243
+ if (match) report.match = match;
244
+ post({ type: "report", ...report }, match?.map((m) => m.delta.data.buffer) ?? []);
159
245
  }
160
246
  } catch (err) {
161
247
  if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt", jobId: msg.jobId });
@@ -0,0 +1,263 @@
1
+ // Mask comparison: score a candidate silhouette against a reference one. Consumes the
2
+ // masks oracle/silhouette.js produces and answers "how close is this shape?" four ways —
3
+ // area overlap (`iou`), rim overlap (`boundaryIoU`), a mean contour separation
4
+ // (`contourDist`), and a per-pixel `delta` map naming what is missing versus extra. No
5
+ // kernel, no DOM, no three, no `node:`, so it runs in the geometry worker like the rest
6
+ // of the oracle.
7
+ //
8
+ // Shape is compared POSE-NORMALIZED: each mask is cropped to its tight foreground bbox,
9
+ // scaled so its longest side fills 0.92 of a 256² frame, and centroid-aligned there. So
10
+ // `iou`, `boundaryIoU`, and `delta` are invariant to where the part sits and how big it
11
+ // is, which is what "does it look like the picture?" means. Absolute size is a separate
12
+ // question and only `{scaleAware: true}` asks it — and only when BOTH masks carry a
13
+ // finite mmPerPx: `iouScale` overlays them on the reference's own mm grid,
14
+ // centroid-aligned and never rescaled, so a part twice too big scores 0.25, and
15
+ // `contourDist` is then a real millimetre distance instead of a fraction of the
16
+ // reference's bbox diagonal.
17
+ //
18
+ // A mask with no foreground pixels — or no mask at all — is UNSCOREABLE, not
19
+ // zero-scoring: matchMasks returns null and matchViews leaves the view out. 0/0 is
20
+ // never a score.
21
+
22
+ import { MATCH_VIEWS } from "./silhouette.js";
23
+
24
+ const S = 256; // internal normalization frame, whatever the input mask sizes
25
+ const FILL = 0.92; // fraction of the frame the longest bbox side occupies
26
+ const BAND_PX = 2; // boundary band thickness, per the Boundary IoU definition
27
+ const BIG = 1e20; // "unreachable" seed for the distance transform's lower envelope
28
+ const MAX_MM_FRAME = 2048; // px ceiling on the scale-aware grid; see mmFrame
29
+
30
+ // candidate/reference: Task 1 masks. opts: {scaleAware}. → null when either is unscoreable.
31
+ export function matchMasks(candidate, reference, opts = {}) {
32
+ const cs = stats(candidate), rs = stats(reference);
33
+ if (!cs || !rs) return null;
34
+
35
+ const nc = normalize(cs), nr = normalize(rs);
36
+ const iou = maskIoU(nc, nr);
37
+ const boundaryIoU = maskIoU(band(nc), band(nr));
38
+ const delta = deltaMap(nc, nr);
39
+
40
+ const scaleAware = opts.scaleAware === true && scaled(candidate) && scaled(reference);
41
+ let contourDist, contourUnit, iouScale;
42
+ if (scaleAware) {
43
+ const [sc, sr, pitch] = mmFrame(cs, candidate.mmPerPx, rs, reference.mmPerPx);
44
+ iouScale = maskIoU(sc, sr);
45
+ contourDist = contourDistance(sc, sr) * pitch;
46
+ contourUnit = "mm";
47
+ } else {
48
+ const diag = Math.hypot(nr.bw, nr.bh);
49
+ contourDist = (contourDistance(nc, nr) / diag) * 100;
50
+ contourUnit = "%bbox-diag";
51
+ }
52
+
53
+ const out = { iou, boundaryIoU, contourDist, contourUnit, delta };
54
+ if (scaleAware) out.iouScale = iouScale;
55
+ return out;
56
+ }
57
+
58
+ // viewMasks: {front: mask|null, ...} → {best: {view, ...scores}|null, views: {view: iou}}.
59
+ // Views are walked in MATCH_VIEWS order, so a tie on `iou` resolves to the earlier view
60
+ // regardless of the object's own key order.
61
+ export function matchViews(viewMasks, reference, opts = {}) {
62
+ const views = {};
63
+ let best = null;
64
+ for (const view of MATCH_VIEWS) {
65
+ const scores = matchMasks(viewMasks?.[view], reference, opts);
66
+ if (!scores) continue;
67
+ views[view] = scores.iou;
68
+ if (!best || scores.iou > best.iou) best = { view, ...scores };
69
+ }
70
+ return { best, views };
71
+ }
72
+
73
+ // Squared-then-rooted Euclidean distance (in px) from every pixel to the nearest non-zero
74
+ // pixel of `data`, by Felzenszwalb–Huttenlocher: a 1D lower-envelope pass over rows, then
75
+ // over columns. With no non-zero pixel at all every entry comes back astronomically large
76
+ // rather than infinite, which keeps the envelope's arithmetic finite.
77
+ export function distanceTransform(data, width, height) {
78
+ const n = width * height;
79
+ const sq = new Float64Array(n);
80
+ for (let i = 0; i < n; i++) sq[i] = data[i] ? 0 : BIG;
81
+
82
+ const m = Math.max(width, height);
83
+ const f = new Float64Array(m), d = new Float64Array(m);
84
+ const v = new Int32Array(m), z = new Float64Array(m + 1);
85
+ for (let r = 0; r < height; r++) {
86
+ for (let c = 0; c < width; c++) f[c] = sq[r * width + c];
87
+ envelope(f, width, d, v, z);
88
+ for (let c = 0; c < width; c++) sq[r * width + c] = d[c];
89
+ }
90
+ for (let c = 0; c < width; c++) {
91
+ for (let r = 0; r < height; r++) f[r] = sq[r * width + c];
92
+ envelope(f, height, d, v, z);
93
+ for (let r = 0; r < height; r++) sq[r * width + c] = d[r];
94
+ }
95
+
96
+ const out = new Float32Array(n);
97
+ for (let i = 0; i < n; i++) out[i] = Math.sqrt(sq[i]);
98
+ return out;
99
+ }
100
+
101
+ // Lower envelope of the parabolas (q - x)² + f[q]: `v` holds the parabolas in the
102
+ // envelope and `z` the boundaries between them. d[] comes back as squared distances.
103
+ function envelope(f, n, d, v, z) {
104
+ let k = 0;
105
+ v[0] = 0; z[0] = -Infinity; z[1] = Infinity;
106
+ for (let q = 1; q < n; q++) {
107
+ let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
108
+ while (s <= z[k]) {
109
+ k--;
110
+ s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
111
+ }
112
+ k++; v[k] = q; z[k] = s; z[k + 1] = Infinity;
113
+ }
114
+ k = 0;
115
+ for (let q = 0; q < n; q++) {
116
+ while (z[k + 1] < q) k++;
117
+ d[q] = (q - v[k]) * (q - v[k]) + f[v[k]];
118
+ }
119
+ }
120
+
121
+ const scaled = (mask) => Number.isFinite(mask?.mmPerPx) && mask.mmPerPx > 0;
122
+
123
+ // Foreground bbox, centroid, and pixel count — null when there is nothing to score.
124
+ function stats(mask) {
125
+ const data = mask?.data, w = mask?.width | 0, h = mask?.height | 0;
126
+ if (!data || !(w > 0) || !(h > 0) || data.length < w * h) return null;
127
+ let c0 = w, c1 = -1, r0 = h, r1 = -1, n = 0, sc = 0, sr = 0;
128
+ for (let r = 0; r < h; r++) for (let c = 0; c < w; c++) {
129
+ if (!data[r * w + c]) continue;
130
+ n++; sc += c; sr += r;
131
+ if (c < c0) c0 = c;
132
+ if (c > c1) c1 = c;
133
+ if (r < r0) r0 = r;
134
+ if (r > r1) r1 = r;
135
+ }
136
+ return n ? { data, w, h, c0, c1, r0, r1, cx: sc / n, cy: sr / n, count: n } : null;
137
+ }
138
+
139
+ // Crop to the bbox, scale the longer side to FILL*S, centroid-align into the S² frame,
140
+ // nearest-neighbour. Sampling is done backwards from the output so the two masks share
141
+ // one mapping form and identical shapes at different scales land on identical pixels.
142
+ //
143
+ // The scale is bbox-derived while the alignment is centroid-derived, so a shape whose
144
+ // centroid sits far from its bbox centre (an L) would run past the frame edge and be
145
+ // silently clipped. The guard shrinks such a shape to fit; it is a function of the
146
+ // shape's own proportions, so two copies of it at different scales still normalize
147
+ // identically and still score 1.
148
+ function normalize(st) {
149
+ const bw = st.c1 - st.c0 + 1, bh = st.r1 - st.r0 + 1;
150
+ let s = (FILL * S) / Math.max(bw, bh);
151
+ const reach = Math.max(st.cx - st.c0, st.c1 - st.cx, st.cy - st.r0, st.r1 - st.cy) + 0.5;
152
+ const room = (S - 2) / 2;
153
+ if (reach * s > room) s = room / reach;
154
+
155
+ const mid = (S - 1) / 2;
156
+ const data = new Uint8Array(S * S);
157
+ for (let R = 0; R < S; R++) {
158
+ const r = Math.round((R - mid) / s + st.cy);
159
+ if (r < st.r0 || r > st.r1) continue;
160
+ for (let C = 0; C < S; C++) {
161
+ const c = Math.round((C - mid) / s + st.cx);
162
+ if (c < st.c0 || c > st.c1) continue;
163
+ if (st.data[r * st.w + c]) data[R * S + C] = 1;
164
+ }
165
+ }
166
+ return { data, width: S, height: S, bw: bw * s, bh: bh * s };
167
+ }
168
+
169
+ // Both masks resampled onto ONE grid at the reference's mmPerPx, centroid-aligned and
170
+ // never rescaled — the frame where absolute size is the question. Sized to hold both, so
171
+ // an oversized candidate is measured rather than cropped by the reference's own extent.
172
+ // Returns the two masks and the grid's pitch in mm/px, which is the reference's own
173
+ // except when a wildly oversized candidate would need a frame past MAX_MM_FRAME: then the
174
+ // grid COARSENS rather than crops, since a truncated union would flatter the candidate.
175
+ function mmFrame(cs, cmm, rs, rmm) {
176
+ // Half-extents in mm from each mask's own centroid, unioned so neither is cropped.
177
+ const span = (st, mm) => [
178
+ (st.cx - st.c0) * mm + mm / 2, (st.c1 - st.cx) * mm + mm / 2,
179
+ (st.cy - st.r0) * mm + mm / 2, (st.r1 - st.cy) * mm + mm / 2,
180
+ ];
181
+ const other = span(rs, rmm);
182
+ const [left, right, up, down] = span(cs, cmm).map((v, i) => Math.max(v, other[i]));
183
+
184
+ const longest = Math.max(left + right, up + down);
185
+ const p = Math.max(rmm, longest / MAX_MM_FRAME);
186
+ const gx = Math.ceil(left / p) + 1, gy = Math.ceil(up / p) + 1;
187
+ const width = gx + Math.ceil(right / p) + 2, height = gy + Math.ceil(down / p) + 2;
188
+
189
+ const draw = (st, mm) => {
190
+ const data = new Uint8Array(width * height);
191
+ for (let R = 0; R < height; R++) {
192
+ const r = Math.round(((R - gy) * p) / mm + st.cy);
193
+ if (r < st.r0 || r > st.r1) continue;
194
+ for (let C = 0; C < width; C++) {
195
+ const c = Math.round(((C - gx) * p) / mm + st.cx);
196
+ if (c < st.c0 || c > st.c1) continue;
197
+ if (st.data[r * st.w + c]) data[R * width + C] = 1;
198
+ }
199
+ }
200
+ return { data, width, height };
201
+ };
202
+ return [draw(cs, cmm), draw(rs, rmm), p];
203
+ }
204
+
205
+ function maskIoU(a, b) {
206
+ let inter = 0, union = 0;
207
+ for (let i = 0; i < a.data.length; i++) {
208
+ const x = a.data[i], y = b.data[i];
209
+ if (x && y) inter++;
210
+ if (x || y) union++;
211
+ }
212
+ return union ? inter / union : 0;
213
+ }
214
+
215
+ // Boundary IoU's band: the mask minus its own erosion by BAND_PX. Erosion by a disk is
216
+ // the distance transform of the background, so the band is "foreground within BAND_PX of
217
+ // some background pixel".
218
+ function band(m) {
219
+ const inv = new Uint8Array(m.data.length);
220
+ for (let i = 0; i < inv.length; i++) inv[i] = m.data[i] ? 0 : 1;
221
+ const dt = distanceTransform(inv, m.width, m.height);
222
+ const data = new Uint8Array(m.data.length);
223
+ for (let i = 0; i < data.length; i++) if (m.data[i] && dt[i] <= BAND_PX) data[i] = 1;
224
+ return { data, width: m.width, height: m.height };
225
+ }
226
+
227
+ // Contour = foreground with at least one background 4-neighbour; outside the image counts
228
+ // as background, so a shape flush with the edge still has one.
229
+ function contour(m) {
230
+ const { data, width, height } = m;
231
+ const out = new Uint8Array(data.length);
232
+ for (let r = 0; r < height; r++) for (let c = 0; c < width; c++) {
233
+ const i = r * width + c;
234
+ if (!data[i]) continue;
235
+ if (r === 0 || r === height - 1 || c === 0 || c === width - 1
236
+ || !data[i - width] || !data[i + width] || !data[i - 1] || !data[i + 1]) out[i] = 1;
237
+ }
238
+ return out;
239
+ }
240
+
241
+ // Symmetric mean contour separation in px: mean distance from each mask's contour to the
242
+ // other's, averaged both directions so neither shape's rim length dominates.
243
+ function contourDistance(a, b) {
244
+ const ca = contour(a), cb = contour(b);
245
+ const da = distanceTransform(ca, a.width, a.height);
246
+ const db = distanceTransform(cb, b.width, b.height);
247
+ const mean = (pts, dt) => {
248
+ let n = 0, sum = 0;
249
+ for (let i = 0; i < pts.length; i++) if (pts[i]) { n++; sum += dt[i]; }
250
+ return n ? sum / n : 0;
251
+ };
252
+ return (mean(cb, da) + mean(ca, db)) / 2;
253
+ }
254
+
255
+ // 0 = background, 1 = overlap, 2 = missing (reference only), 3 = excess (candidate only).
256
+ function deltaMap(c, r) {
257
+ const data = new Uint8Array(c.data.length);
258
+ for (let i = 0; i < data.length; i++) {
259
+ const inC = c.data[i], inR = r.data[i];
260
+ data[i] = inC && inR ? 1 : inR ? 2 : inC ? 3 : 0;
261
+ }
262
+ return { width: c.width, height: c.height, data };
263
+ }
@@ -19,7 +19,11 @@ const unionBounds = (list) => list.reduce(
19
19
  // → { part, view, measuredMinWall, subparts[], aggregate, overlaps[], gaps[],
20
20
  // nearMisses[], ok }
21
21
  export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}, opts = {}) {
22
- const built = buildView(kernel, part, view, params);
22
+ // `opts.built` is a build of this view the caller already has. The inspect job
23
+ // needs those meshes anyway — it rasterizes them for silhouette match scoring —
24
+ // and a second buildView here would be a whole duplicate build of the part for
25
+ // nothing. Absent, this measures its own build exactly as it always did.
26
+ const built = opts.built ?? buildView(kernel, part, view, params);
23
27
  // ONE BVH per sub-part mesh for this call, shared by the two passes that need
24
28
  // one: min-wall (inward rays per triangle) and meshGaps (pair distances). They
25
29
  // used to index the same mesh objects independently, so every sub-part of a
@@ -0,0 +1,146 @@
1
+ // Orthographic silhouette masks: project a part (or a set of 2D rings) onto one of the
2
+ // six canonical views and scanline-fill it into a binary image. The foundation of
3
+ // silhouette match scoring — no kernel, no DOM, no three, no `node:`, so it runs in the
4
+ // geometry worker alongside the rest of the oracle.
5
+ //
6
+ // Contract: {data: Uint8Array of 0|255, width, height, mmPerPx, minX, minY}. Row 0 is
7
+ // the TOP of the image; minX/minY are the projected-plane coordinates of the image's
8
+ // BOTTOM-LEFT corner, so a caller maps a pixel back with
9
+ // x = minX + (col + 0.5) * mmPerPx, y = minY + (height - 0.5 - row) * mmPerPx
10
+ // Nothing to draw, or a projection with zero extent, returns null.
11
+
12
+ export const MATCH_VIEWS = ["front", "back", "top", "bottom", "left", "right"];
13
+
14
+ // Image axes per view as [modelAxisIndex, sign] in MODEL space (x,y,z) — the third axis
15
+ // is dropped. Derived from the viewer's single pivot rotation (`pivot.rotation.x =
16
+ // -Math.PI/2` in viewer.js, taking model Z-up to world Y-up) composed with each view's
17
+ // camera basis in view-angles.js. A wrong sign here mirrors every downstream match
18
+ // score without failing anything else, so the table is written out rather than derived.
19
+ const VIEW_AXES = {
20
+ front: { x: [0, 1], y: [2, 1] }, // drops Y
21
+ back: { x: [0, -1], y: [2, 1] }, // drops Y
22
+ right: { x: [1, 1], y: [2, 1] }, // drops X
23
+ left: { x: [1, -1], y: [2, 1] }, // drops X
24
+ top: { x: [0, 1], y: [1, 1] }, // drops Z
25
+ bottom: { x: [0, 1], y: [1, -1] }, // drops Z
26
+ };
27
+
28
+ const PAD = 0.04; // blank fraction of the frame on each side
29
+ const FILL = 1 - 2 * PAD; // 0.92 — the fraction the content's longest extent occupies
30
+
31
+ // meshes: [{positions: Float32Array|number[], indices?: Uint32Array|number[]}] — `indices`
32
+ // optional, flat triangle soup when absent (3 verts per triangle), same as oracle/mesh.js.
33
+ export function rasterizeMeshMask(meshes, view, size = 256) {
34
+ const axes = VIEW_AXES[view];
35
+ if (!axes) throw new Error(`unknown match view "${view}"`);
36
+ const [ax, sx] = axes.x, [ay, sy] = axes.y;
37
+ const groups = [];
38
+ for (const mesh of meshes || []) {
39
+ const P = mesh?.positions;
40
+ if (!P || P.length < 9) continue;
41
+ const idx = mesh.indices;
42
+ const n = idx ? idx.length : P.length / 3;
43
+ for (let i = 0; i + 3 <= n; i += 3) {
44
+ const tri = new Float64Array(6);
45
+ let ok = true;
46
+ for (let k = 0; k < 3 && ok; k++) {
47
+ const base = (idx ? idx[i + k] : i + k) * 3;
48
+ const x = P[base + ax] * sx, y = P[base + ay] * sy;
49
+ if (Number.isFinite(x) && Number.isFinite(y)) { tri[k * 2] = x; tri[k * 2 + 1] = y; }
50
+ else ok = false;
51
+ }
52
+ if (ok) groups.push([tri]); // one triangle = one even-odd group; see fillPolygons
53
+ }
54
+ }
55
+ return fillPolygons(groups, size);
56
+ }
57
+
58
+ // rings: [[[x,y], ...], ...] in mm. All rings share ONE even-odd group, so a ring inside
59
+ // another is a hole.
60
+ export function rasterizeRingsMask(rings, size = 256) {
61
+ const group = [];
62
+ for (const ring of rings || []) {
63
+ if (!ring || ring.length < 3) continue;
64
+ const flat = new Float64Array(ring.length * 2);
65
+ let ok = true;
66
+ for (let i = 0; i < ring.length && ok; i++) {
67
+ const x = ring[i]?.[0], y = ring[i]?.[1];
68
+ if (Number.isFinite(x) && Number.isFinite(y)) { flat[i * 2] = x; flat[i * 2 + 1] = y; }
69
+ else ok = false;
70
+ }
71
+ if (ok) group.push(flat);
72
+ }
73
+ return group.length ? fillPolygons([group], size) : null;
74
+ }
75
+
76
+ // A GROUP is one even-odd polygon set, given as rings of flat [x0,y0,x1,y1,...] pairs.
77
+ // Groups UNION with each other, which is the whole reason for the grouping: a closed mesh
78
+ // projects its front and back faces onto the same pixels, and even-odd across the whole
79
+ // soup would cancel them into background. So each triangle is its own group and a ring
80
+ // set is one group, and holes still subtract.
81
+ function fillPolygons(groups, size) {
82
+ if (!groups.length) return null;
83
+ let loX = Infinity, loY = Infinity, hiX = -Infinity, hiY = -Infinity;
84
+ for (const g of groups) for (const ring of g) for (let i = 0; i < ring.length; i += 2) {
85
+ if (ring[i] < loX) loX = ring[i];
86
+ if (ring[i] > hiX) hiX = ring[i];
87
+ if (ring[i + 1] < loY) loY = ring[i + 1];
88
+ if (ring[i + 1] > hiY) hiY = ring[i + 1];
89
+ }
90
+ const extent = Math.max(hiX - loX, hiY - loY);
91
+ if (!(extent > 0)) return null;
92
+
93
+ // Uniform scale, tight bbox, PAD on each side, centred in a square frame.
94
+ const mmPerPx = extent / (FILL * size);
95
+ const span = size * mmPerPx;
96
+ const minX = (loX + hiX) / 2 - span / 2, minY = (loY + hiY) / 2 - span / 2;
97
+ const rowOf = (y) => size - 0.5 - (y - minY) / mmPerPx; // row 0 = top: y is flipped here
98
+
99
+ // Bucket each group by its first row and drop it once past its last, so a scanline only
100
+ // walks the groups that can cross it (a mesh soup is thousands of tiny groups).
101
+ const starts = Array.from({ length: size }, () => []);
102
+ const lastRow = new Int32Array(groups.length);
103
+ for (let g = 0; g < groups.length; g++) {
104
+ let gLo = Infinity, gHi = -Infinity;
105
+ for (const ring of groups[g]) for (let i = 1; i < ring.length; i += 2) {
106
+ if (ring[i] < gLo) gLo = ring[i];
107
+ if (ring[i] > gHi) gHi = ring[i];
108
+ }
109
+ const r0 = Math.min(size - 1, Math.max(0, Math.floor(rowOf(gHi))));
110
+ lastRow[g] = Math.min(size - 1, Math.max(0, Math.ceil(rowOf(gLo))));
111
+ starts[r0].push(g);
112
+ }
113
+
114
+ const data = new Uint8Array(size * size);
115
+ const xs = [];
116
+ let active = [];
117
+ for (let r = 0; r < size; r++) {
118
+ if (starts[r].length) active = active.concat(starts[r]);
119
+ if (!active.length) continue;
120
+ active = active.filter((g) => lastRow[g] >= r);
121
+ const y = minY + (size - 0.5 - r) * mmPerPx;
122
+ for (const g of active) {
123
+ xs.length = 0;
124
+ for (const ring of groups[g]) {
125
+ const n = ring.length;
126
+ // Half-open crossing test (y0 <= y) !== (y1 <= y): a vertex sitting exactly on the
127
+ // scanline is counted once, not twice, so parity stays sane.
128
+ for (let i = 0, j = n - 2; i < n; j = i, i += 2) {
129
+ const y0 = ring[j + 1], y1 = ring[i + 1];
130
+ if ((y0 <= y) === (y1 <= y)) continue;
131
+ xs.push(ring[j] + ((y - y0) * (ring[i] - ring[j])) / (y1 - y0));
132
+ }
133
+ }
134
+ if (xs.length < 2) continue;
135
+ xs.sort((a, b) => a - b);
136
+ for (let k = 0; k + 1 < xs.length; k += 2) {
137
+ let c0 = Math.ceil((xs[k] - minX) / mmPerPx - 0.5); // first pixel CENTRE inside
138
+ let c1 = Math.floor((xs[k + 1] - minX) / mmPerPx - 0.5); // last pixel centre inside
139
+ if (c0 < 0) c0 = 0;
140
+ if (c1 > size - 1) c1 = size - 1;
141
+ for (let c = c0; c <= c1; c++) data[r * size + c] = 255;
142
+ }
143
+ }
144
+ }
145
+ return { data, width: size, height: size, mmPerPx, minX, minY };
146
+ }
package/src/testing.js CHANGED
@@ -23,3 +23,8 @@ export { renderViews, RENDER_VIEWS } from "./testing/render.js";
23
23
  export { verify } from "./framework/oracle/verify.js";
24
24
  export { buildBVH } from "./framework/oracle/bvh.js";
25
25
  export { minWall } from "./framework/oracle/min-wall.js";
26
+ // Silhouette match scoring — also worker-reachable (the `inspect` job scores
27
+ // `matchTargets` with exactly these), re-exported so a downstream harness can build
28
+ // the same masks and reproduce a score outside the job loop.
29
+ export { MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask } from "./framework/oracle/silhouette.js";
30
+ export { matchMasks, matchViews } from "./framework/oracle/match.js";
@@ -35,6 +35,15 @@ export function bootOcctKernel(opts?: { fonts?: Record<string, FontSource> }): P
35
35
 
36
36
  // --- the job loop -----------------------------------------------------------
37
37
 
38
+ /**
39
+ * A reference shape for the `inspect` job to score the part's silhouettes against.
40
+ * A `profile` is millimetres and so is compared at absolute scale too; an `image`
41
+ * is a photo mask with no scale, compared on shape alone.
42
+ */
43
+ export type MatchTarget =
44
+ | { kind: "profile"; rings: Array<Array<[number, number]>> }
45
+ | { kind: "image"; mask: { data: Uint8Array; width: number; height: number } };
46
+
38
47
  /** A job the worker loop accepts. */
39
48
  export interface WorkerJob {
40
49
  type: "generate" | "export-stl" | "export-step" | "export-3mf" | "inspect";
@@ -50,6 +59,16 @@ export interface WorkerJob {
50
59
  jobId?: number;
51
60
  /** Single-file export name base. */
52
61
  name?: string;
62
+ /**
63
+ * `inspect`: score the part's six canonical silhouettes against these. Absent or
64
+ * empty leaves `match` off the report entirely.
65
+ *
66
+ * A target that cannot be scored — malformed, or with no foreground to score
67
+ * against — is DROPPED rather than reported as a zero, so `report.match` is not
68
+ * index-aligned with this list. Attribute a result by its `kind` and by the
69
+ * relative order of the targets sharing that kind, never by index.
70
+ */
71
+ matchTargets?: MatchTarget[];
53
72
  }
54
73
 
55
74
  /**
@@ -272,7 +291,16 @@ export function measure(
272
291
  part: PartDefinition,
273
292
  view?: string,
274
293
  params?: ResolvedParams,
275
- opts?: { minWall?: boolean; gapThreshold?: number },
294
+ opts?: {
295
+ minWall?: boolean;
296
+ gapThreshold?: number;
297
+ /**
298
+ * A build of this view the caller already has, measured instead of building a
299
+ * second time. It is trusted, not checked against `view`/`params` — hand in a
300
+ * build of the same view you are asking about.
301
+ */
302
+ built?: BuiltSubPart[];
303
+ },
276
304
  ): MeasureReport;
277
305
 
278
306
  // --- verify -----------------------------------------------------------------
@@ -339,6 +367,93 @@ export function verify(
339
367
  },
340
368
  ): VerifyReport;
341
369
 
370
+ // --- silhouette match scoring -----------------------------------------------
371
+
372
+ /**
373
+ * A binary silhouette. `data` is 0 or 255, one byte per pixel, row 0 at the TOP.
374
+ * `minX`/`minY` are the projected-plane coordinates of the image's BOTTOM-LEFT
375
+ * corner. A mask with no `mmPerPx` carries no scale (a photo), which is what makes
376
+ * the scale-aware comparison unavailable for it.
377
+ */
378
+ export interface SilhouetteMask {
379
+ data: Uint8Array;
380
+ width: number;
381
+ height: number;
382
+ mmPerPx?: number;
383
+ minX?: number;
384
+ minY?: number;
385
+ }
386
+
387
+ /** The six canonical orthographic views a part is rasterized into for matching. */
388
+ export const MATCH_VIEWS: string[];
389
+
390
+ /**
391
+ * Project posed meshes onto one of `MATCH_VIEWS` and scanline-fill the silhouette.
392
+ * `null` when there is nothing to draw or the projection has zero extent.
393
+ */
394
+ export function rasterizeMeshMask(
395
+ meshes: Array<Pick<Mesh, "positions" | "indices">>,
396
+ view: string,
397
+ size?: number,
398
+ ): SilhouetteMask | null;
399
+
400
+ /**
401
+ * Fill a set of closed 2-D rings (millimetres) into a mask. All rings share one
402
+ * even-odd group, so a ring inside another is a hole. `null` when nothing fills.
403
+ */
404
+ export function rasterizeRingsMask(
405
+ rings: Array<Array<[number, number]>>,
406
+ size?: number,
407
+ ): SilhouetteMask | null;
408
+
409
+ /**
410
+ * Per-pixel comparison of the two masks: `0` background, `1` overlap, `2` missing
411
+ * (reference only), `3` excess (candidate only).
412
+ */
413
+ export interface MatchDelta {
414
+ width: number;
415
+ height: number;
416
+ data: Uint8Array;
417
+ }
418
+
419
+ /**
420
+ * How close a candidate silhouette is to a reference one. Shape is compared
421
+ * pose-normalized, so `iou` and `boundaryIoU` ignore position and size. `iouScale`
422
+ * appears only for a scale-aware comparison, which also makes `contourDist` a real
423
+ * millimetre distance instead of a percentage of the reference's bbox diagonal.
424
+ */
425
+ export interface MatchScores {
426
+ iou: number;
427
+ boundaryIoU: number;
428
+ contourDist: number;
429
+ contourUnit: "mm" | "%bbox-diag";
430
+ iouScale?: number;
431
+ delta: MatchDelta;
432
+ }
433
+
434
+ /**
435
+ * Score one candidate mask against a reference. `null` when either has no
436
+ * foreground at all — unscoreable is not the same as scoring zero.
437
+ *
438
+ * `scaleAware` is the caller's promise that both masks are in millimetres; it takes
439
+ * effect only when both actually carry a finite `mmPerPx`.
440
+ */
441
+ export function matchMasks(
442
+ candidate: SilhouetteMask | null | undefined,
443
+ reference: SilhouetteMask | null | undefined,
444
+ opts?: { scaleAware?: boolean },
445
+ ): MatchScores | null;
446
+
447
+ /**
448
+ * Score every view's mask against one reference and name the best. Unscoreable
449
+ * views are left out of `views` entirely; `best` is `null` when none scored.
450
+ */
451
+ export function matchViews(
452
+ viewMasks: Record<string, SilhouetteMask | null>,
453
+ reference: SilhouetteMask | null | undefined,
454
+ opts?: { scaleAware?: boolean },
455
+ ): { best: ({ view: string } & MatchScores) | null; views: Record<string, number> };
456
+
342
457
  // --- rendering --------------------------------------------------------------
343
458
 
344
459
  /** The canonical angle names `renderViews` accepts. */