partforge 0.80.0 → 0.82.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.
Files changed (32) hide show
  1. package/bin/cli.js +50 -4
  2. package/docs/AUTHORING-PARTS.md +99 -161
  3. package/docs/ERROR-PATTERNS.md +6 -0
  4. package/package.json +1 -1
  5. package/src/framework/geometry/probe.js +21 -8
  6. package/src/framework/jobs.js +35 -12
  7. package/src/framework/lint/rules-build.js +12 -2
  8. package/src/framework/lint/rules-shape.js +19 -0
  9. package/src/framework/oracle/measure.js +87 -0
  10. package/src/framework/oracle/verify.js +4 -1
  11. package/src/framework/worker.js +6 -3
  12. package/src/oracle.js +18 -14
  13. package/src/parts/import-demo.js +22 -1
  14. package/types/oracle.d.ts +6 -11
  15. package/types/testing.d.ts +15 -177
  16. package/types/worker.d.ts +10 -2
  17. package/src/framework/oracle/describe/accept.js +0 -188
  18. package/src/framework/oracle/describe/features/dressups.js +0 -173
  19. package/src/framework/oracle/describe/features/holes.js +0 -129
  20. package/src/framework/oracle/describe/features/prismatic.js +0 -454
  21. package/src/framework/oracle/describe/features/sweeps.js +0 -233
  22. package/src/framework/oracle/describe/fit.js +0 -535
  23. package/src/framework/oracle/describe/hints.js +0 -91
  24. package/src/framework/oracle/describe/limits.js +0 -19
  25. package/src/framework/oracle/describe/patterns.js +0 -494
  26. package/src/framework/oracle/describe/ransac.js +0 -391
  27. package/src/framework/oracle/describe/report.js +0 -217
  28. package/src/framework/oracle/describe/segment.js +0 -498
  29. package/src/framework/oracle/describe/snap.js +0 -83
  30. package/src/framework/oracle/describe/surface-graph.js +0 -396
  31. package/src/framework/oracle/describe/topology.js +0 -121
  32. package/src/framework/oracle/describe.js +0 -643
