partforge 0.77.0 → 0.78.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.
@@ -0,0 +1,538 @@
1
+ // The describe orchestrator: mesh in, semantic report out. Sits beside measure.js and
2
+ // plays the same role for an imported mesh that measure plays for a built part.
3
+ //
4
+ // THE MEMO IS THE POINT OF THE WHOLE CACHING STORY (spec §4.1). measure and verify
5
+ // depend on part source AND params, so they must re-run on every apply. describe depends
6
+ // on NOTHING BUT THE MESH BYTES. So it keys on the import's content digest and an edit
7
+ // can never invalidate it: computed once per mesh per worker, reused for the entire
8
+ // session, across every turn. The memo Map is caller-owned rather than module-level so
9
+ // a worker can scope it to its own lifetime and a test can get a clean one — the same
10
+ // reasoning bvh.js's cachedBVH documents for its cache.
11
+ //
12
+ // Errors come from a CLOSED SET and are returned, never thrown, for anything short of a
13
+ // programming mistake. A mesh describe cannot read is a finding about the mesh, and the
14
+ // caller (a CLI, an agent) can act on `{error: "not-manifold"}` far better than on an
15
+ // exception. Every code has an ERROR-PATTERNS.md entry.
16
+ import { buildTopology } from "./describe/topology.js";
17
+ import { segment } from "./describe/segment.js";
18
+ import { surfaceGraph } from "./describe/surface-graph.js";
19
+ import { detectHoles } from "./describe/features/holes.js";
20
+ import { detectDressups } from "./describe/features/dressups.js";
21
+ import { detectPrismatic } from "./describe/features/prismatic.js";
22
+ import { detectSweeps } from "./describe/features/sweeps.js";
23
+ import { detectPatterns } from "./describe/patterns.js";
24
+ import { snapValue, snapHoleDiameter } from "./describe/snap.js";
25
+ import { acceptCandidates, DEFAULT_ATTEMPT_BUDGET } from "./describe/accept.js";
26
+ import { buildReport } from "./describe/report.js";
27
+ import { buildHints } from "./describe/hints.js";
28
+ import { bounds, meshArea } from "./mesh.js";
29
+ import { buildBVH } from "./bvh.js";
30
+
31
+ export const DESCRIBE_ERRORS = Object.freeze(
32
+ ["not-manifold", "too-large", "empty", "budget-exceeded", "unreadable"]);
33
+
34
+ // Above this the segmentation cost stops being worth the wait in an interactive loop.
35
+ // Not a correctness limit — a responsiveness one, reported as `too-large` so the caller
36
+ // can decimate and retry rather than wonder why nothing happened.
37
+ const MAX_TRIANGLES = 400_000;
38
+
39
+ export const describeMemo = () => new Map();
40
+
41
+ // A closed-set error, shaped as the repo's structured diagnostic triple (spec §5, and
42
+ // the same (cause, location, correctiveAction) contract measure/verify emit). The
43
+ // research behind it is blunt about why: structured triples cut average agent retries
44
+ // 2.62 -> 1.86 against the same failures reported as prose. `error` stays a bare code so
45
+ // a caller can switch on it exhaustively.
46
+ const fail = (error, opts, source, cause, location, correctiveAction) => ({
47
+ error,
48
+ detail: cause,
49
+ diagnostic: { cause, location, correctiveAction },
50
+ source: { name: opts.name ?? null, digest: opts.digest ?? null, ...source },
51
+ });
52
+
53
+ // --- vec3 helpers for orienting acceptance candidates ------------------------
54
+ // toCandidate builds every solid in a canonical local frame (holes and cylinder
55
+ // bosses along local Z; boxes with local Z as depth and local X as one wall) and
56
+ // then rotates it onto the part's OWN frame, wherever a rigid rotation of the
57
+ // whole input mesh happened to put that frame. Kept together, and separate from
58
+ // the per-feature logic below, because every one of them is pure coordinate math
59
+ // with no feature-vocabulary knowledge of its own.
60
+ const dot3 = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
61
+ const cross3 = (a, b) => [a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2], a[0]*b[1] - a[1]*b[0]];
62
+ const unit3 = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0]/l, a[1]/l, a[2]/l]; };
63
+ const scale3 = (a, s) => [a[0]*s, a[1]*s, a[2]*s];
64
+ const add3 = (a, b) => [a[0]+b[0], a[1]+b[1], a[2]+b[2]];
65
+ // The component of `u` perpendicular to `w` (w assumed unit) — cleans up a wall
66
+ // normal that is perpendicular to the extrusion axis only up to fit residual.
67
+ const orthogonalize = (u, w) => unit3([u[0] - w[0]*dot3(u,w), u[1] - w[1]*dot3(u,w), u[2] - w[2]*dot3(u,w)]);
68
+
69
+ // Aligns a Z-based primitive (a kernel cylinder's own local axis) with an arbitrary
70
+ // unit `dir` — the axis/angle rotation `.rotate(deg, [0,0,0], axis)` needs. A hole's
71
+ // bore can point anywhere once the whole part is rotated (the orientation-invariance
72
+ // test below exercises exactly this), so a candidate cylinder built assuming a world-Z
73
+ // axis would miss a tilted bore entirely: it would still be built, still get measured
74
+ // by acceptCandidates' own xor-volume gain, and would simply score ~0 and never be
75
+ // accepted — a silent, hard-to-diagnose loss of the feature from `suggestion.steps`,
76
+ // not a crash. Returns null when `dir` already IS +Z (no rotation needed) and a
77
+ // 180°-about-X rotation for the -Z case, where the cross product degenerates to zero.
78
+ function alignZTo(dir) {
79
+ const d = unit3(dir);
80
+ const cosT = d[2]; // dot([0,0,1], d)
81
+ const axis = [-d[1], d[0], 0]; // cross([0,0,1], d)
82
+ const sinT = Math.hypot(axis[0], axis[1], axis[2]);
83
+ if (sinT < 1e-9) return cosT > 0 ? null : { axis: [1, 0, 0], deg: 180 };
84
+ return { axis, deg: (Math.atan2(sinT, cosT) * 180) / Math.PI };
85
+ }
86
+
87
+ // Rotates a plain vector by the SAME axis-angle convention `.rotate()` itself uses
88
+ // (manifold-backend.js's own axisAngleMat4 — copied here, not imported, since this
89
+ // file stays kernel-agnostic pure math and the OCCT backend's `.rotate()` honours
90
+ // the identical (deg, center, axis) contract per KERNEL-CONTRACT.md). Used below to
91
+ // work out where a candidate's own local X axis has already landed after the first
92
+ // of two composed rotations, so the second can be computed exactly.
93
+ function rotateVector(v, axis, deg) {
94
+ const [x, y, z] = unit3(axis);
95
+ const t = (deg * Math.PI) / 180, c = Math.cos(t), s = Math.sin(t), C = 1 - c;
96
+ return [
97
+ (c + x*x*C)*v[0] + (x*y*C - z*s)*v[1] + (x*z*C + y*s)*v[2],
98
+ (y*x*C + z*s)*v[0] + (c + y*y*C)*v[1] + (y*z*C - x*s)*v[2],
99
+ (z*x*C - y*s)*v[0] + (z*y*C + x*s)*v[1] + (c + z*z*C)*v[2],
100
+ ];
101
+ }
102
+
103
+ // Degrees to rotate unit vector `a` onto unit vector `b` about `axis`, where both
104
+ // a and b are already perpendicular to `axis` — the "roll" half of orienting a box
105
+ // candidate below. A signed angle (atan2, not acos) because the roll can go either way.
106
+ const angleAbout = (a, b, axis) => (Math.atan2(dot3(cross3(a, b), axis), dot3(a, b)) * 180) / Math.PI;
107
+
108
+ // Rotates `solid` (built with local Z along `direction` and local X along `uAxis`)
109
+ // onto the world orientation those two vectors actually have, as TWO single-axis
110
+ // rotations composed: tip local Z onto `direction` (alignZTo), then roll about
111
+ // `direction` so local X lands on `uAxis`. Two single-axis rotations rather than one
112
+ // general 3x3 basis change because `.rotate()`/`.rotateAbout()` only accept a single
113
+ // axis-angle pair, and Euler's rotation theorem guarantees this composition reaches
114
+ // every orientation a 3x3 change could (recovering axis/angle from an arbitrary
115
+ // rotation matrix directly is the harder, more error-prone equivalent this avoids).
116
+ function orientOnto(solid, direction, uAxis) {
117
+ const tip = alignZTo(direction);
118
+ const tipped = tip ? solid.rotate(tip.deg, [0, 0, 0], tip.axis) : solid;
119
+ const x1 = tip ? rotateVector([1, 0, 0], tip.axis, tip.deg) : [1, 0, 0];
120
+ const rollDeg = angleAbout(x1, uAxis, direction);
121
+ return Math.abs(rollDeg) < 1e-9 ? tipped : tipped.rotate(rollDeg, [0, 0, 0], direction);
122
+ }
123
+
124
+ // Bounding extent of every vertex in `positions` (flat x,y,z triples — either
125
+ // backend's form; duplicated shared vertices don't skew a min/max) projected onto
126
+ // each of three (assumed orthonormal) `axes`. `bounds()` (mesh.js) is the same
127
+ // idea specialised to the world axes; this is its general-frame twin, needed
128
+ // because a rotated part's own extrusion axis is not world Z (segment.js/
129
+ // surface-graph.js already read every OTHER measurement this same way).
130
+ function projectedBounds(positions, axes) {
131
+ const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
132
+ for (let i = 0; i < positions.length; i += 3) {
133
+ const p = [positions[i], positions[i+1], positions[i+2]];
134
+ for (let a = 0; a < 3; a++) {
135
+ const proj = dot3(p, axes[a]);
136
+ if (proj < lo[a]) lo[a] = proj;
137
+ if (proj > hi[a]) hi[a] = proj;
138
+ }
139
+ }
140
+ return { min: lo, max: hi };
141
+ }
142
+
143
+ // Vertex positions (flat x,y,z triples, duplicates and all — `projectedBounds` only
144
+ // needs a min/max) belonging to ONE feature's own surfaces, not the whole mesh.
145
+ // Round 2 review's CRITICAL finding: a two-box stepped part (two "extrusion"-family
146
+ // features) fed the WHOLE mesh into `projectedBounds` for both candidates, so both
147
+ // came out identically sized to the full-part bbox — 62500mm3 each against a true
148
+ // 38500mm3 combined — and `acceptCandidates` (which greedily takes the better of two
149
+ // near-duplicate candidates and never revisits) accepted one and silently dropped the
150
+ // other: no error, no warning, just missing from `volumeShare`/`suggestion.steps`. A
151
+ // feature's `wallFaces`/`floorFace` name exactly the surfaces that belong to IT, and
152
+ // each surface already carries its own triangle list (`surface-graph.js`'s `faces`),
153
+ // so this reads the actual footprint each feature bounds rather than the part's.
154
+ //
155
+ // `faceScope` (round 3 review, the SAME defect one level down): `mergeCoFamily`
156
+ // (surface-graph.js) folds two patches into one SURFACE whenever they share a fitted
157
+ // plane, whether or not they touch — so a named surface can itself span more than one
158
+ // physical feature (two same-height boss tops, or two different steps' walls that
159
+ // happen to share an x/y coordinate). Reading that surface's WHOLE `faces` list, as
160
+ // this function did before round 3, pulled in a neighbouring feature's own geometry
161
+ // right back into the candidate this fix in round 2 had just scoped per-feature.
162
+ // `prismatic.js`'s `detectPrismatic` now hands back a `faceScope` map (surface id ->
163
+ // JUST the triangles belonging to that specific feature's own island) precisely so
164
+ // this can read the right subset instead; a feature without one (holes, dressups, or
165
+ // a plain single-island cap with topo unavailable) falls back to the surface's whole
166
+ // list, unchanged.
167
+ function surfaceVertices(topo, surfById, ids, faceScope) {
168
+ const out = [];
169
+ for (const id of ids) {
170
+ const surf = surfById.get(id);
171
+ if (!surf) continue;
172
+ const faces = faceScope?.[id] ?? surf.faces;
173
+ for (const t of faces) {
174
+ for (let k = 0; k < 3; k++) {
175
+ const v = topo.tris[3*t + k] * 3;
176
+ out.push(topo.verts[v], topo.verts[v+1], topo.verts[v+2]);
177
+ }
178
+ }
179
+ }
180
+ return out;
181
+ }
182
+
183
+ export function describe(kernel, solid, opts = {}) {
184
+ // A live Solid in, not a mesh. The kernel exposes no public mesh->solid constructor —
185
+ // geometry only enters through `_registerImport` + `import(name)` — and acceptance needs
186
+ // a Solid to diff against. Both real callers (the worker job and the CLI) already hold
187
+ // one from `k.import(name)`, so taking the Solid and deriving the mesh here is both the
188
+ // honest signature and the shorter path.
189
+ let mesh;
190
+ try {
191
+ mesh = solid.toMesh();
192
+ } catch (err) {
193
+ return fail("unreadable", opts, { triangles: 0 },
194
+ `solid.toMesh() threw: ${err?.message ?? err}`,
195
+ `import "${opts.name ?? "?"}"`,
196
+ "the geometry could not be read back off the kernel; re-export the source file and retry");
197
+ }
198
+ // Both backends' toMesh() carries its own triangle count directly (kernel.js's
199
+ // toMesh JSDoc) — no need to re-derive it from positions/indices length.
200
+ const triangles = mesh?.triangles ?? 0;
201
+ if (!triangles) {
202
+ return fail("empty", opts, { triangles: 0 },
203
+ "the mesh has no triangles",
204
+ `import "${opts.name ?? "?"}"`,
205
+ "check that the `imports` source resolves to a real file; see ERROR-PATTERNS.md#describe-empty");
206
+ }
207
+ if (triangles > MAX_TRIANGLES) {
208
+ return fail("too-large", opts, { triangles },
209
+ `${triangles} triangles exceeds the ${MAX_TRIANGLES} describe limit`,
210
+ `import "${opts.name ?? "?"}"`,
211
+ "re-export or decimate at a coarser chord tolerance; the feature rules read surfaces, not facets");
212
+ }
213
+
214
+ const memo = opts.memo;
215
+ const key = opts.digest ? `${opts.digest}:${opts.budget ?? DEFAULT_ATTEMPT_BUDGET}` : null;
216
+ if (memo && key && memo.has(key)) return memo.get(key);
217
+
218
+ const topo = buildTopology(mesh);
219
+
220
+ // Every edge in a genuine solid is shared by exactly two triangles; a boundary edge
221
+ // (`triB < 0`, topology.js's own convention) means the mesh still has an open seam
222
+ // after vertex-merge and winding repair, so it does not bound a solid and acceptance
223
+ // has nothing to diff against. Checked here, before any of the expensive stages run.
224
+ const openEdges = topo.edges.reduce((n, e) => n + (e.triB < 0 ? 1 : 0), 0);
225
+ if (openEdges > 0) {
226
+ return fail("not-manifold", opts, { triangles, openEdges },
227
+ `${openEdges} open edge${openEdges === 1 ? "" : "s"} after vertex-merge and winding repair`,
228
+ `import "${opts.name ?? "?"}"`,
229
+ "repair the mesh before describing it; see ERROR-PATTERNS.md#describe-not-manifold");
230
+ }
231
+
232
+ const { patches, unassigned } = segment(topo);
233
+ const graph = surfaceGraph(topo, patches);
234
+
235
+ // ONE BVH for this call, shared by every stage that needs one (controller ruling R39).
236
+ // `detectSweeps`'s shell rule raycasts inward from each plane, and `buildBVH` is
237
+ // O(n log n) over the WHOLE mesh regardless of how few rays are cast — measured at 9.8ms
238
+ // on 10.8k triangles and 48ms on 43k. Letting each stage build its own would pay that
239
+ // repeatedly for nothing. This is the same caller-owned-cache pattern `measure.js` uses
240
+ // to stop min-wall and meshGaps indexing the same mesh twice; see `cachedBVH`'s own
241
+ // comment for why a caller-owned Map rather than a module-level WeakMap.
242
+ const bvh = buildBVH({ positions: topo.verts, indices: topo.tris });
243
+
244
+ // Feature families run in a fixed order and their results are concatenated in that
245
+ // order, then sorted by each rule's own geometry-derived `key`. So f-numbering depends
246
+ // on the MESH, never on iteration order or on which family happened to run first.
247
+ const raw = [
248
+ ...detectHoles(graph),
249
+ ...detectDressups(graph),
250
+ ...detectPrismatic(graph, topo),
251
+ ...detectSweeps(graph, topo, { bvh }),
252
+ ].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
253
+
254
+ const features = raw.map((f, i) => {
255
+ const snapped = {};
256
+ if (Number.isFinite(f.diameter)) { const s = snapHoleDiameter(f.diameter); if (s) snapped.diameter = s; }
257
+ for (const k of ["depth", "radius", "width", "thickness"]) {
258
+ if (Number.isFinite(f[k])) { const s = snapValue(f[k]); if (s) snapped[k] = s; }
259
+ }
260
+ return { ...f, id: `f${i}`, snapped };
261
+ });
262
+
263
+ const b = bounds(mesh.positions);
264
+ const { patterns, symmetry } = detectPatterns(features, b);
265
+
266
+ // Candidates for acceptance, in the order the rules produced them. `featureKey` is what
267
+ // hints.js joins against to collapse a pattern's members into one step. `surfById` and
268
+ // `topo` let a prismatic candidate orient itself onto THAT FEATURE'S OWN frame and
269
+ // extent (see toCandidate's own comment) instead of assuming the extrusion runs along
270
+ // world Z, or — round 2 review's CRITICAL finding — reading the whole mesh's bounds
271
+ // for every feature and building every candidate the same full-part size.
272
+ const surfById = new Map(graph.surfaces.map((s) => [s.id, s]));
273
+ const candidates = features
274
+ .map((f) => toCandidate(kernel, f, b, { surfById, topo }))
275
+ .filter(Boolean);
276
+
277
+ const graded = acceptCandidates(kernel, solid, candidates, { budget: opts.budget });
278
+ // featureKey -> the candidate `toCandidate` proposed for it, if any — what tells a
279
+ // feature with `volumeShare: null` apart into WHY (fix round 2, IMPORTANT 2, below).
280
+ const candidateByFeatureKey = new Map(candidates.map((c) => [c.featureKey, c]));
281
+
282
+ const totalArea = meshArea(mesh.positions, mesh.indices);
283
+ const explainedArea = totalArea > 0
284
+ ? graph.surfaces.reduce((a, s) => a + s.area, 0) / totalArea
285
+ : 0;
286
+ const residualArea = unassigned.reduce((a, t) => a + topo.faceArea[t], 0);
287
+
288
+ const report = buildReport({
289
+ source: { name: opts.name ?? null, digest: opts.digest ?? null, triangles, watertight: true },
290
+ bounds: b,
291
+ surfaces: graph.surfaces.map((s) => ({
292
+ id: s.id, type: s.type, area: s.area, triangles: s.faces.length,
293
+ rms: s.fit.rms, maxDev: s.fit.maxDev, fit: s.fit,
294
+ })),
295
+ arcs: graph.arcs,
296
+ // NAMED `volumeShare`, not `confidence` (round 2 review, IMPORTANT): the value
297
+ // is accept.js's own volume-normalised marginal xor-volume gain — how much of
298
+ // the PART'S VOLUME this feature accounts for, not how sure the description is.
299
+ // A "confidence" label reads backwards for exactly the features a rebuilder
300
+ // most needs to trust: a precise 3mm hole in a large plate is CERTAIN (its fit
301
+ // rms is tiny) but SMALL, so it legitimately reports a low share — see
302
+ // `score.note` for the same point stated for a reader who never gets this far.
303
+ // `faceScope` (prismatic.js) is internal plumbing for THIS file's own candidate
304
+ // builder — a feature's floor/wall surfaces reduced to per-triangle index arrays
305
+ // — and must never reach a model-facing report (round 4 review, IMPORTANT):
306
+ // measured at 31% of an entire compact report's bytes for one feature's own
307
+ // bookkeeping on a 476-triangle fixture, scaling with mesh complexity up to the
308
+ // 400k-triangle MAX_TRIANGLES ceiling. Stripped HERE, not left for buildReport/
309
+ // compactDescribe to remember to delete — those two are downstream of every
310
+ // detector, not just this one, so a future per-triangle field on a different
311
+ // detector would leak the same way unless every consumer had to opt in
312
+ // separately. Chose stripping over a side channel (a second detectPrismatic
313
+ // return value, or a Map keyed by feature key) because `detectPrismatic`'s
314
+ // return shape is a plain feature array read directly by a dozen existing call
315
+ // sites (this file's own tests, describe-features-prismatic.test.js,
316
+ // describe-features-key-stability.test.js) — changing that contract to thread a
317
+ // second value through is a real, unforced breaking change for every one of
318
+ // them, whereas destructuring one field out at its only egress point here is
319
+ // not. `candidates` (below) is built from the PRE-strip `features` array, so
320
+ // toCandidate still reads every feature's own `faceScope`.
321
+ // `volumeShare: null` alone does not say WHY (fix round 2, IMPORTANT 2): three
322
+ // genuinely different situations all used to collapse onto it indistinguishably —
323
+ // a feature type `toCandidate` never proposes at all (fillet/chamfer/revolve/
324
+ // shell — see its own trailing comment), one that WAS proposed but the search
325
+ // never reached before running out of `--budget`, and one that WAS reached and
326
+ // built but never won a round (accept.js's own MIN_GAIN_FRACTION gate, or simply
327
+ // never the best candidate that round). A rebuilder needs to tell these apart —
328
+ // "not modelled by this tool at all" vs. "try a bigger budget" vs. "this genuinely
329
+ // doesn't fit" are different next actions. `volumeShareReason` names which one, a
330
+ // closed set of `"not-proposed" | "budget" | "rejected"`, null only when
331
+ // `volumeShare` itself is non-null (see accept.js's `attempted` Set for exactly
332
+ // what "rejected" does and doesn't distinguish within itself).
333
+ features: features.map(({ faceScope: _faceScope, ...f }) => {
334
+ const accepted = graded.accepted.find((a) => a.candidate.featureKey === f.key);
335
+ if (accepted) return { ...f, volumeShare: accepted.gain, volumeShareReason: null };
336
+ const candidate = candidateByFeatureKey.get(f.key);
337
+ const reason = !candidate ? "not-proposed" : graded.attempted.has(candidate) ? "rejected" : "budget";
338
+ return { ...f, volumeShare: null, volumeShareReason: reason };
339
+ }),
340
+ patterns, symmetry,
341
+ residual: {
342
+ areaFraction: totalArea > 0 ? residualArea / totalArea : 0,
343
+ regions: residualRegions(topo, unassigned),
344
+ },
345
+ score: {
346
+ // TWO DIFFERENT MEASUREMENTS, and conflating them breaks the report's honesty
347
+ // property (controller ruling R45). `explainedArea` is how much of the mesh SURFACE
348
+ // segmentation fitted to some analytic primitive. `explainedVolumeFraction` is how
349
+ // much of the part's SHAPE the accepted features actually reconstruct. They diverge
350
+ // hard: a hemisphere dome segments to 1.0 area coverage and reconstructs 0.0 of its
351
+ // volume, because a sphere is not a candidate-eligible type. Both must be carried —
352
+ // the low-coverage banner gates on the WORSE of the two, since an agent rebuilding a
353
+ // part cares whether the shape is accounted for, not whether primitives were fitted.
354
+ explainedArea,
355
+ explainedVolumeFraction: graded.score.explainedVolumeFraction,
356
+ xorFraction: graded.score.xorFraction,
357
+ xorVolume: graded.score.xorVolume,
358
+ },
359
+ suggestion: buildHints(graded.accepted, patterns, b),
360
+ });
361
+ if (graded.budgetExceeded) report.warning = "budget-exceeded";
362
+
363
+ if (memo && key) memo.set(key, report);
364
+ return report;
365
+ }
366
+
367
+ // Unassigned faces grouped into connected islands, each reported with its own extent.
368
+ // A count alone tells the agent nothing actionable; "290 triangles, here" does.
369
+ function residualRegions(topo, unassigned) {
370
+ const pool = new Set(unassigned), out = [];
371
+ for (const seed of unassigned) {
372
+ if (!pool.has(seed)) continue;
373
+ const stack = [seed], faces = [];
374
+ pool.delete(seed);
375
+ while (stack.length) {
376
+ const t = stack.pop();
377
+ faces.push(t);
378
+ for (const ei of topo.faceEdges[t]) {
379
+ const e = topo.edges[ei];
380
+ const nb = e.triA === t ? e.triB : e.triA;
381
+ if (nb >= 0 && pool.has(nb)) { pool.delete(nb); stack.push(nb); }
382
+ }
383
+ }
384
+ const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
385
+ const c = [0, 0, 0];
386
+ for (const t of faces) for (let k = 0; k < 3; k++) {
387
+ const v = topo.tris[3*t + k] * 3;
388
+ for (let a = 0; a < 3; a++) {
389
+ const val = topo.verts[v + a];
390
+ if (val < lo[a]) lo[a] = val;
391
+ if (val > hi[a]) hi[a] = val;
392
+ c[a] += val / (faces.length * 3);
393
+ }
394
+ }
395
+ out.push({ triangles: faces.length, centroid: c, bounds: { min: lo, max: hi } });
396
+ }
397
+ return out.sort((a, b) => b.triangles - a.triangles);
398
+ }
399
+
400
+ // One acceptance candidate per feature. `build` is a thunk so nothing is materialised
401
+ // for a candidate the greedy loop never reaches. `ctx.surfById` (a graph.surfaces
402
+ // lookup) and `ctx.topo` (the welded mesh topology, for reading a SPECIFIC feature's
403
+ // own surfaces' vertices via `surfaceVertices` — never the whole mesh's) are what let
404
+ // a candidate read the part's OWN frame and THAT FEATURE'S OWN extent, rather than
405
+ // assuming a frame or reading the whole-mesh bbox for every feature alike; see the
406
+ // extrusion/boss branch's own comment for both failure modes this avoids.
407
+ //
408
+ // kernel.box/kernel.cylinder take OPTIONS OBJECTS ({size:[...]}/{min,max} and {r,h}) —
409
+ // the positional legacy forms are silently accepted by the kernel front-end but resolve
410
+ // to a DIFFERENT signature (box(min,max); cylinder(rBottom,rTop,h)) and hand back a
411
+ // zero-volume solid rather than erroring. Verified directly against a live kernel:
412
+ // `kernel.box(60,40,12).volume()` and `kernel.cylinder(2.65,40).volume()` both read 0.
413
+ function toCandidate(kernel, f, b, ctx) {
414
+ const size = [0,1,2].map((i) => b.max[i] - b.min[i]);
415
+ if (f.type === "throughHole" || f.type === "blindHole") {
416
+ const depth = f.type === "throughHole" ? Math.max(...size) * 2 : f.depth;
417
+ // Oriented along the hole's OWN axis, not world Z — a bore can point anywhere once
418
+ // the part is rotated (alignZTo's own comment has the full reasoning).
419
+ const rot = alignZTo(f.axis.direction);
420
+ return {
421
+ key: f.key, featureKey: f.key, op: "cut", explains: [f.id],
422
+ dimension: f.diameter, paramName: "holeDia", hintOp: "cut",
423
+ hintArgs: { shape: "cylinder", diameter: f.diameter, depth },
424
+ build: () => {
425
+ const cyl = kernel.cylinder({ r: f.diameter / 2, h: depth });
426
+ const oriented = rot ? cyl.rotate(rot.deg, [0, 0, 0], rot.axis) : cyl;
427
+ return oriented.translate([
428
+ f.axis.origin[0] - f.axis.direction[0] * depth / 2,
429
+ f.axis.origin[1] - f.axis.direction[1] * depth / 2,
430
+ f.axis.origin[2] - f.axis.direction[2] * depth / 2,
431
+ ]);
432
+ },
433
+ };
434
+ }
435
+ // Pocket candidates (round 3 review — a widening, not part of the island-merge fix
436
+ // itself): a pocket's own island (floor + walls) bounds its recessed geometry exactly
437
+ // the same way a boss's bounds its protruding geometry — `projectedBounds` reads
438
+ // actual vertex extent, agnostic to which side of the surrounding surface it falls
439
+ // on — so the SAME box/cylinder construction below works unchanged; only `op` (cut,
440
+ // not union) and `hintOp` differ. Previously pockets were described but never
441
+ // proposed as candidates at all (Task 12's original scope note, kept below on the
442
+ // fillet/chamfer/revolve/shell types that still are), which is why a part with a
443
+ // pocket used to always read as at least partially unexplained even when perfectly
444
+ // segmented — not a bug this file introduced, but a gap this fix's own "two
445
+ // same-height pockets" regression test would otherwise misreport.
446
+ if (f.type === "extrusion" || f.type === "boss" || f.type === "pocket") {
447
+ const op = f.type === "pocket" ? "cut" : "union";
448
+ // Oriented onto the part's OWN extrusion frame, not the WORLD-axis-aligned bbox —
449
+ // measured directly against a box+bore fixture rotated 29° about an oblique axis:
450
+ // a bbox-aligned candidate built from the rotated mesh's own (now-larger, tilted)
451
+ // AABB scored a NEGATIVE xor-volume gain (candidate 52796mm³ vs a 28535mm³ source,
452
+ // intersecting only 6632mm³) and was correctly rejected — leaving the base
453
+ // extrusion, and therefore the bore that can only be cut FROM it (acceptCandidates'
454
+ // loop never attempts a `cut` against a null base), unexplained: 0% of the part's
455
+ // volume, not merely a worse fit. Reading the true frame off one of this feature's
456
+ // own wall surfaces (below) reproduces a ~28800mm³ candidate containing ~99.999%
457
+ // of the 28535mm³ source and restores the same ~100% reconstruction the
458
+ // axis-aligned case already gets.
459
+ const direction = unit3(f.direction);
460
+ if (f.profile.kind === "circle") {
461
+ // Rotationally symmetric about its own axis, so — unlike the box branch below —
462
+ // no roll correction is needed, only the axis itself: the same alignZTo a hole
463
+ // uses. Reads the true axis (and its exact base point) off this boss's own
464
+ // cylindrical wall surface when the graph has one; falls back to the bbox-centre
465
+ // approximation only when it doesn't (e.g. a bare disc with no side wall fitted).
466
+ const wallCyl = ctx?.surfById && f.wallFaces
467
+ ? f.wallFaces.map((id) => ctx.surfById.get(id)).find((s) => s?.type === "cylinder")
468
+ : null;
469
+ const axisDir = wallCyl ? unit3(wallCyl.fit.axis.direction) : direction;
470
+ // fit.js's `extent` is always [min, max] along the axis (fitCylinder sorts it),
471
+ // so index 0 is the wall's own lower/base end regardless of which way the
472
+ // fitted axis direction happens to point.
473
+ const base = wallCyl
474
+ ? add3(wallCyl.fit.axis.origin, scale3(axisDir, wallCyl.fit.extent[0]))
475
+ : [b.min[0] + size[0]/2, b.min[1] + size[1]/2, b.min[2]];
476
+ const rot = alignZTo(axisDir);
477
+ return {
478
+ key: f.key, featureKey: f.key, op, explains: [f.id],
479
+ dimension: f.depth, paramName: "height", hintOp: f.type === "boss" ? "union" : f.type === "pocket" ? "cut" : "box",
480
+ hintArgs: { shape: "circle", depth: f.depth },
481
+ build: () => {
482
+ const cyl = kernel.cylinder({ r: f.profile.radius, h: f.depth });
483
+ const oriented = rot ? cyl.rotate(rot.deg, [0, 0, 0], rot.axis) : cyl;
484
+ return oriented.translate(base);
485
+ },
486
+ };
487
+ }
488
+ // Polygon/mixed profile: `profileOf` (prismatic.js) reports only a POINT COUNT for
489
+ // a polygon, never its vertices, so the footprint's own IN-PLANE rotation (its
490
+ // "roll" about `direction`) cannot be read from the profile fact. It CAN be read
491
+ // off one of this feature's own wall PLANES: `isSideWallOf` (prismatic.js)
492
+ // guarantees every wall normal is already perpendicular to `direction`, which is
493
+ // exactly one of this box's own true edge directions. Depth and footprint size
494
+ // then come from THIS FEATURE'S OWN vertices (its `floorFace` cap plus its
495
+ // `wallFaces`, via `surfaceVertices` — NOT the whole mesh; round 2 review's
496
+ // CRITICAL finding, see that function's own comment for the two-box repro)
497
+ // projected onto that exact (u, v, direction) frame — `projectedBounds`, this
498
+ // file's general-frame twin of mesh.js's `bounds()` — rather than the world-axis
499
+ // bbox `size`/`f.depth` used above for the (rotation-insensitive) circle case.
500
+ const wallPlane = ctx?.surfById && f.wallFaces
501
+ ? f.wallFaces.map((id) => ctx.surfById.get(id)).find((s) => s?.type === "plane")
502
+ : null;
503
+ if (!wallPlane || !ctx?.topo) {
504
+ // No wall plane to read a true in-plane axis from (or no topology handed in)
505
+ // — fall back to the axis-aligned bbox approximation, honest about being one:
506
+ // still worth proposing, since a low-gain candidate is simply never accepted
507
+ // (acceptCandidates' own MIN_GAIN_FRACTION gate), never a false positive.
508
+ return {
509
+ key: f.key, featureKey: f.key, op, explains: [f.id],
510
+ dimension: f.depth, paramName: "height", hintOp: f.type === "boss" ? "union" : f.type === "pocket" ? "cut" : "box",
511
+ hintArgs: { shape: f.profile.kind, depth: f.depth },
512
+ build: () => kernel.box({ min: b.min, max: [b.min[0] + size[0], b.min[1] + size[1], b.min[2] + f.depth] }),
513
+ };
514
+ }
515
+ const u = orthogonalize(wallPlane.fit.normal, direction);
516
+ const v = cross3(direction, u);
517
+ const ownSurfaces = [f.floorFace, ...(f.wallFaces ?? [])].filter(Boolean);
518
+ return {
519
+ key: f.key, featureKey: f.key, op, explains: [f.id],
520
+ dimension: f.depth, paramName: "height", hintOp: f.type === "boss" ? "union" : f.type === "pocket" ? "cut" : "box",
521
+ hintArgs: { shape: f.profile.kind, depth: f.depth },
522
+ build: () => {
523
+ const verts = surfaceVertices(ctx.topo, ctx.surfById, ownSurfaces, f.faceScope);
524
+ const bnd = projectedBounds(verts, [u, v, direction]);
525
+ const [uSize, vSize, depth] = [0, 1, 2].map((i) => bnd.max[i] - bnd.min[i]);
526
+ const local = kernel.box({ min: [0, 0, 0], max: [uSize, vSize, depth] });
527
+ const oriented = orientOnto(local, direction, u);
528
+ const origin = add3(add3(scale3(u, bnd.min[0]), scale3(v, bnd.min[1])), scale3(direction, bnd.min[2]));
529
+ return oriented.translate(origin);
530
+ },
531
+ };
532
+ }
533
+ // Fillets, chamfers, revolves and shells are described but not yet proposed as
534
+ // acceptance candidates: each needs an edge or profile selector the facts layer does
535
+ // not yet carry, and a candidate that cannot be built is worse than none. They still
536
+ // appear in `features` with a null volumeShare, which is the honest report.
537
+ return null;
538
+ }
package/src/testing.js CHANGED
@@ -28,3 +28,8 @@ export { minWall } from "./framework/oracle/min-wall.js";
28
28
  // the same masks and reproduce a score outside the job loop.
29
29
  export { MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask } from "./framework/oracle/silhouette.js";
30
30
  export { matchMasks, matchViews } from "./framework/oracle/match.js";
31
+ // The semantic mesh oracle. Worker-reachable like the rest of the oracle (the
32
+ // `describe` job runs it); re-exported so a downstream harness can run it directly.
33
+ export { describe, describeMemo, DESCRIBE_ERRORS } from "./framework/oracle/describe.js";
34
+ export { compactDescribe, LOW_COVERAGE } from "./framework/oracle/describe/report.js";
35
+ export { DESCRIBE_LIMITS } from "./framework/oracle/describe/limits.js";