partforge 0.77.0 → 0.79.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,498 @@
1
+ // Mesh faces -> primitive patches. The classic reverse-engineering segmentation,
2
+ // implemented rather than invented (spec "Prior art"): seed in normal space, grow
3
+ // on the dual graph under a primitive predicate, refit as the region grows, repeat
4
+ // to stability.
5
+ //
6
+ // Why BOTH seeding and growing, when either alone half-works: Gauss-map bucketing
7
+ // alone cannot separate two parallel planes at different offsets, and it shreds a
8
+ // tessellated cylinder into one bucket per facet. Region growing alone has no idea
9
+ // where to start and picks up whatever its arbitrary seed happened to touch. Seeds
10
+ // give growth a well-conditioned starting hypothesis; growth gives seeds their
11
+ // spatial coherence. This is the same structure Efficient RANSAC and VSA arrive at
12
+ // from different directions.
13
+ //
14
+ // The patches this produces are CANDIDATES, not truth (spec §2.8) — accept.js
15
+ // decides what is real. So a slightly over-eager grow here is recoverable, and the
16
+ // tolerances lean permissive on purpose.
17
+ //
18
+ // Pure leaf. See spec §2.3.
19
+ import { fitPlane, fitCylinder, fitCone, fitSphere, fitTorus, deviationOf, intrinsicFrame } from "./fit.js";
20
+ import { ransacPatches } from "./ransac.js";
21
+
22
+ // Fit acceptance band, as a fraction of the mesh's own characteristic length
23
+ // (`intrinsicFrame`'s `diagonal`, fit.js). A CAD tessellation's chord error is
24
+ // bounded and small; this sits an order of magnitude above it so faceting never
25
+ // breaks a surface apart, and well below any real feature size so two genuinely
26
+ // different surfaces never merge.
27
+ // Exported: surface-graph.js's post-merge residual guard needs the identical
28
+ // fraction, not a second constant that could quietly drift from this one — a
29
+ // merged patch is only trustworthy if it clears the SAME acceptance band region
30
+ // growing itself used to validate its two halves.
31
+ //
32
+ // The value below (fix round 5) is a ONE-TIME DELIBERATE RETUNE, not an
33
+ // invariant, and is documented as such rather than left as `3e-4` unexplained.
34
+ // This constant was originally tuned against `diagonal` computed as a
35
+ // bounding-box extent (a max-extent measure); `diagonal` is now a radius of
36
+ // gyration (`2 * sqrt(trace/n)`, an RMS measure — fit.js's `intrinsicFrame`),
37
+ // which reads SMALLER on the same mesh, and fit.js deliberately does not
38
+ // compensate for that itself (that function's own comment explains why: the
39
+ // ratio between the two measures is shape-dependent, so no single constant
40
+ // folded into the primitive could keep every part's effective tolerance
41
+ // unchanged; `filletedBox` alone needed a 9% different correction than the
42
+ // reference shape this value IS calibrated against). Measured on that
43
+ // reference shape (a 30x20x14mm hollow box, already correct — asymmetric, no
44
+ // PCA degeneracy — under the OLD bbox-diagonal formula): old diagonal
45
+ // 38.678159mm, new (bare radius-of-gyration) diagonal 35.552778mm, ratio
46
+ // 1.0879082239773115. `3e-4 * 1.0879082239773115 = 3.263725e-4` is the value
47
+ // that reproduces the SAME effective millimetre tolerance on THAT shape as the
48
+ // original `3e-4` did against the old, larger `diagonal` — carrying the old
49
+ // tuning forward onto the new scale, not preserving it everywhere in general.
50
+ // Other shapes will see a real, expected, previously-reported shift (see
51
+ // task-15-report.md, fix rounds 3-4) — this is a documented starting point for
52
+ // the constant, not a claim that segmentation is now unchanged.
53
+ export const FIT_TOL_FRAC = 3.263725e-4;
54
+ // Reserved dial, not currently load-bearing: every seed is a single triangle,
55
+ // three points always define SOME plane exactly (rms/maxDev = 0), so `bestFit`
56
+ // (with MIN_PTS.plane = 3) can only fail on a seed whose points are so close to
57
+ // collinear that fit.js's own rank guard rejects it as degenerate — everything
58
+ // else clears `faces.length < MIN_PATCH_FACES` at its default of 1 trivially.
59
+ // Left in place (rather than deleted) as the one knob a future revision would
60
+ // raise to also discard small-but-valid patches as noise; it does nothing at 1.
61
+ const MIN_PATCH_FACES = 1;
62
+ const REFIT_ROUNDS = 3;
63
+
64
+ // Fold-angle ceiling for a growth candidate's shared edge, in radians (30°).
65
+ // This is what keeps growth from ever crossing a genuine surface boundary, and
66
+ // it has to be checked BEFORE the adaptive tolerance below is allowed to widen
67
+ // anything: unlike a chord-error tolerance, a dihedral angle is SCALE-INVARIANT
68
+ // under retessellation — a cylinder wall's own internal facet-to-facet fold is
69
+ // 2π/segs and shrinks toward zero as the mesh gets finer, while a genuine
70
+ // cylinder-to-cap edge stays at 90° no matter how fine the mesh gets. Gating on
71
+ // this first is what makes it safe to widen tolerance adaptively next: without
72
+ // this gate, the adaptive term below would inflate the tolerance most at
73
+ // exactly the sharp edges it must never cross (a large dihedral would imply a
74
+ // large permitted deviation, backwards from what's needed). `topology.js`
75
+ // already computes signed dihedrals per edge, so this check costs nothing extra.
76
+ const SMOOTH_DIHEDRAL_MAX = Math.PI / 6;
77
+
78
+ // Scales the adaptive tolerance below. Calibrated, not guessed: measured the
79
+ // ACTUAL worst-vertex deviation of a wall facet's true first neighbour against
80
+ // `w * dihedral` (see `leverArm`) across cylinderMesh(4, 10, N) for N = 16..240
81
+ // and found the two track each other almost exactly (ratio 0.99-1.00 at every
82
+ // N tried) — `w * dihedral` IS the chord sagitta this predicate needs to admit,
83
+ // not merely proportional to it. 1.5 keeps a real margin above that measured
84
+ // 1:1 correspondence without weakening the dihedral gate above, which is what
85
+ // actually protects genuinely different surfaces from merging.
86
+ const FACET_K = 1.5;
87
+
88
+ // `FACET_K * leverArm * |dihedral|` has NO ceiling: leverArm grows with facet
89
+ // size without limit, so a large, coarsely-tessellated flat face (exactly the
90
+ // shape a CAD exporter emits for a big plane — ONE quad) gets an arbitrarily
91
+ // loose effective tolerance at a FIXED fold angle. Measured directly: two flat
92
+ // quads hinged at 10°-29° merge into a single mis-typed "cylinder" patch at
93
+ // every facet width from 5 to 100mm, a regression this dihedral+adaptive
94
+ // design introduced (verified absent on the pre-adaptive code). The dihedral
95
+ // gate alone can't catch this — 10-29° is exactly the range it's SUPPOSED to
96
+ // let through for real curvature.
97
+ //
98
+ // The physical distinction: on a genuinely tessellated curve, `leverArm /
99
+ // dihedral` (the IMPLIED RADIUS of curvature — see `impliedRadius`) is close
100
+ // to CONSTANT and density-independent: every internal edge implies close to
101
+ // the same radius, because that's what "the same curved surface, sampled at
102
+ // different densities" means. On a flat-to-flat fold, that same ratio is
103
+ // UNBOUNDED (a fixed design angle over an arbitrarily large facet), and
104
+ // crucially there is only ONE such edge — a real polygon corner borders
105
+ // exactly one other face along it, alone, with no repetition. A genuinely
106
+ // curved patch, by contrast, always has at least one MORE internal edge with
107
+ // a closely matching implied radius and sign nearby (the facet on the
108
+ // curve's other side), because the curvature that produced this edge's fold
109
+ // didn't stop existing one facet later.
110
+ //
111
+ // So the adaptive allowance is granted only when the candidate edge's fold
112
+ // has a WITNESS: another edge of the same sign and comparable IMPLIED RADIUS,
113
+ // either already confirmed inside the growing patch, or discovered in the
114
+ // very same growth pass (see `established`/`sameFamily` in `segment`). A lone
115
+ // fold, however permissive the raw sagitta arithmetic would allow, never
116
+ // gets one.
117
+ //
118
+ // Comparing on implied radius rather than on raw dihedral matters, not just
119
+ // as a style choice: measured directly on a flat plane tangent (G1-smooth) to
120
+ // a cylinder wall, sharing one edge with it — the FIRST-order tangent
121
+ // transition edge has EXACTLY HALF the dihedral of the wall's own internal
122
+ // wall-to-wall edges (1.875° vs 3.75° at N=96; 0.9626° vs 1.925° at N=187 —
123
+ // consistently a factor of 2, at every density tried, so this is a general
124
+ // property of a tangent boundary, not a fixture artifact), while its
125
+ // `leverArm` is IDENTICAL to the internal edge's. Raw-dihedral matching
126
+ // would treat "half the internal step" as plausibly the same family (well
127
+ // within a loose ratio bound) and merge the flat plane into the cylinder
128
+ // through a small leaked chunk — reproduced directly before this fix.
129
+ // Implied radius makes the gap explicit and large: the tangent edge's
130
+ // leverArm/dihedral comes out at ~2x the wall's own true radius (~8mm vs the
131
+ // fixture's actual r=4mm) at every density tried, a robust, structural
132
+ // distinction (a G1-tangent boundary is HALF a facet-step removed from the
133
+ // curve's own interior sampling, definitionally) rather than a coincidence
134
+ // of one test case.
135
+ //
136
+ // `MATCH_RATIO` sits between that measured 1x (genuine internal consistency)
137
+ // and 2x (the tangent-boundary artifact) gap: loose enough that a uniformly
138
+ // tessellated surface's implied radius (identical edge to edge on every
139
+ // fixture measured) clears it with margin, tight enough that the 2x tangent
140
+ // artifact does not.
141
+ const MATCH_RATIO = 1.5;
142
+
143
+ // The radius of curvature this edge's fold IMPLIES, if it were one facet's
144
+ // worth of sampling a circular arc: `leverArm` (the chord's perpendicular
145
+ // throw) over the fold angle, since for small angles the two are related by
146
+ // `leverArm ≈ R * dihedral`. Meaningless (and never called) for a flat
147
+ // (dihedral = 0) edge.
148
+ const impliedRadius = (leverArm, dihedral) => leverArm / Math.abs(dihedral);
149
+
150
+ // Same sign (same convexity direction) and within a MATCH_RATIO factor of
151
+ // each other in IMPLIED RADIUS — the corroboration test `established`/
152
+ // `pending` signatures call to decide whether a candidate edge's fold is "the
153
+ // same curvature sampled again" rather than an unrelated coincidence (a
154
+ // design corner, or a tangent boundary onto a DIFFERENT surface — see the
155
+ // comment on MATCH_RATIO for why implied radius, not raw dihedral, is what
156
+ // gets compared).
157
+ function sameFamily(sigA, sigB) {
158
+ if (sigA.sign !== sigB.sign) return false;
159
+ const ratio = sigA.R / sigB.R;
160
+ return ratio >= 1 / MATCH_RATIO && ratio <= MATCH_RATIO;
161
+ }
162
+
163
+ // The corroboration signature for one nonzero-dihedral edge: its convexity
164
+ // sign and its implied radius. Built once, wherever an edge is about to enter
165
+ // `established` or `pending`, rather than re-derived ad hoc at each compare.
166
+ const familySignature = (edge, arm) => ({ sign: Math.sign(edge.dihedral), R: impliedRadius(arm, edge.dihedral) });
167
+
168
+ // All five candidates over the same trial set, in ascending order of degrees of
169
+ // freedom. Two different policies read this list for two different purposes
170
+ // below (`bestFit` vs `growthFit`) — see the comment on `growthFit` for why one
171
+ // list needs two different consumers rather than one.
172
+ function candidateFits(pts, normals) {
173
+ return [fitPlane(pts), fitCylinder(pts, normals), fitCone(pts, normals), fitSphere(pts), fitTorus(pts, normals)];
174
+ }
175
+
176
+ // FINAL classification, once a patch has stopped growing: the FIRST candidate
177
+ // (ascending DOF) that fits within tolerance, never the best-scoring one. A plane
178
+ // is a degenerate cylinder of infinite radius and a cylinder is a degenerate cone
179
+ // of zero angle, so "best RMS" would routinely dress a flat face as a huge-radius
180
+ // cylinder and produce a technically-accurate, semantically-useless report.
181
+ function bestFit(pts, normals, tol) {
182
+ for (const f of candidateFits(pts, normals)) if (f && f.maxDev <= tol) return f;
183
+ return null;
184
+ }
185
+
186
+ // GROWTH driver: of the candidates within tolerance, the one with the SMALLEST
187
+ // residual — deliberately NOT `bestFit`'s ascending-DOF policy, and this is not
188
+ // a stylistic difference, it is the difference between a segmenter that finds
189
+ // cylinders and one that cannot. Every smooth surface is locally flat: a patch
190
+ // of only a few facets sits well inside a tangent plane's chord tolerance no
191
+ // matter what it will turn out to be, so `bestFit` calls it a plane the moment
192
+ // it is asked, before there is enough of the patch gathered to tell a genuine
193
+ // flat from the first few facets of a cylinder. If THAT verdict is what drives
194
+ // growth (candidates tested against it via `faceDeviation`), the region cannot
195
+ // escape it: every newly admitted face must itself pass the same plane test, so
196
+ // the accumulated set can never carry enough curvature to invalidate its own
197
+ // classification — growth halts exactly at the tangent plane's tolerance
198
+ // boundary, one facet short of ever trying the cylinder that was available the
199
+ // whole time (verified empirically during this task: a tessellated cylinder
200
+ // wall seeded and refit under `bestFit` converges to a stable ~3-facet "plane"
201
+ // and then permanently stops growing, regardless of tessellation fineness,
202
+ // because the growth test and the classification test were the same policy).
203
+ // A true surface's residual falls toward zero as its patch grows (more of a
204
+ // real cylinder is still exactly a cylinder), while an increasingly strained
205
+ // plane's residual grows with it — the two curves cross well before the
206
+ // plane's tolerance is exhausted, so picking the smaller one lets growth adopt
207
+ // the cylinder as soon as there is enough data to prefer it, and keep going.
208
+ // `bestFit` still gets the final say (see below): once growth has converged,
209
+ // re-running its ascending-DOF policy on the finished point set is what stops
210
+ // a genuinely flat patch (whose non-plane candidates fail outright on
211
+ // degenerate normals, or the same trap risk elsewhere) from being reported as
212
+ // whatever curved thing this driver preferred mid-growth.
213
+ function growthFit(pts, normals, tol) {
214
+ let best = null;
215
+ for (const f of candidateFits(pts, normals)) {
216
+ if (f && f.maxDev <= tol && (!best || f.maxDev < best.maxDev)) best = f;
217
+ }
218
+ return best;
219
+ }
220
+
221
+ const faceNormalOf = (topo, t) => [topo.faceNormal[3*t], topo.faceNormal[3*t+1], topo.faceNormal[3*t+2]];
222
+
223
+ // All three vertices of a face, so a fit sees the real surface rather than a cloud
224
+ // of centroids — a cylinder fitted from centroids alone comes out systematically
225
+ // under-radius by the sagitta of one facet.
226
+ //
227
+ // Exported: this is the same helper `ransac.js` and `surface-graph.js` both need
228
+ // (a point/normal cloud for a face set is not specific to region growing), and it
229
+ // used to exist as three near-identical private copies. `ransac.js` imports it
230
+ // from here rather than keeping its own — a function declaration, not a `const`,
231
+ // specifically so the resulting segment.js <-> ransac.js circular import resolves
232
+ // cleanly (function declarations are hoisted and live before either module's
233
+ // top-level code runs, unlike a `const` binding, which would be in its temporal
234
+ // dead zone at the point the cycle closes).
235
+ export function facePoints(topo, faces) {
236
+ const pts = [], normals = [];
237
+ for (const t of faces) {
238
+ const n = faceNormalOf(topo, t);
239
+ for (let k = 0; k < 3; k++) {
240
+ const v = topo.tris[3*t + k] * 3;
241
+ pts.push([topo.verts[v], topo.verts[v+1], topo.verts[v+2]]);
242
+ normals.push(n);
243
+ }
244
+ }
245
+ return { pts, normals };
246
+ }
247
+
248
+ // Worst distance from a face's three vertices to a fitted primitive. The growth
249
+ // predicate (ruling R19): cheap, allocation-light, and it reuses the ONE definition of
250
+ // point-to-primitive distance that fit.js owns, so growth and RANSAC can never disagree
251
+ // about what "within tolerance" means.
252
+ function faceDeviation(topo, t, fit) {
253
+ let worst = 0;
254
+ for (let k = 0; k < 3; k++) {
255
+ const v = topo.tris[3*t + k] * 3;
256
+ const d = Math.abs(deviationOf(fit, [topo.verts[v], topo.verts[v+1], topo.verts[v+2]]));
257
+ if (d > worst) worst = d;
258
+ }
259
+ return worst;
260
+ }
261
+
262
+ const sub = (a, b) => [a[0]-b[0], a[1]-b[1], a[2]-b[2]];
263
+ const cross = (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]];
264
+
265
+ // Neighbour faces across non-boundary edges, paired with the edge itself (its
266
+ // signed dihedral and endpoints) rather than bare face ids — the growth
267
+ // predicate below needs both to gate on fold sharpness and to size its
268
+ // tolerance to the actual local facet geometry, not a global constant.
269
+ function neighbourEdges(topo, t) {
270
+ const out = [];
271
+ for (const ei of topo.faceEdges[t]) {
272
+ const e = topo.edges[ei];
273
+ if (e.triB < 0) continue;
274
+ out.push({ face: e.triA === t ? e.triB : e.triA, edge: e });
275
+ }
276
+ return out;
277
+ }
278
+
279
+ // The local facet "width" a chord-error tolerance needs to scale with: NOT the
280
+ // shared edge's own length (on this mesh family that edge commonly runs ALONG
281
+ // the surface's straight axis — e.g. a cylinder wall's wall-to-wall edge is
282
+ // vertical, its full length the part's height, entirely unrelated to how far
283
+ // apart the facets are AROUND the curve) but the LEVER ARM the fold actually
284
+ // pivots on: the perpendicular distance from the shared edge's line out to the
285
+ // candidate face's farthest vertex. A dihedral fold of angle θ about that line
286
+ // displaces a point at lever arm L by approximately L·θ for small θ — exactly
287
+ // the chord sagitta this predicate needs to admit, confirmed by measuring this
288
+ // quantity against the real worst-vertex deviation across N = 16..240 on
289
+ // cylinderMesh (see FACET_K) rather than assumed.
290
+ function leverArm(topo, edge, nbFace) {
291
+ const v0 = [topo.verts[3*edge.v0], topo.verts[3*edge.v0+1], topo.verts[3*edge.v0+2]];
292
+ const v1 = [topo.verts[3*edge.v1], topo.verts[3*edge.v1+1], topo.verts[3*edge.v1+2]];
293
+ const dir = sub(v1, v0);
294
+ const len = Math.hypot(dir[0], dir[1], dir[2]);
295
+ if (len === 0) return 0;
296
+ const u = [dir[0]/len, dir[1]/len, dir[2]/len];
297
+ let worst = 0;
298
+ for (let k = 0; k < 3; k++) {
299
+ const v = topo.tris[3*nbFace + k] * 3;
300
+ const perp = cross(sub([topo.verts[v], topo.verts[v+1], topo.verts[v+2]], v0), u);
301
+ const dist = Math.hypot(perp[0], perp[1], perp[2]);
302
+ if (dist > worst) worst = dist;
303
+ }
304
+ return worst;
305
+ }
306
+
307
+ // Classifies one growth candidate against the standing fit. Returns
308
+ // `{ verdict, leverArm }`. `tol` alone is NOT a safe unconditional pass for a
309
+ // NONZERO-dihedral edge, even though it always was for the pre-adaptive
310
+ // design: `tol` is a fraction of the WHOLE MESH's bbox diagonal, so one large
311
+ // feature anywhere in the same part (a big flat face, say) inflates it for
312
+ // every OTHER feature too — measured directly, a coarse flat plane merely
313
+ // 50mm across was enough to inflate `tol` past the true, small deviation of a
314
+ // genuine flat-to-cylinder transition edge, letting it through with no
315
+ // dihedral or corroboration check ever firing. So only a genuinely FLAT
316
+ // continuation (`edge.convexity === "flat"` — the two triangles of one
317
+ // tessellated quad, or any edge a real curve's own sampling makes coplanar)
318
+ // is unconditionally safe ("accept", no corroboration: there is no fold to
319
+ // be wrong about).
320
+ //
321
+ // This MUST be `topology.js`'s own `convexity` band (`|dihedral| <
322
+ // FLAT_EPS`), never an exact `dihedral === 0` check: a genuinely coplanar
323
+ // pair of triangles only comes out bit-exact zero when the mesh happens to
324
+ // be axis-aligned, which is a property of the FIXTURE, not of the geometry.
325
+ // Rotate the very same mesh by an arbitrary angle and the identical
326
+ // coplanar diagonal computes to something like `-9.4e-17` — a real zero
327
+ // with the wrong bit pattern from accumulated floating-point rounding in
328
+ // the cross/dot/atan2 chain `topology.js` derives it through. An exact
329
+ // equality test sends that edge down the "pending" path instead, where its
330
+ // own numerically-noisy implied radius has nothing to corroborate against
331
+ // (it is the ONLY internal edge a single seed triangle has), and growth
332
+ // never gets past one triangle — verified directly: every mesh in this
333
+ // module's own test fixtures is axis-aligned, which is exactly why this
334
+ // shipped working and rotating ANY of them (a cylinder, a box, a washer,
335
+ // at every density tried) collapsed growth entirely.
336
+ //
337
+ // Every other edge within the smoothness ceiling — whether it clears the
338
+ // plain `tol` or only the facet-scaled adaptive allowance — is "pending":
339
+ // admissible only with a witness (see `sameFamily`/`familySignature` and
340
+ // the growth loop in `segment`), because a nonzero fold is exactly the
341
+ // case a witness-less allowance can quietly get wrong. `leverArm` is
342
+ // reported whenever the edge clears the dihedral gate so the caller can
343
+ // build a corroboration signature without recomputing it.
344
+ function classifyCandidate(topo, edge, nb, fit, tol) {
345
+ if (Math.abs(edge.dihedral) > SMOOTH_DIHEDRAL_MAX) return { verdict: "reject" };
346
+ const dev = faceDeviation(topo, nb, fit);
347
+ if (edge.convexity === "flat") return { verdict: dev <= tol ? "accept" : "reject" };
348
+ const arm = leverArm(topo, edge, nb);
349
+ const adaptive = FACET_K * arm * Math.abs(edge.dihedral);
350
+ return { verdict: dev <= Math.max(tol, adaptive) ? "pending" : "reject", leverArm: arm };
351
+ }
352
+
353
+ // Seed order: bucket faces by quantized normal on the Gauss sphere, then visit the
354
+ // buckets largest-area-first. Big flat regions get claimed while the fit is
355
+ // best-conditioned, and the fiddly transition strips (fillets, chamfers) are left
356
+ // for last instead of being grown into by accident.
357
+ // `axes`: the part's OWN principal directions (fit.js's intrinsicFrame), not world
358
+ // XYZ — quantizing a world-frame normal puts the Gauss-sphere bucket boundaries at a
359
+ // fixed place in space, and an arbitrary rotation of the part slides every facet's
360
+ // normal across them at once, reshuffling which facets seed together on any patch
361
+ // whose normal varies quickly enough to have neighbors near a boundary (exactly the
362
+ // compound-fillet corner blends this pass is warned about below). Bucketing in the
363
+ // mesh's own frame moves the boundaries WITH the geometry instead.
364
+ function seedOrder(topo, axes) {
365
+ const buckets = new Map();
366
+ for (let t = 0; t < topo.faceArea.length; t++) {
367
+ if (topo.faceArea[t] <= 0) continue;
368
+ const w = faceNormalOf(topo, t);
369
+ const n = [
370
+ w[0]*axes[0][0] + w[1]*axes[0][1] + w[2]*axes[0][2],
371
+ w[0]*axes[1][0] + w[1]*axes[1][1] + w[2]*axes[1][2],
372
+ w[0]*axes[2][0] + w[1]*axes[2][1] + w[2]*axes[2][2],
373
+ ];
374
+ const key = `${Math.round(n[0]*24)},${Math.round(n[1]*24)},${Math.round(n[2]*24)}`;
375
+ if (!buckets.has(key)) buckets.set(key, { area: 0, faces: [] });
376
+ const b = buckets.get(key);
377
+ b.area += topo.faceArea[t];
378
+ b.faces.push(t);
379
+ }
380
+ return [...buckets.values()].sort((a, b) => b.area - a.area).flatMap((b) => b.faces);
381
+ }
382
+
383
+ export function segment(topo, opts = {}) {
384
+ // intrinsicFrame(), not a plain world-axis min/max: a tilted mesh's AABB diagonal is
385
+ // NOT the same number as its axis-aligned twin's (see intrinsicFrame's own comment —
386
+ // a 29-degree tilt inflated one reference part's diagonal ~35%), and this tolerance
387
+ // must come out identical under any rigid rotation of the same input. The same frame
388
+ // also drives seedOrder's normal bucketing below, for the same reason.
389
+ const frame = intrinsicFrame(topo.verts);
390
+ const tol = opts.tol ?? frame.diagonal * FIT_TOL_FRAC;
391
+
392
+ const owner = new Int32Array(topo.faceArea.length).fill(-1);
393
+ const patches = [];
394
+
395
+ for (const seed of seedOrder(topo, frame.axes)) {
396
+ if (owner[seed] >= 0) continue;
397
+ let faces = [seed];
398
+ owner[seed] = patches.length;
399
+ let fit = growthFit(...Object.values(facePoints(topo, faces)), tol);
400
+ if (!fit) { owner[seed] = -1; continue; }
401
+
402
+ // Corroboration signatures (sign + implied radius, see `familySignature`)
403
+ // for the nonzero-dihedral edges this patch has already grown across,
404
+ // carried across REFIT_ROUNDS (not reset per round) so a later, coarser
405
+ // round can corroborate against curvature evidence an earlier round already
406
+ // established — see `sameFamily` / `classifyCandidate` for why a "pending"
407
+ // (adaptive-only) candidate needs a witness here before it can be admitted.
408
+ const established = [];
409
+
410
+ // Grow, refit, grow again. Refitting matters: a patch seeded on one facet of a
411
+ // cylinder starts out fitted as a PLANE, and only once it has grown across a few
412
+ // facets does the cylinder fit become the better description. Without the refit
413
+ // rounds the whole wall would come out as a fan of tiny planes.
414
+ for (let round = 0; round < REFIT_ROUNDS; round++) {
415
+ // Candidates are tested against the CURRENT fit's parameters, not by re-fitting
416
+ // the whole trial set (controller ruling R19). Re-fitting per candidate would call
417
+ // growthFit/bestFit — and therefore fitTorus, the most expensive fit at ~3-17ms —
418
+ // on every REJECTED neighbour, which is most of them: a trial set spanning two
419
+ // surfaces fits nothing, so it falls through every cheaper fit first. That is
420
+ // thousands of full fits per part. A deviation check against the standing fit is
421
+ // the standard region-growing formulation, is orders of magnitude cheaper, and is
422
+ // correctness-neutral because the refit below re-converges the patch each round.
423
+ let grew = false;
424
+ // Fixed-point loop: a pass may admit some faces outright (flat `tol`) and
425
+ // shelve others as "pending" (adaptive-only, awaiting a witness). Promoting
426
+ // a pending candidate can itself supply the witness a DIFFERENT pending
427
+ // candidate was waiting on (or open up brand-new neighbours), so the whole
428
+ // scan repeats until a full pass changes nothing.
429
+ let changed = true;
430
+ while (changed) {
431
+ changed = false;
432
+ const queue = [...faces];
433
+ const pending = [];
434
+ const pendingOwned = new Set();
435
+ while (queue.length) {
436
+ for (const { face: nb, edge } of neighbourEdges(topo, queue.pop())) {
437
+ if (owner[nb] >= 0 || topo.faceArea[nb] <= 0 || pendingOwned.has(nb)) continue;
438
+ const { verdict, leverArm: arm } = classifyCandidate(topo, edge, nb, fit, tol);
439
+ if (verdict === "reject") continue;
440
+ if (verdict === "accept") {
441
+ faces.push(nb); owner[nb] = patches.length; queue.push(nb);
442
+ grew = true; changed = true;
443
+ if (edge.convexity !== "flat") established.push(familySignature(edge, arm));
444
+ continue;
445
+ }
446
+ pending.push({ nb, edge, leverArm: arm }); pendingOwned.add(nb); // verdict === "pending"
447
+ }
448
+ }
449
+ for (const p of pending) {
450
+ if (owner[p.nb] >= 0) continue; // claimed by another pending promotion this pass
451
+ const sig = familySignature(p.edge, p.leverArm);
452
+ const witnessed = established.some((s) => sameFamily(s, sig)) ||
453
+ pending.some((q) => q !== p && sameFamily(familySignature(q.edge, q.leverArm), sig));
454
+ if (!witnessed) continue;
455
+ faces.push(p.nb); owner[p.nb] = patches.length;
456
+ established.push(sig);
457
+ grew = true; changed = true;
458
+ }
459
+ }
460
+ if (!grew) break;
461
+ const { pts, normals } = facePoints(topo, faces);
462
+ // growthFit, not bestFit, drives the NEXT round's growth test — see the
463
+ // comment on growthFit for why using the ascending-DOF classification here
464
+ // would permanently trap a curved patch under its own youngest plane fit.
465
+ fit = growthFit(pts, normals, tol) ?? fit;
466
+ }
467
+
468
+ if (faces.length < MIN_PATCH_FACES) { for (const t of faces) owner[t] = -1; continue; }
469
+ // The growth loop's `fit` was chosen to keep growth moving, not to name the
470
+ // surface; re-run the ascending-DOF classification on the now-converged point
471
+ // set for the type actually reported, so a genuinely flat patch that
472
+ // growthFit happened to grow under a curved fit (its normals are degenerate
473
+ // for every non-plane candidate, so this risk is chiefly theoretical, but the
474
+ // final call belongs to `bestFit`'s stricter policy regardless) is still
475
+ // reported as what it is.
476
+ const { pts, normals } = facePoints(topo, faces);
477
+ const finalFit = bestFit(pts, normals, tol) ?? fit;
478
+ patches.push({
479
+ id: `q${patches.length}`, faces, fit: finalFit,
480
+ area: faces.reduce((a, t) => a + topo.faceArea[t], 0),
481
+ });
482
+ }
483
+
484
+ const unassigned = [];
485
+ for (let t = 0; t < owner.length; t++) if (owner[t] < 0 && topo.faceArea[t] > 0) unassigned.push(t);
486
+
487
+ // Region growing is connectivity-bound; RANSAC is not. Anything growth could not
488
+ // claim gets one consensus pass before it is declared residual, so a surface split
489
+ // into islands by a crossing feature is recovered rather than reported as a hole in
490
+ // the description. `opts.ransac === false` skips it — used by ransac.js's own tests
491
+ // and by the budget path in accept.js.
492
+ if (opts.ransac !== false && unassigned.length) {
493
+ const mop = ransacPatches(topo, unassigned, tol);
494
+ patches.push(...mop.patches);
495
+ return { patches, unassigned: mop.unassigned };
496
+ }
497
+ return { patches, unassigned };
498
+ }
@@ -0,0 +1,83 @@
1
+ // Measurement -> intent. A tessellated CAD export puts a 12mm wall at 11.9976 and a
2
+ // clearance hole at 5.2996, and handing those numbers to an agent produces a part
3
+ // parameterised on scanning artefacts. Snapping converts them back into the numbers a
4
+ // human actually typed.
5
+ //
6
+ // The invariant that makes this safe: snapping NEVER destroys the measurement. Every
7
+ // snap returns {raw, to, note} and report.js writes both, so a reader can always see
8
+ // what was measured and what it was interpreted as, and disagree with the second
9
+ // without losing the first (spec §3.1 principle 3).
10
+ //
11
+ // Candidates are ordered coarsest-first and the FIRST match within tolerance wins, not
12
+ // the nearest. 11.4998 must become 11.5 rather than 11.5 losing to 12 on some
13
+ // tie-break, and a coarse-first walk with a tight band gives that for free.
14
+ //
15
+ // Pure leaf. See spec §2.7.
16
+
17
+ // Snap band, relative to the value. Tight enough that a real 11.73 never becomes 11.75,
18
+ // loose enough to absorb any chord tolerance a sane exporter produces.
19
+ export const SNAP_TOL_FRAC = 5e-4;
20
+ const ABS_FLOOR = 1e-4; // below this a relative band is meaninglessly small
21
+
22
+ // ISO 273 metric clearance holes, close fit, in millimetres. Keyed by the drilled
23
+ // diameter a CAD model actually carries, which is what a mesh can show us — the thread
24
+ // size is the annotation, not the measurement.
25
+ //
26
+ // Checked against a live reference (ISO 273 close-fit column, cross-checked across
27
+ // multiple fastener-hardware references) rather than shipped as given: the brief's
28
+ // draft table used the *close*-fit diameters for M5-M10 (5.3, 6.4, 8.4, 10.5) but the
29
+ // *medium/normal*-fit diameters for M2-M4 (2.4, 2.9, 3.4, 4.5) while labelling every
30
+ // row "close fit" — an internally inconsistent table that would misidentify an M2/
31
+ // M2.5/M3/M4 close-fit hole as one size larger. Corrected M2-M4 to their true
32
+ // close-fit diameters (2.2, 2.7, 3.2, 4.3); M5-M10 were already correct and are
33
+ // unchanged.
34
+ const CLEARANCE = [
35
+ { d: 2.2, note: "M2 clearance (close fit)" },
36
+ { d: 2.7, note: "M2.5 clearance (close fit)" },
37
+ { d: 3.2, note: "M3 clearance (close fit)" },
38
+ { d: 4.3, note: "M4 clearance (close fit)" },
39
+ { d: 5.3, note: "M5 clearance (close fit)" },
40
+ { d: 6.4, note: "M6 clearance (close fit)" },
41
+ { d: 8.4, note: "M8 clearance (close fit)" },
42
+ { d: 10.5, note: "M10 clearance (close fit)" },
43
+ ];
44
+
45
+ const near = (a, b) => Math.abs(a - b) <= Math.max(Math.abs(b) * SNAP_TOL_FRAC, ABS_FLOOR);
46
+
47
+ export function snapValue(raw, opts = {}) {
48
+ if (!Number.isFinite(raw)) return null;
49
+ const steps = opts.steps ?? [10, 5, 1, 0.5, 0.25, 0.1, 0.05];
50
+ for (const step of steps) {
51
+ const to = Math.round(raw / step) * step;
52
+ // Re-round to kill float dust from the divide: 0.4999/0.5 -> 0.5, not 0.5000000001.
53
+ const clean = Math.round(to * 1e6) / 1e6;
54
+ if (clean !== 0 && near(raw, clean)) return { raw, to: clean, note: null };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ export function snapHoleDiameter(raw) {
60
+ for (const c of CLEARANCE) if (near(raw, c.d)) return { raw, to: c.d, note: c.note };
61
+ return snapValue(raw);
62
+ }
63
+
64
+ // The coarsest step every value is a multiple of. Reported so the hints layer can
65
+ // propose parameters on that grid, and so a report reader can see at a glance whether
66
+ // the part was designed in whole millimetres or in something finer.
67
+ //
68
+ // Candidate list stops at 0.25, not 0.1: at SNAP_TOL_FRAC's band, any value written to
69
+ // one decimal place is *always* within tolerance of the nearest 0.1 multiple — that is
70
+ // what "one decimal place" means, independent of whether the part has any real grid.
71
+ // A 0.1 candidate therefore matches every input and never returns anything but a false
72
+ // positive; unlike 0.25, it carries no information. Confirmed by running the brief's
73
+ // original [10, 5, 2.5, 2, 1, 0.5, 0.25, 0.1] list against [3.1, 7.7, 11.3]: it reports
74
+ // {grid: 0.1, coverage: 1} instead of the `null` its own test requires.
75
+ export function inferGrid(values) {
76
+ const finite = values.filter((v) => Number.isFinite(v) && Math.abs(v) > ABS_FLOOR);
77
+ if (finite.length < 2) return null;
78
+ for (const grid of [10, 5, 2.5, 2, 1, 0.5, 0.25]) {
79
+ const hits = finite.filter((v) => near(v, Math.round(v / grid) * grid));
80
+ if (hits.length === finite.length) return { grid, coverage: 1 };
81
+ }
82
+ return null;
83
+ }