@@ -1,643 +0,0 @@
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
- // A deterministic unit vector perpendicular to `w`: orthogonalize the world axis
184
- // with the smallest |component| along `w`. Deterministic matters — describe is memoed
185
- // by content digest, so a candidate's frame must be a pure function of the mesh.
186
- const perpTo = (w) => {
187
- const ax = Math.abs(w[0]) <= Math.abs(w[1]) && Math.abs(w[0]) <= Math.abs(w[2]) ? [1, 0, 0]
188
- : Math.abs(w[1]) <= Math.abs(w[2]) ? [0, 1, 0] : [0, 0, 1];
189
- return orthogonalize(ax, w);
190
- };
191
-
192
- // The cap's own measured boundary loops as ABSOLUTE (u,v) coordinates in the cap's
193
- // plane — the real footprint, where the box branch below can only offer the
194
- // footprint's bounding rectangle (mostly air on an L-bracket or a sword-shaped
195
- // bookmark, so the candidate loses on xor-gain and the feature reconstructs
196
- // nothing). `surface-graph.js` already chained these loops per surface; a merged
197
- // surface's list can carry OTHER islands' rims too (mergeCoFamily joins co-planar
198
- // patches that never touch), so when the feature carries a `faceScope` the loops are
199
- // filtered to those whose every vertex belongs to this island's own triangles.
200
- // The largest-area survivor is the outer contour; the rest are holes (an annular
201
- // cap's second loop — exactly the signal the hole/pocket rules already read).
202
- // Winding is normalized to CCW for both, the orientation `kernel.extrude` expects
203
- // of a contour. Returns null when no loop survives — callers fall back to the
204
- // bounding-box candidate, honest about being one.
205
- function footprintLoops(topo, cap, scopeFaces, u, v, claimedVerts) {
206
- let allowed = null;
207
- if (scopeFaces) {
208
- allowed = new Set();
209
- for (const t of scopeFaces) for (let c = 0; c < 3; c++) allowed.add(topo.tris[3 * t + c]);
210
- }
211
- const loops = [];
212
- for (const loop of cap.loops ?? []) {
213
- if (loop.length < 3) continue;
214
- if (allowed && !loop.every((vi) => allowed.has(vi))) continue;
215
- const pts = loop.map((vi) => {
216
- const pnt = [topo.verts[3 * vi], topo.verts[3 * vi + 1], topo.verts[3 * vi + 2]];
217
- return [dot3(pnt, u), dot3(pnt, v)];
218
- });
219
- let a2 = 0; // shoelace, signed — sign is the winding, magnitude ranks outer vs holes
220
- for (let i = 0; i < pts.length; i++) {
221
- const a = pts[i], q = pts[(i + 1) % pts.length];
222
- a2 += a[0] * q[1] - a[1] * q[0];
223
- }
224
- loops.push({ pts, vis: loop, area: Math.abs(a2) / 2, ccw: a2 > 0 });
225
- }
226
- if (!loops.length) return null;
227
- loops.sort((a, b) => b.area - a.area);
228
- const ccw = (l) => (l.ccw ? l.pts : [...l.pts].reverse());
229
- // An interior loop whose rim another feature CLAIMS (a detected hole's bore — the
230
- // rim ring is shared between the cap and the bore wall, so every loop vertex sits
231
- // in the bore surface's own vertex set) is left OUT of the footprint: that hole's
232
- // own cut candidate owns it. Without this, an annular cap rebuilt hole-included
233
- // leaves the hole feature nothing to explain — measured on the washer fixture,
234
- // whose through-hole's volumeShare went to null the moment footprints landed,
235
- // exactly the double-explanation this guard prevents. A parametric author would
236
- // decompose it the same way: base profile, then a bore with its own diameter.
237
- // Unclaimed interior loops (a square cutout no hole rule recognizes) stay in the
238
- // footprint — better an honest hole in the prism than 12.5% unexplained volume.
239
- const unclaimed = (l) => !claimedVerts || !l.vis.every((vi) => claimedVerts.has(vi));
240
- return { outer: ccw(loops[0]), holes: loops.slice(1).filter(unclaimed).map(ccw) };
241
- }
242
-
243
- export function describe(kernel, solid, opts = {}) {
244
- // A live Solid in, not a mesh. The kernel exposes no public mesh->solid constructor —
245
- // geometry only enters through `_registerImport` + `import(name)` — and acceptance needs
246
- // a Solid to diff against. Both real callers (the worker job and the CLI) already hold
247
- // one from `k.import(name)`, so taking the Solid and deriving the mesh here is both the
248
- // honest signature and the shorter path.
249
- let mesh;
250
- try {
251
- mesh = solid.toMesh();
252
- } catch (err) {
253
- return fail("unreadable", opts, { triangles: 0 },
254
- `solid.toMesh() threw: ${err?.message ?? err}`,
255
- `import "${opts.name ?? "?"}"`,
256
- "the geometry could not be read back off the kernel; re-export the source file and retry");
257
- }
258
- // Both backends' toMesh() carries its own triangle count directly (kernel.js's
259
- // toMesh JSDoc) — no need to re-derive it from positions/indices length.
260
- const triangles = mesh?.triangles ?? 0;
261
- if (!triangles) {
262
- return fail("empty", opts, { triangles: 0 },
263
- "the mesh has no triangles",
264
- `import "${opts.name ?? "?"}"`,
265
- "check that the `imports` source resolves to a real file; see ERROR-PATTERNS.md#describe-empty");
266
- }
267
- if (triangles > MAX_TRIANGLES) {
268
- return fail("too-large", opts, { triangles },
269
- `${triangles} triangles exceeds the ${MAX_TRIANGLES} describe limit`,
270
- `import "${opts.name ?? "?"}"`,
271
- "re-export or decimate at a coarser chord tolerance; the feature rules read surfaces, not facets");
272
- }
273
-
274
- const memo = opts.memo;
275
- const key = opts.digest ? `${opts.digest}:${opts.budget ?? DEFAULT_ATTEMPT_BUDGET}` : null;
276
- if (memo && key && memo.has(key)) return memo.get(key);
277
-
278
- const topo = buildTopology(mesh);
279
-
280
- // Every edge in a genuine solid is shared by exactly two triangles; a boundary edge
281
- // (`triB < 0`, topology.js's own convention) means the mesh still has an open seam
282
- // after vertex-merge and winding repair, so it does not bound a solid and acceptance
283
- // has nothing to diff against. Checked here, before any of the expensive stages run.
284
- const openEdges = topo.edges.reduce((n, e) => n + (e.triB < 0 ? 1 : 0), 0);
285
- if (openEdges > 0) {
286
- return fail("not-manifold", opts, { triangles, openEdges },
287
- `${openEdges} open edge${openEdges === 1 ? "" : "s"} after vertex-merge and winding repair`,
288
- `import "${opts.name ?? "?"}"`,
289
- "repair the mesh before describing it; see ERROR-PATTERNS.md#describe-not-manifold");
290
- }
291
-
292
- const { patches, unassigned } = segment(topo);
293
- const graph = surfaceGraph(topo, patches);
294
-
295
- // ONE BVH for this call, shared by every stage that needs one (controller ruling R39).
296
- // `detectSweeps`'s shell rule raycasts inward from each plane, and `buildBVH` is
297
- // O(n log n) over the WHOLE mesh regardless of how few rays are cast — measured at 9.8ms
298
- // on 10.8k triangles and 48ms on 43k. Letting each stage build its own would pay that
299
- // repeatedly for nothing. This is the same caller-owned-cache pattern `measure.js` uses
300
- // to stop min-wall and meshGaps indexing the same mesh twice; see `cachedBVH`'s own
301
- // comment for why a caller-owned Map rather than a module-level WeakMap.
302
- const bvh = buildBVH({ positions: topo.verts, indices: topo.tris });
303
-
304
- // Feature families run in a fixed order and their results are concatenated in that
305
- // order, then sorted by each rule's own geometry-derived `key`. So f-numbering depends
306
- // on the MESH, never on iteration order or on which family happened to run first.
307
- const raw = [
308
- ...detectHoles(graph),
309
- ...detectDressups(graph),
310
- ...detectPrismatic(graph, topo),
311
- ...detectSweeps(graph, topo, { bvh }),
312
- ].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
313
-
314
- const features = raw.map((f, i) => {
315
- const snapped = {};
316
- if (Number.isFinite(f.diameter)) { const s = snapHoleDiameter(f.diameter); if (s) snapped.diameter = s; }
317
- for (const k of ["depth", "radius", "width", "thickness"]) {
318
- if (Number.isFinite(f[k])) { const s = snapValue(f[k]); if (s) snapped[k] = s; }
319
- }
320
- return { ...f, id: `f${i}`, snapped };
321
- });
322
-
323
- const b = bounds(mesh.positions);
324
- const { patterns, symmetry } = detectPatterns(features, b);
325
-
326
- // Candidates for acceptance, in the order the rules produced them. `featureKey` is what
327
- // hints.js joins against to collapse a pattern's members into one step. `surfById` and
328
- // `topo` let a prismatic candidate orient itself onto THAT FEATURE'S OWN frame and
329
- // extent (see toCandidate's own comment) instead of assuming the extrusion runs along
330
- // world Z, or — round 2 review's CRITICAL finding — reading the whole mesh's bounds
331
- // for every feature and building every candidate the same full-part size.
332
- const surfById = new Map(graph.surfaces.map((s) => [s.id, s]));
333
- // Every vertex belonging to a surface some HOLE feature claims — the set
334
- // footprintLoops consults so a footprint never re-explains a rim a hole's own cut
335
- // candidate owns (see its comment for the washer measurement that forced this).
336
- const claimedVerts = new Set();
337
- for (const f of features) {
338
- if (f.type !== "throughHole" && f.type !== "blindHole") continue;
339
- for (const id of f.surfaces ?? []) {
340
- const surf = surfById.get(id);
341
- for (const t of surf?.faces ?? []) {
342
- for (let c = 0; c < 3; c++) claimedVerts.add(topo.tris[3 * t + c]);
343
- }
344
- }
345
- }
346
- const candidates = features
347
- .map((f) => toCandidate(kernel, f, b, { surfById, topo, claimedVerts }))
348
- .filter(Boolean);
349
-
350
- const graded = acceptCandidates(kernel, solid, candidates, { budget: opts.budget });
351
- // featureKey -> the candidate `toCandidate` proposed for it, if any — what tells a
352
- // feature with `volumeShare: null` apart into WHY (fix round 2, IMPORTANT 2, below).
353
- const candidateByFeatureKey = new Map(candidates.map((c) => [c.featureKey, c]));
354
-
355
- const totalArea = meshArea(mesh.positions, mesh.indices);
356
- const explainedArea = totalArea > 0
357
- ? graph.surfaces.reduce((a, s) => a + s.area, 0) / totalArea
358
- : 0;
359
- const residualArea = unassigned.reduce((a, t) => a + topo.faceArea[t], 0);
360
-
361
- const report = buildReport({
362
- source: { name: opts.name ?? null, digest: opts.digest ?? null, triangles, watertight: true },
363
- bounds: b,
364
- surfaces: graph.surfaces.map((s) => ({
365
- id: s.id, type: s.type, area: s.area, triangles: s.faces.length,
366
- rms: s.fit.rms, maxDev: s.fit.maxDev, fit: s.fit,
367
- })),
368
- arcs: graph.arcs,
369
- // NAMED `volumeShare`, not `confidence` (round 2 review, IMPORTANT): the value
370
- // is accept.js's own volume-normalised marginal xor-volume gain — how much of
371
- // the PART'S VOLUME this feature accounts for, not how sure the description is.
372
- // A "confidence" label reads backwards for exactly the features a rebuilder
373
- // most needs to trust: a precise 3mm hole in a large plate is CERTAIN (its fit
374
- // rms is tiny) but SMALL, so it legitimately reports a low share — see
375
- // `score.note` for the same point stated for a reader who never gets this far.
376
- // `faceScope` (prismatic.js) is internal plumbing for THIS file's own candidate
377
- // builder — a feature's floor/wall surfaces reduced to per-triangle index arrays
378
- // — and must never reach a model-facing report (round 4 review, IMPORTANT):
379
- // measured at 31% of an entire compact report's bytes for one feature's own
380
- // bookkeeping on a 476-triangle fixture, scaling with mesh complexity up to the
381
- // 400k-triangle MAX_TRIANGLES ceiling. Stripped HERE, not left for buildReport/
382
- // compactDescribe to remember to delete — those two are downstream of every
383
- // detector, not just this one, so a future per-triangle field on a different
384
- // detector would leak the same way unless every consumer had to opt in
385
- // separately. Chose stripping over a side channel (a second detectPrismatic
386
- // return value, or a Map keyed by feature key) because `detectPrismatic`'s
387
- // return shape is a plain feature array read directly by a dozen existing call
388
- // sites (this file's own tests, describe-features-prismatic.test.js,
389
- // describe-features-key-stability.test.js) — changing that contract to thread a
390
- // second value through is a real, unforced breaking change for every one of
391
- // them, whereas destructuring one field out at its only egress point here is
392
- // not. `candidates` (below) is built from the PRE-strip `features` array, so
393
- // toCandidate still reads every feature's own `faceScope`.
394
- // `volumeShare: null` alone does not say WHY (fix round 2, IMPORTANT 2): three
395
- // genuinely different situations all used to collapse onto it indistinguishably —
396
- // a feature type `toCandidate` never proposes at all (fillet/chamfer/revolve/
397
- // shell — see its own trailing comment), one that WAS proposed but the search
398
- // never reached before running out of `--budget`, and one that WAS reached and
399
- // built but never won a round (accept.js's own MIN_GAIN_FRACTION gate, or simply
400
- // never the best candidate that round). A rebuilder needs to tell these apart —
401
- // "not modelled by this tool at all" vs. "try a bigger budget" vs. "this genuinely
402
- // doesn't fit" are different next actions. `volumeShareReason` names which one, a
403
- // closed set of `"not-proposed" | "budget" | "rejected"`, null only when
404
- // `volumeShare` itself is non-null (see accept.js's `attempted` Set for exactly
405
- // what "rejected" does and doesn't distinguish within itself).
406
- features: features.map(({ faceScope: _faceScope, ...f }) => {
407
- const accepted = graded.accepted.find((a) => a.candidate.featureKey === f.key);
408
- if (accepted) return { ...f, volumeShare: accepted.gain, volumeShareReason: null };
409
- const candidate = candidateByFeatureKey.get(f.key);
410
- const reason = !candidate ? "not-proposed" : graded.attempted.has(candidate) ? "rejected" : "budget";
411
- return { ...f, volumeShare: null, volumeShareReason: reason };
412
- }),
413
- patterns, symmetry,
414
- residual: {
415
- areaFraction: totalArea > 0 ? residualArea / totalArea : 0,
416
- regions: residualRegions(topo, unassigned),
417
- },
418
- score: {
419
- // TWO DIFFERENT MEASUREMENTS, and conflating them breaks the report's honesty
420
- // property (controller ruling R45). `explainedArea` is how much of the mesh SURFACE
421
- // segmentation fitted to some analytic primitive. `explainedVolumeFraction` is how
422
- // much of the part's SHAPE the accepted features actually reconstruct. They diverge
423
- // hard: a hemisphere dome segments to 1.0 area coverage and reconstructs 0.0 of its
424
- // volume, because a sphere is not a candidate-eligible type. Both must be carried —
425
- // the low-coverage banner gates on the WORSE of the two, since an agent rebuilding a
426
- // part cares whether the shape is accounted for, not whether primitives were fitted.
427
- explainedArea,
428
- explainedVolumeFraction: graded.score.explainedVolumeFraction,
429
- xorFraction: graded.score.xorFraction,
430
- xorVolume: graded.score.xorVolume,
431
- },
432
- suggestion: buildHints(graded.accepted, patterns, b),
433
- });
434
- if (graded.budgetExceeded) report.warning = "budget-exceeded";
435
-
436
- if (memo && key) memo.set(key, report);
437
- return report;
438
- }
439
-
440
- // Unassigned faces grouped into connected islands, each reported with its own extent.
441
- // A count alone tells the agent nothing actionable; "290 triangles, here" does.
442
- function residualRegions(topo, unassigned) {
443
- const pool = new Set(unassigned), out = [];
444
- for (const seed of unassigned) {
445
- if (!pool.has(seed)) continue;
446
- const stack = [seed], faces = [];
447
- pool.delete(seed);
448
- while (stack.length) {
449
- const t = stack.pop();
450
- faces.push(t);
451
- for (const ei of topo.faceEdges[t]) {
452
- const e = topo.edges[ei];
453
- const nb = e.triA === t ? e.triB : e.triA;
454
- if (nb >= 0 && pool.has(nb)) { pool.delete(nb); stack.push(nb); }
455
- }
456
- }
457
- const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
458
- const c = [0, 0, 0];
459
- for (const t of faces) for (let k = 0; k < 3; k++) {
460
- const v = topo.tris[3*t + k] * 3;
461
- for (let a = 0; a < 3; a++) {
462
- const val = topo.verts[v + a];
463
- if (val < lo[a]) lo[a] = val;
464
- if (val > hi[a]) hi[a] = val;
465
- c[a] += val / (faces.length * 3);
466
- }
467
- }
468
- out.push({ triangles: faces.length, centroid: c, bounds: { min: lo, max: hi } });
469
- }
470
- return out.sort((a, b) => b.triangles - a.triangles);
471
- }
472
-
473
- // One acceptance candidate per feature. `build` is a thunk so nothing is materialised
474
- // for a candidate the greedy loop never reaches. `ctx.surfById` (a graph.surfaces
475
- // lookup) and `ctx.topo` (the welded mesh topology, for reading a SPECIFIC feature's
476
- // own surfaces' vertices via `surfaceVertices` — never the whole mesh's) are what let
477
- // a candidate read the part's OWN frame and THAT FEATURE'S OWN extent, rather than
478
- // assuming a frame or reading the whole-mesh bbox for every feature alike; see the
479
- // extrusion/boss branch's own comment for both failure modes this avoids.
480
- //
481
- // kernel.box/kernel.cylinder take OPTIONS OBJECTS ({size:[...]}/{min,max} and {r,h}) —
482
- // the positional legacy forms are silently accepted by the kernel front-end but resolve
483
- // to a DIFFERENT signature (box(min,max); cylinder(rBottom,rTop,h)) and hand back a
484
- // zero-volume solid rather than erroring. Verified directly against a live kernel:
485
- // `kernel.box(60,40,12).volume()` and `kernel.cylinder(2.65,40).volume()` both read 0.
486
- function toCandidate(kernel, f, b, ctx) {
487
- const size = [0,1,2].map((i) => b.max[i] - b.min[i]);
488
- if (f.type === "throughHole" || f.type === "blindHole") {
489
- const depth = f.type === "throughHole" ? Math.max(...size) * 2 : f.depth;
490
- // Oriented along the hole's OWN axis, not world Z — a bore can point anywhere once
491
- // the part is rotated (alignZTo's own comment has the full reasoning).
492
- const rot = alignZTo(f.axis.direction);
493
- return {
494
- key: f.key, featureKey: f.key, op: "cut", explains: [f.id],
495
- dimension: f.diameter, paramName: "holeDia", hintOp: "cut",
496
- hintArgs: { shape: "cylinder", diameter: f.diameter, depth },
497
- build: () => {
498
- const cyl = kernel.cylinder({ r: f.diameter / 2, h: depth });
499
- const oriented = rot ? cyl.rotate(rot.deg, [0, 0, 0], rot.axis) : cyl;
500
- return oriented.translate([
501
- f.axis.origin[0] - f.axis.direction[0] * depth / 2,
502
- f.axis.origin[1] - f.axis.direction[1] * depth / 2,
503
- f.axis.origin[2] - f.axis.direction[2] * depth / 2,
504
- ]);
505
- },
506
- };
507
- }
508
- // Pocket candidates (round 3 review — a widening, not part of the island-merge fix
509
- // itself): a pocket's own island (floor + walls) bounds its recessed geometry exactly
510
- // the same way a boss's bounds its protruding geometry — `projectedBounds` reads
511
- // actual vertex extent, agnostic to which side of the surrounding surface it falls
512
- // on — so the SAME box/cylinder construction below works unchanged; only `op` (cut,
513
- // not union) and `hintOp` differ. Previously pockets were described but never
514
- // proposed as candidates at all (Task 12's original scope note, kept below on the
515
- // fillet/chamfer/revolve/shell types that still are), which is why a part with a
516
- // pocket used to always read as at least partially unexplained even when perfectly
517
- // segmented — not a bug this file introduced, but a gap this fix's own "two
518
- // same-height pockets" regression test would otherwise misreport.
519
- if (f.type === "extrusion" || f.type === "boss" || f.type === "pocket") {
520
- const op = f.type === "pocket" ? "cut" : "union";
521
- // Oriented onto the part's OWN extrusion frame, not the WORLD-axis-aligned bbox —
522
- // measured directly against a box+bore fixture rotated 29° about an oblique axis:
523
- // a bbox-aligned candidate built from the rotated mesh's own (now-larger, tilted)
524
- // AABB scored a NEGATIVE xor-volume gain (candidate 52796mm³ vs a 28535mm³ source,
525
- // intersecting only 6632mm³) and was correctly rejected — leaving the base
526
- // extrusion, and therefore the bore that can only be cut FROM it (acceptCandidates'
527
- // loop never attempts a `cut` against a null base), unexplained: 0% of the part's
528
- // volume, not merely a worse fit. Reading the true frame off one of this feature's
529
- // own wall surfaces (below) reproduces a ~28800mm³ candidate containing ~99.999%
530
- // of the 28535mm³ source and restores the same ~100% reconstruction the
531
- // axis-aligned case already gets.
532
- const direction = unit3(f.direction);
533
- if (f.profile.kind === "circle") {
534
- // Rotationally symmetric about its own axis, so — unlike the box branch below —
535
- // no roll correction is needed, only the axis itself: the same alignZTo a hole
536
- // uses. Reads the true axis (and its exact base point) off this boss's own
537
- // cylindrical wall surface when the graph has one; falls back to the bbox-centre
538
- // approximation only when it doesn't (e.g. a bare disc with no side wall fitted).
539
- const wallCyl = ctx?.surfById && f.wallFaces
540
- ? f.wallFaces.map((id) => ctx.surfById.get(id)).find((s) => s?.type === "cylinder")
541
- : null;
542
- const axisDir = wallCyl ? unit3(wallCyl.fit.axis.direction) : direction;
543
- // fit.js's `extent` is always [min, max] along the axis (fitCylinder sorts it),
544
- // so index 0 is the wall's own lower/base end regardless of which way the
545
- // fitted axis direction happens to point.
546
- const base = wallCyl
547
- ? add3(wallCyl.fit.axis.origin, scale3(axisDir, wallCyl.fit.extent[0]))
548
- : [b.min[0] + size[0]/2, b.min[1] + size[1]/2, b.min[2]];
549
- const rot = alignZTo(axisDir);
550
- return {
551
- key: f.key, featureKey: f.key, op, explains: [f.id],
552
- dimension: f.depth, paramName: "height", hintOp: f.type === "boss" ? "union" : f.type === "pocket" ? "cut" : "box",
553
- hintArgs: { shape: "circle", depth: f.depth },
554
- build: () => {
555
- const cyl = kernel.cylinder({ r: f.profile.radius, h: f.depth });
556
- const oriented = rot ? cyl.rotate(rot.deg, [0, 0, 0], rot.axis) : cyl;
557
- return oriented.translate(base);
558
- },
559
- };
560
- }
561
- // Polygon/mixed profile: `profileOf` (prismatic.js) reports only a POINT COUNT for
562
- // a polygon, never its vertices, so the footprint's own IN-PLANE rotation (its
563
- // "roll" about `direction`) cannot be read from the profile fact. It CAN be read
564
- // off one of this feature's own wall PLANES: `isSideWallOf` (prismatic.js)
565
- // guarantees every wall normal is already perpendicular to `direction`, which is
566
- // exactly one of this box's own true edge directions. Depth and footprint size
567
- // then come from THIS FEATURE'S OWN vertices (its `floorFace` cap plus its
568
- // `wallFaces`, via `surfaceVertices` — NOT the whole mesh; round 2 review's
569
- // CRITICAL finding, see that function's own comment for the two-box repro)
570
- // projected onto that exact (u, v, direction) frame — `projectedBounds`, this
571
- // file's general-frame twin of mesh.js's `bounds()` — rather than the world-axis
572
- // bbox `size`/`f.depth` used above for the (rotation-insensitive) circle case.
573
- // Loop-based candidate first: the cap's own measured boundary loops ARE the
574
- // footprint (footprintLoops above), so when they are available the candidate is
575
- // the real prism — outer contour extruded, hole contours honoured — rather than
576
- // either rectangle below. The frame's in-plane axis is arbitrary (perpTo): the
577
- // loop coordinates are absolute projections onto (u, v), so any orthonormal pair
578
- // perpendicular to `direction` reproduces the same world-space solid after
579
- // orientOnto. Depth still comes from THIS FEATURE'S OWN vertices projected onto
580
- // `direction` (the same projectedBounds discipline as the box branch, round 2
581
- // review's CRITICAL finding). A candidate whose loops were mis-chained or span a
582
- // merged surface's other island simply scores a poor xor-gain and is rejected —
583
- // the same honesty the box fallback has always leaned on.
584
- const cap = ctx?.surfById?.get(f.floorFace);
585
- if (cap?.loops?.length && ctx?.topo) {
586
- const u = perpTo(direction);
587
- const v = cross3(direction, u);
588
- const fp = footprintLoops(ctx.topo, cap, f.faceScope?.[f.floorFace], u, v, ctx.claimedVerts);
589
- if (fp) {
590
- const ownSurfaces = [f.floorFace, ...(f.wallFaces ?? [])].filter(Boolean);
591
- return {
592
- key: f.key, featureKey: f.key, op, explains: [f.id],
593
- dimension: f.depth, paramName: "height", hintOp: f.type === "boss" ? "union" : f.type === "pocket" ? "cut" : "box",
594
- hintArgs: { shape: f.profile.kind, depth: f.depth },
595
- build: () => {
596
- const verts = surfaceVertices(ctx.topo, ctx.surfById, ownSurfaces, f.faceScope);
597
- const bnd = projectedBounds(verts, [u, v, direction]);
598
- const local = kernel.extrude({ profile: { outer: fp.outer, holes: fp.holes }, h: bnd.max[2] - bnd.min[2] });
599
- const oriented = orientOnto(local, direction, u);
600
- return oriented.translate(scale3(direction, bnd.min[2]));
601
- },
602
- };
603
- }
604
- }
605
- const wallPlane = ctx?.surfById && f.wallFaces
606
- ? f.wallFaces.map((id) => ctx.surfById.get(id)).find((s) => s?.type === "plane")
607
- : null;
608
- if (!wallPlane || !ctx?.topo) {
609
- // No wall plane to read a true in-plane axis from (or no topology handed in)
610
- // — fall back to the axis-aligned bbox approximation, honest about being one:
611
- // still worth proposing, since a low-gain candidate is simply never accepted
612
- // (acceptCandidates' own MIN_GAIN_FRACTION gate), never a false positive.
613
- return {
614
- key: f.key, featureKey: f.key, op, explains: [f.id],
615
- dimension: f.depth, paramName: "height", hintOp: f.type === "boss" ? "union" : f.type === "pocket" ? "cut" : "box",
616
- hintArgs: { shape: f.profile.kind, depth: f.depth },
617
- build: () => kernel.box({ min: b.min, max: [b.min[0] + size[0], b.min[1] + size[1], b.min[2] + f.depth] }),
618
- };
619
- }
620
- const u = orthogonalize(wallPlane.fit.normal, direction);
621
- const v = cross3(direction, u);
622
- const ownSurfaces = [f.floorFace, ...(f.wallFaces ?? [])].filter(Boolean);
623
- return {
624
- key: f.key, featureKey: f.key, op, explains: [f.id],
625
- dimension: f.depth, paramName: "height", hintOp: f.type === "boss" ? "union" : f.type === "pocket" ? "cut" : "box",
626
- hintArgs: { shape: f.profile.kind, depth: f.depth },
627
- build: () => {
628
- const verts = surfaceVertices(ctx.topo, ctx.surfById, ownSurfaces, f.faceScope);
629
- const bnd = projectedBounds(verts, [u, v, direction]);
630
- const [uSize, vSize, depth] = [0, 1, 2].map((i) => bnd.max[i] - bnd.min[i]);
631
- const local = kernel.box({ min: [0, 0, 0], max: [uSize, vSize, depth] });
632
- const oriented = orientOnto(local, direction, u);
633
- const origin = add3(add3(scale3(u, bnd.min[0]), scale3(v, bnd.min[1])), scale3(direction, bnd.min[2]));
634
- return oriented.translate(origin);
635
- },
636
- };
637
- }
638
- // Fillets, chamfers, revolves and shells are described but not yet proposed as
639
- // acceptance candidates: each needs an edge or profile selector the facts layer does
640
- // not yet carry, and a candidate that cannot be built is worse than none. They still
641
- // appear in `features` with a null volumeShare, which is the honest report.
642
- return null;
643
- }