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,391 @@
1
+ // Efficient RANSAC (Schnabel/Wahl/Klein 2007) over the faces region growing could
2
+ // not claim. Growth is connectivity-driven and therefore blind to a surface split
3
+ // into disjoint islands by a feature crossing it — a plane interrupted by a boss,
4
+ // a wall broken by a slot. RANSAC is consensus-driven and does not care whether its
5
+ // inliers touch, so the two are complementary rather than redundant.
6
+ //
7
+ // DETERMINISM IS A HARD REQUIREMENT, not a nicety. Oracle output feeds a
8
+ // content-hash memo (spec §4.1) and the framework's purity rule forbids Math.random
9
+ // outright, so candidate sampling is built from the mesh's own topology and
10
+ // quantized normals instead of drawing randomly. Same input (same faces, in the
11
+ // same order — see `ransacPatches`'s own header note on that), same patches,
12
+ // every run, in every process.
13
+ //
14
+ // Pure leaf. See spec §2.3.
15
+ import { fitPlane, fitCylinder, fitCone, fitSphere, fitTorus, deviationOf } from "./fit.js";
16
+ import { facePoints } from "./segment.js";
17
+
18
+ const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
19
+ const unit = (a) => { const n = Math.hypot(a[0], a[1], a[2]); return n > 0 ? [a[0]/n, a[1]/n, a[2]/n] : [0, 0, 0]; };
20
+
21
+ // A minimal sample is 2 FACES (6 vertices, 6 normals — three per triangle). Two,
22
+ // not three, because this mop-up's own primary target (per the file header — a
23
+ // plane cut by a boss, a wall cut by a slot) is a flat CAD face reduced by
24
+ // tessellation to as few as TWO triangles, so a threshold of three would make
25
+ // that exact, common case unrecoverable by construction, not by any real
26
+ // shortfall of consensus: three DISTINCT triangles drawn from a mesh where a
27
+ // real plane is only ever two triangles wide can never all land on that one
28
+ // plane (verified directly on `boxMesh`: every 3-triangle sample mixes at least
29
+ // two different quads, and the least-squares plane through two different box
30
+ // faces' points misses tolerance by millimetres, never by noise). `minInliers`
31
+ // is the same number, for the same reason.
32
+ const SAMPLE_FACES = 2;
33
+ const MIN_INLIERS = SAMPLE_FACES;
34
+
35
+ // Quantization for "same normal direction, bucket together" — deliberately the
36
+ // same 1/24 grid `segment.js`'s own `seedOrder` uses for the identical purpose
37
+ // (grouping faces by Gauss-map direction), not a fresh constant invented here.
38
+ const NORMAL_BUCKET_SCALE = 24;
39
+ // Within one normal bucket, exhaustively pair only the first this-many members.
40
+ // A genuinely single real surface only needs ONE pair from its bucket to
41
+ // bootstrap (consensus then scans the WHOLE pool, not just the bucket, so it
42
+ // recovers every matching face regardless of which pair started it) — this cap
43
+ // only bounds the rarer case of one bucket holding several DIFFERENT parallel
44
+ // planes (same normal, different offset), where exhaustive intra-bucket
45
+ // pairing is what guarantees at least one same-offset pair is tried.
46
+ const BUCKET_PAIR_CAP = 24;
47
+ // Hard ceiling on total candidate pairs handed to the round below, applied
48
+ // AFTER generating the topological and normal-bucket pairs (see
49
+ // `candidatePairs`) — a cap on how much work gets EVALUATED, never a switch to
50
+ // a different, lossier way of choosing which pairs to generate. A prior
51
+ // version of this file used a coprime-stride walk as a fallback once the pool
52
+ // exceeded a budget, and that walk could — provably, not just theoretically —
53
+ // skip every same-facet pair a plane hypothesis needs (see `candidatePairs`'s
54
+ // own comment for the measured cylinder-wall regression this caused). The
55
+ // fix is not a bigger or smarter stride, it is to make the GENERATION itself
56
+ // structurally incapable of missing those pairs, and only THEN bound the
57
+ // total.
58
+ const PAIR_BUDGET = 65536;
59
+
60
+ // Controller ruling R28: `ransacPatches` measures at roughly O(n^2.7) in its face
61
+ // count — 80ms at 400 faces, 1.8s at 1600, 23.8s at 4000, ~60s around 5,500. That
62
+ // has been safe so far only because its one caller (`segment()`'s mop-up, below)
63
+ // hands it a small RESIDUAL — the faces region growing couldn't already claim, not
64
+ // the whole mesh. A future caller offering an unfiltered face list (the whole
65
+ // candidate surface of a complex mesh) could walk straight into the cliff above
66
+ // with no warning. `maxFaces` is that guardrail: past it, this function degrades —
67
+ // it processes a bounded, deterministic prefix of the pool and reports the rest as
68
+ // unassigned — rather than either refusing to run or silently taking minutes.
69
+ // 2000 is chosen to sit comfortably under the 1600-faces/1.8s point measured above
70
+ // (the worst case inside one bucket-pair/topology-neighbour pass is still bounded
71
+ // by PAIR_BUDGET regardless of pool size, but the per-candidate consensus SCAN is
72
+ // O(pool) per pair, which is where the n^2.7 comes from).
73
+ const DEFAULT_MAX_FACES = 2000;
74
+
75
+ // Angle band for "this face's own normal is compatible with the fit's local
76
+ // surface normal here", radians (30°) — reuses segment.js's own smoothness
77
+ // ceiling (`SMOOTH_DIHEDRAL_MAX`) rather than inventing a second unrelated
78
+ // constant for what is conceptually the same question: how much a face's flat
79
+ // normal may disagree with a claimed smooth surface before the two are
80
+ // considered different surfaces. Not re-imported (segment.js doesn't export
81
+ // it) because the two live in siblings with no shared parent to hoist a
82
+ // constant into without ransac.js importing FROM segment.js — a leaf importing
83
+ // its own caller — so the value is restated here with this comment as the
84
+ // tether between them.
85
+ const NORMAL_COS_MIN = Math.cos(Math.PI / 6);
86
+
87
+ const faceNormalOf = (topo, t) => [topo.faceNormal[3*t], topo.faceNormal[3*t+1], topo.faceNormal[3*t+2]];
88
+ function faceCentroid(topo, t) {
89
+ const c = [0, 0, 0];
90
+ for (let k = 0; k < 3; k++) {
91
+ const v = topo.tris[3*t + k] * 3;
92
+ c[0] += topo.verts[v]; c[1] += topo.verts[v+1]; c[2] += topo.verts[v+2];
93
+ }
94
+ return [c[0]/3, c[1]/3, c[2]/3];
95
+ }
96
+ // Worst distance from a face's three vertices to a fitted primitive. Exported because
97
+ // segment.js's growth predicate needs exactly the same question answered exactly the same
98
+ // way; the point-to-primitive distance itself lives in fit.js (ruling R19), so there is
99
+ // one definition and neither consumer can drift from it.
100
+ export function faceDeviation(topo, t, fit) {
101
+ let worst = 0;
102
+ for (let k = 0; k < 3; k++) {
103
+ const v = topo.tris[3*t + k] * 3;
104
+ const d = Math.abs(deviationOf(fit, [topo.verts[v], topo.verts[v+1], topo.verts[v+2]]));
105
+ if (d > worst) worst = d;
106
+ }
107
+ return worst;
108
+ }
109
+
110
+ // GRAD_H: the step of a central-difference gradient of `deviationOf`, taken as the
111
+ // fit's local unit outward normal. Not five hand-derived per-type normal formulas
112
+ // (one per fit in fit.js) — that would be five MORE places a normal convention
113
+ // could quietly drift from `deviationOf`'s own sign convention, exactly the
114
+ // duplication ruling R19 already forbids for distance. `deviationOf` is a smooth
115
+ // signed-distance-like field for every one of the five types by construction
116
+ // (fit.js's own docstring on the function), so its gradient anywhere near the
117
+ // surface IS that surface's local normal, generically, with no per-type case
118
+ // analysis needed. Fixed, not scaled to the fit's own size or the mesh's bbox:
119
+ // determinism forbids a step that depends on anything but the two arguments
120
+ // given, the same reason candidate generation below is built from the mesh's
121
+ // own topology and quantized normals rather than anything data-adaptive.
122
+ const GRAD_H = 1e-4;
123
+ function fitNormalAt(fit, point) {
124
+ // Plane fast path: `deviationOf`'s plane branch is `dot(normal, point) -
125
+ // offset`, exactly linear, so its gradient is `normal` EVERYWHERE, not just
126
+ // near the surface — this isn't an approximation for the plane case, it's
127
+ // the same answer the finite difference below converges to, without six
128
+ // extra `deviationOf` calls per face checked. Consensus in this file only
129
+ // ever calls this with a plane fit (`planeHypothesis` never hands back
130
+ // anything else), so this is the path actually taken; the general
131
+ // finite-difference fallback stays for `opts`/future callers that might
132
+ // pass a converged non-plane fit through the same check.
133
+ if (fit.type === "plane") return fit.normal;
134
+ const g = [0, 0, 0];
135
+ for (let a = 0; a < 3; a++) {
136
+ const hi = [...point], lo = [...point];
137
+ hi[a] += GRAD_H; lo[a] -= GRAD_H;
138
+ g[a] = deviationOf(fit, hi) - deviationOf(fit, lo);
139
+ }
140
+ return unit(g);
141
+ }
142
+
143
+ // Why consensus needs BOTH a distance test and this one, not distance alone: a
144
+ // box's 8 corners lie, EXACTLY (to floating-point noise), on infinitely many
145
+ // circumscribing cylinders and spheres — any axis through the centroid parallel
146
+ // to a principal direction gives a cylinder every corner sits on; the
147
+ // circumsphere every corner sits on is a further such case. That is not a
148
+ // tessellation artifact, it is elementary solid geometry (a rectangular box's
149
+ // vertices are, by definition, equidistant from its centroid along suitably
150
+ // chosen axes), so it recurs on every rectangular fixture, not a fluke of one
151
+ // mesh — measured directly against all 60 cross-quad pairs of `boxMesh`'s 12
152
+ // triangles (the 66 total pairs of 12 triangles, less the 6 same-quad pairs):
153
+ // every one of those 60 fits a cylinder AND a sphere to machine precision. A
154
+ // distance-only consensus test can't tell that coincidence apart from a real
155
+ // surface. A real surface's own faces have flat
156
+ // normals that track the surface's local tangent as it's actually built; a box
157
+ // face dropped onto someone else's circumscribing sphere has a flat normal
158
+ // (say, straight down off the bottom) that has nothing to do with that
159
+ // sphere's local outward direction there (radially away from a center nowhere
160
+ // near below it) — measured directly at tens of degrees apart, well outside
161
+ // any plausible smoothness band. This is the standard Efficient RANSAC
162
+ // safeguard (Schnabel/Wahl/Klein score every inlier on normal compatibility,
163
+ // not distance alone), restated here because the immediate trigger for adding
164
+ // it was exactly this box fixture.
165
+ function faceNormalConsistent(topo, t, fit) {
166
+ return dot(fitNormalAt(fit, faceCentroid(topo, t)), faceNormalOf(topo, t)) >= NORMAL_COS_MIN;
167
+ }
168
+
169
+ // The minimal-sample hypothesis is PLANE ONLY, never cylinder/cone/sphere/torus,
170
+ // even though every fit is available (and used) once a consensus set has grown —
171
+ // see the REFIT step below. This is not a simplification of convenience: two
172
+ // faces supply only two independent surface-normal directions, and that is
173
+ // structurally too little to trust a 5-DOF ruled-surface fit (a cylinder's or
174
+ // cone's axis) with. Measured directly on `boxMesh`: EVERY pair of triangles
175
+ // drawn from two different, unrelated quads satisfies `fitCylinder` (and
176
+ // `fitSphere`) to machine-precision residual — a rectangular box's vertices are
177
+ // always equidistant from its centroid along the right axis, so a "cylinder"
178
+ // or "sphere" hypothesis born from almost any two unrelated flat facets isn't
179
+ // evidence of curvature at all, it's an artifact of how much freedom two
180
+ // normal directions leave a 5-DOF fit. No fixed normal-consistency or distance
181
+ // threshold closes this reliably (the more elongated a box's cross-section,
182
+ // the closer two unrelated facets' radial deviation drifts toward zero,
183
+ // eliminating any fixed-angle margin as the aspect ratio grows), so the
184
+ // principled fix is not to widen the FILTER but to narrow what a minimal
185
+ // sample is trusted to CLAIM: a plane needs only three points to be exactly
186
+ // determined (fit.js's own MIN_PTS), so two flat facets are already enough
187
+ // data to trust a plane hypothesis with no analogous coincidence risk (two
188
+ // unrelated, non-coplanar quads do NOT satisfy a common plane to any close
189
+ // tolerance — verified on the same fixture). This mirrors segment.js's own
190
+ // seed/grow split (a lone seed triangle is only ever classified provisionally,
191
+ // never asked to prove a curved type) rather than inventing a new principle.
192
+ function planeHypothesis(topo, sample, tol) {
193
+ const { pts, normals } = facePoints(topo, sample);
194
+ const f = fitPlane(pts);
195
+ if (!f || f.maxDev > tol) return null;
196
+ // fitPlane's normal is an eigenvector of a covariance matrix, and an
197
+ // eigenvector's sign is arbitrary — fit.js never canonicalizes it, because
198
+ // nothing in a bare point cloud says which side is "outward". A mesh face
199
+ // does say: `topo.faceNormal` is the winding-derived outward direction, the
200
+ // one thing `faceNormalConsistent` actually needs the fitted plane's normal
201
+ // to agree with. Without this, the two ends up antiparallel roughly half the
202
+ // time (whichever way the eigensolver happened to land), and
203
+ // `faceNormalConsistent` then rejects the SAME faces that produced the
204
+ // hypothesis in the first place — reproduced directly: `boxMesh`'s bottom
205
+ // quad fit a plane with normal (0,0,1) while its own triangles carry (0,0,-1),
206
+ // and the consensus step threw away the seed pair itself. Flipping both
207
+ // `normal` and `offset` together (so `dot(normal,p) = offset` still holds)
208
+ // re-expresses the identical plane with the sign the mesh's own winding
209
+ // agrees with; summing agreement across the whole sample rather than
210
+ // checking one face keeps the corrected sign robust to a single face's
211
+ // winding being the unusual one.
212
+ let agree = 0;
213
+ for (const n of normals) agree += dot(f.normal, n);
214
+ return agree < 0 ? { ...f, normal: [-f.normal[0], -f.normal[1], -f.normal[2]], offset: -f.offset } : f;
215
+ }
216
+
217
+ // FINAL classification of a converged consensus set, once RANSAC has stopped
218
+ // growing it: the full ascending-DOF ladder (plane, then cylinder/cone, then
219
+ // sphere/torus), first candidate within tolerance — same policy, and the same
220
+ // justification, as segment.js's `bestFit`. By the time this runs, `sample`
221
+ // is the WHOLE inlier set (already filtered on both distance and normal
222
+ // consistency against the seed plane), not a bare minimal sample, so it carries
223
+ // far more independent constraint than the two-facet hypothesis above and does
224
+ // not share that hypothesis's degenerate-fit risk.
225
+ function candidateFrom(topo, sample, tol) {
226
+ const { pts, normals } = facePoints(topo, sample);
227
+ for (const f of [fitPlane(pts), fitCylinder(pts, normals), fitCone(pts, normals),
228
+ fitSphere(pts), fitTorus(pts, normals)]) {
229
+ if (f && f.maxDev <= tol) return f;
230
+ }
231
+ return null;
232
+ }
233
+
234
+ // Every candidate pair worth trying a plane hypothesis on, deterministically
235
+ // ordered. A plane needs matching normal direction (two faces from unrelated
236
+ // directions never fit one to any close tolerance — see `planeHypothesis`'s
237
+ // own comment), so the pairs that can EVER succeed are already a narrow,
238
+ // structured subset of all C(n,2) — this generates exactly that subset from
239
+ // two cheap, complementary sources instead of guessing at it with a stride:
240
+ //
241
+ // 1. TOPOLOGICAL NEIGHBOURS (`topo.faceEdges`): O(3n), and — this is the part
242
+ // a coprime-stride walk got wrong — GUARANTEED to include the diagonal
243
+ // pair of any single real flat facet still whole in the pool (a box quad's
244
+ // own two triangles, a cylinder wall segment's own two triangles: they
245
+ // share an edge by construction). A prior version of this file fell back
246
+ // to a stride walk once the pool exceeded a budget, and that walk could
247
+ // (measured directly: a 100-segment cylinder's 200 wall triangles, handed
248
+ // to `ransacPatches` with no adjacency hint, at that budget) skip EVERY
249
+ // one of the wall's 100 same-facet diagonal pairs, recovering only the two
250
+ // caps and leaving the entire wall — exactly half the mesh — unassigned,
251
+ // silently, with no error. Topological adjacency has no such blind spot:
252
+ // it doesn't sample a subset hoping to land on the right pairs, it reads
253
+ // the one relationship (shared edge) a real facet's own two triangles
254
+ // always have, for every face in the pool, unconditionally.
255
+ // 2. NORMAL BUCKETS (see `NORMAL_BUCKET_SCALE`): reaches a face with NO
256
+ // topological neighbour left in the pool at all — a genuinely isolated
257
+ // sliver, or one half of a plane a crossing feature disconnected from its
258
+ // other half — by pairing it with whatever ELSE in the pool shares its
259
+ // (quantized) normal direction, the one thing any two pieces of the same
260
+ // real flat surface are guaranteed to share. Bounded per bucket (see
261
+ // `BUCKET_PAIR_CAP`) rather than fully exhaustive, because a bucket that
262
+ // holds one real, already-topologically-connected surface already got its
263
+ // bootstrap pair from source 1 above — this only needs to guarantee
264
+ // covering pairs ACROSS disconnected pieces, which is cheap by
265
+ // construction (few faces per genuine disconnected remainder).
266
+ //
267
+ // `PAIR_BUDGET` truncates the COMBINED result as a hard cap on total pairs
268
+ // evaluated, applied last and only as a cap — never as a reason to swap in a
269
+ // worse generator (see `PAIR_BUDGET`'s own comment).
270
+ function candidatePairs(topo, pool) {
271
+ const seen = new Set();
272
+ const pairs = [];
273
+ const push = (a, b) => {
274
+ if (a === b) return;
275
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`;
276
+ if (seen.has(key)) return;
277
+ seen.add(key);
278
+ pairs.push(a < b ? [a, b] : [b, a]);
279
+ };
280
+
281
+ const inPool = new Set(pool);
282
+ for (const t of pool) {
283
+ for (const ei of topo.faceEdges[t]) {
284
+ const e = topo.edges[ei];
285
+ if (e.triB < 0) continue;
286
+ const nb = e.triA === t ? e.triB : e.triA;
287
+ if (inPool.has(nb)) push(t, nb);
288
+ }
289
+ }
290
+
291
+ const buckets = new Map();
292
+ for (const t of pool) {
293
+ const n = faceNormalOf(topo, t);
294
+ const key = `${Math.round(n[0]*NORMAL_BUCKET_SCALE)},${Math.round(n[1]*NORMAL_BUCKET_SCALE)},${Math.round(n[2]*NORMAL_BUCKET_SCALE)}`;
295
+ let bucket = buckets.get(key);
296
+ if (!bucket) buckets.set(key, bucket = []);
297
+ bucket.push(t);
298
+ }
299
+ for (const bucket of buckets.values()) {
300
+ const m = Math.min(bucket.length, BUCKET_PAIR_CAP);
301
+ for (let i = 0; i < m; i++) for (let j = i + 1; j < m; j++) push(bucket[i], bucket[j]);
302
+ }
303
+
304
+ return pairs.length > PAIR_BUDGET ? pairs.slice(0, PAIR_BUDGET) : pairs;
305
+ }
306
+
307
+ export function ransacPatches(topo, faces, tol, opts = {}) {
308
+ const minInliers = opts.minInliers ?? MIN_INLIERS;
309
+ const maxFaces = opts.maxFaces ?? DEFAULT_MAX_FACES;
310
+ // Over the cap: split off a deterministic, order-independent prefix to actually
311
+ // process and defer the rest straight to `unassigned` (folded in below, after the
312
+ // main loop, alongside whatever the consensus search itself couldn't claim).
313
+ // Sorted by face INDEX — a property of the mesh, not of the caller's array — so
314
+ // which faces get processed vs deferred does not depend on the order `faces` was
315
+ // handed in, the same order-independence the rest of this function already
316
+ // guarantees (see the canonicalization comments below).
317
+ let overflow = [];
318
+ let bounded = faces;
319
+ if (faces.length > maxFaces) {
320
+ const sorted = [...faces].sort((a, b) => a - b);
321
+ bounded = sorted.slice(0, maxFaces);
322
+ overflow = sorted.slice(maxFaces);
323
+ }
324
+ let pool = [...bounded];
325
+ const patches = [];
326
+
327
+ while (pool.length >= minInliers) {
328
+ let best = null;
329
+ // Deterministic minimal samples: every pair `candidatePairs` generates, not
330
+ // drawn at random (see the file header — determinism is a hard requirement,
331
+ // not a style preference) and not a coprime-stride subset either (see
332
+ // `candidatePairs` for why that seemed reasonable and provably was not).
333
+ for (const sample of candidatePairs(topo, pool)) {
334
+ const fit = planeHypothesis(topo, sample, tol);
335
+ if (!fit) continue;
336
+ const inliers = pool.filter((t) => faceDeviation(topo, t, fit) <= tol && faceNormalConsistent(topo, t, fit));
337
+ if (inliers.length >= minInliers && (!best || inliers.length > best.inliers.length)) best = { fit, inliers };
338
+ }
339
+ if (!best) break;
340
+ // Refit and reclassify on the full consensus set: the minimal sample only
341
+ // located a plane, and every subsequent consumer reads these parameters as
342
+ // measurements. `candidateFrom` here can, and sometimes should, come back
343
+ // with a NON-plane type — an island whose faces all satisfy the seed plane's
344
+ // distance+normal test can still turn out to be better described some other
345
+ // way once every one of its faces (not just two) is fitted at once. Falls
346
+ // back to the minimal-sample fit (never null: the loop above only records
347
+ // `best` when `fit` succeeded) if the larger set fails every candidate
348
+ // outright — that can happen when the consensus set's extra faces are within
349
+ // `tol` of the seed plane individually but their combined least-squares
350
+ // refit drifts just past it.
351
+ const refit = candidateFrom(topo, best.inliers, tol) ?? best.fit;
352
+ patches.push({
353
+ faces: best.inliers, fit: refit,
354
+ area: best.inliers.reduce((a, t) => a + topo.faceArea[t], 0),
355
+ });
356
+ const claimed = new Set(best.inliers);
357
+ pool = pool.filter((t) => !claimed.has(t));
358
+ }
359
+
360
+ // Canonicalize order before numbering: the WHILE loop above discovers
361
+ // patches in an order driven by `pool`'s own current arrangement (ties in
362
+ // consensus size are broken by whichever candidate the pair generator
363
+ // happened to try first), and `pool`'s arrangement starts from whatever
364
+ // order the caller passed `faces` in. The set of faces in each patch is
365
+ // provably order-invariant (region growing's own connectivity doesn't
366
+ // enter into this file at all, and neither does array order — only which
367
+ // faces satisfy a fitted plane's distance+normal test, which doesn't
368
+ // depend on iteration order); the SEQUENCE they were discovered in is not,
369
+ // and this file's own header claims "same input, same patches, every
370
+ // run" — a claim that is only true if "same input" means the same SET of
371
+ // faces, not the same order too, unless output order is made to not
372
+ // depend on input order either. Sorting by each patch's own smallest face
373
+ // index — a property of the mesh's topology, not of the caller's array —
374
+ // gives a canonical order with no dependency on how `faces` happened to
375
+ // be listed, closing that gap rather than merely documenting it.
376
+ for (const p of patches) p.faces.sort((a, b) => a - b);
377
+ patches.sort((a, b) => a.faces[0] - b.faces[0]);
378
+ patches.forEach((p, i) => { p.id = `r${i}`; });
379
+
380
+ // Same reasoning for the residual: its CONTENT (which faces went unclaimed)
381
+ // is order-invariant, but `pool` carries whatever relative order the
382
+ // caller's `faces` had. `overflow` (the maxFaces deferral above, empty on
383
+ // the common in-budget path) folds in here rather than earlier, so a face
384
+ // the consensus search never even looked at is reported the same way as one
385
+ // it looked at and couldn't claim — both are just "unassigned" to the
386
+ // caller. A plain ascending sort is the canonical form for a bare list of
387
+ // face indices.
388
+ pool = pool.concat(overflow).sort((a, b) => a - b);
389
+
390
+ return { patches, unassigned: pool };
391
+ }
@@ -0,0 +1,217 @@
1
+ // The two report shapes, defined together on purpose.
2
+ //
3
+ // FULL is the archive: everything measured, written to disk by `partforge describe
4
+ // --json`. COMPACT is what a model reads: features, patterns, symmetry, score,
5
+ // residual, and the suggestion, with surfaces and edges elided to counts.
6
+ //
7
+ // Both live here rather than compact being invented by each consumer. A 24k-triangle
8
+ // part yields hundreds of surfaces, and every consumer that trims them independently
9
+ // trims them differently — cloud's model-facing view and the CLI's summary would drift
10
+ // apart within a release. mountManager.js's compactReport is the precedent for the
11
+ // principle (a full oracle report is not a model-facing artefact) and the warning: it
12
+ // lives downstream, and its field renames have to be kept in sync by hand.
13
+ //
14
+ // Pure leaf. See spec §3.
15
+
16
+ import { DESCRIBE_LIMITS } from "./limits.js";
17
+
18
+ // Below this fraction the geometry is not trustworthy as a description of the part,
19
+ // and saying so quietly is worse than saying nothing: an agent will build against a
20
+ // confident-looking list that covers 61% of the geometry. See compactDescribe's own
21
+ // comment for what "this fraction" is measured against (fix round 1: it's the worse
22
+ // of two independent numbers, not just one of them).
23
+ export const LOW_COVERAGE = 0.85;
24
+
25
+ // Caps are CEILINGS, not targets: slicing an oversized array is not a failure, it is
26
+ // the report doing its job. `flags[name]` records whether THIS array actually hit its
27
+ // ceiling, so `buildReport`'s `truncated` block only ever claims what really happened.
28
+ const cap = (arr, max, flags, name) => {
29
+ const a = arr ?? [];
30
+ flags[name] = a.length > max;
31
+ return a.slice(0, max);
32
+ };
33
+
34
+ // score is passed straight through except for one added field: a plain-language note
35
+ // distinguishing its two coverage numbers. `explainedArea` (segment.js/surface-graph.js
36
+ // territory) is how much of the mesh's SURFACE was fit by a surface type; it says
37
+ // nothing about whether that surface was ever turned into a reconstructable feature.
38
+ // `explainedVolumeFraction` (accept.js) is how much of the part's VOLUME the accepted
39
+ // candidates actually rebuild — the number that matters to an agent deciding whether it
40
+ // can trust the feature list to rebuild the part. They can diverge totally: a sphere
41
+ // segments at explainedArea ~1 (it fits a sphere surface cleanly) while contributing
42
+ // explainedVolumeFraction 0 (no detector treats a sphere as a candidate-eligible
43
+ // feature type, so accept.js never gets a candidate to build from it at all). Spelled
44
+ // out here, in the data, because the consumer is an LLM that cannot go read accept.js's
45
+ // comments to work out that these are different measurements (fix round 1, IMPORTANT 5).
46
+ //
47
+ // Also covers each feature's own `volumeShare` (describe.js), the SAME underlying
48
+ // accept.js gain measurement applied per-feature rather than summed: how much of the
49
+ // part's volume THAT feature accounts for, not how sure the description is (round 2
50
+ // review, IMPORTANT). A small-but-certain feature legitimately reports a small share —
51
+ // spelled out here for the same reason the area/volume distinction is: the consumer
52
+ // cannot go read accept.js's comments to learn that "small" and "uncertain" are not
53
+ // the same axis.
54
+ const SCORE_NOTE =
55
+ "explainedArea is surface coverage from segmentation (how much of the mesh's area " +
56
+ "was fit by a surface); explainedVolumeFraction is shape coverage from " +
57
+ "reconstruction (how much of the part's volume the accepted features actually " +
58
+ "rebuild). These are different measurements and can diverge totally — a part can " +
59
+ "be ~100% segmented and 0% reconstructed. The LOW COVERAGE banner, if present, " +
60
+ "reflects whichever of the two is worse. Each feature's own volumeShare is the " +
61
+ "same reconstruction measurement applied per-feature: the fraction of the part's " +
62
+ "volume THAT feature accounts for. It is a measure of SIZE, not certainty — a " +
63
+ "small-but-certain feature (e.g. a precisely-fitted 3mm hole in a large plate) " +
64
+ "legitimately reports a small volumeShare because it is small, not because it is " +
65
+ "doubtful. For a genuine fit-quality/certainty signal, read that feature's own " +
66
+ "fitted surface rms/maxDev instead.";
67
+
68
+ function buildScore(score) {
69
+ return { ...(score ?? {}), note: SCORE_NOTE };
70
+ }
71
+
72
+ export function buildReport(input) {
73
+ // Every array this report might cap gets a flag here, initialised false, before any
74
+ // capping happens — so the shape is TOTAL. A cap never hit, and an array the input
75
+ // never supplied at all (e.g. no suggestion yet, before Task 12's orchestrator is
76
+ // wired in), must both report `false`, not an absent key: a consumer that walks
77
+ // `Object.values(truncated)` needs every flag present every time, or "nothing was
78
+ // truncated" and "the flag is just missing" become indistinguishable (fix round 1,
79
+ // IMPORTANT 4).
80
+ const truncated = {
81
+ surfaces: false,
82
+ edges: false,
83
+ features: false,
84
+ patterns: false,
85
+ residualRegions: false,
86
+ suggestionSteps: false,
87
+ };
88
+
89
+ const surfaces = cap(input.surfaces, DESCRIBE_LIMITS.MAX_SURFACES, truncated, "surfaces");
90
+ // input.arcs are the fitted edges between surface pairs (Tasks 6-7); reported here
91
+ // as "edges" since that is what a reader of the report calls them. Each carries a
92
+ // `convexity` of "convex" or "concave" — settled empirically to mean whether the
93
+ // edge rounds an outside or an inside corner of the PART, nothing about how a
94
+ // torus's own tube radius is parametrised. Easy to misread as the latter; it isn't.
95
+ const edges = cap(input.arcs, DESCRIBE_LIMITS.MAX_EDGES, truncated, "edges");
96
+ const features = cap(input.features, DESCRIBE_LIMITS.MAX_FEATURES, truncated, "features");
97
+ const patterns = cap(input.patterns, DESCRIBE_LIMITS.MAX_PATTERNS, truncated, "patterns");
98
+ const residualRegions = cap(
99
+ input.residual?.regions, DESCRIBE_LIMITS.MAX_RESIDUAL_REGIONS, truncated, "residualRegions"
100
+ );
101
+ // Capped even when there is no suggestion yet (input.suggestion null/undefined) so
102
+ // `truncated.suggestionSteps` is always `false` in that case, never absent — see the
103
+ // flag block's own comment above.
104
+ const suggestionSteps = cap(
105
+ input.suggestion?.steps, DESCRIBE_LIMITS.MAX_SUGGESTION_STEPS, truncated, "suggestionSteps"
106
+ );
107
+
108
+ return {
109
+ source: {
110
+ name: input.source?.name ?? null,
111
+ digest: input.source?.digest ?? null,
112
+ triangles: input.source?.triangles ?? 0,
113
+ watertight: input.source?.watertight ?? null,
114
+ units: "mm",
115
+ },
116
+ // Stated explicitly, every time. Z-up/Y-up confusion is a documented LLM failure
117
+ // mode (research doc §5) and one line defuses it. "as-imported" is the honest
118
+ // claim: describe never realigns, so the frame is whatever the file carried.
119
+ frame: { up: "+Z", note: "as-imported; no realignment applied" },
120
+ bounds: {
121
+ min: input.bounds.min,
122
+ max: input.bounds.max,
123
+ size: [0, 1, 2].map((i) => input.bounds.max[i] - input.bounds.min[i]),
124
+ },
125
+ // Pre-cap magnitudes, computed from the INPUT, not from `surfaces`/`edges` below.
126
+ // Those two arrays are what compactDescribe elides to counts, and a cap that fires
127
+ // must not also erase how big the thing it capped really was: 500 input surfaces
128
+ // against MAX_SURFACES=200 must still say 500 here, not silently report 200 as if
129
+ // that were the whole part (fix round 1, IMPORTANT 3).
130
+ counts: {
131
+ surfaces: (input.surfaces ?? []).length,
132
+ edges: (input.arcs ?? []).length,
133
+ },
134
+ surfaces,
135
+ edges,
136
+ features,
137
+ patterns,
138
+ symmetry: input.symmetry ?? [],
139
+ residual: {
140
+ areaFraction: input.residual?.areaFraction ?? 0,
141
+ regions: residualRegions,
142
+ },
143
+ score: buildScore(input.score),
144
+ suggestion: input.suggestion ? { ...input.suggestion, steps: suggestionSteps } : null,
145
+ truncated,
146
+ };
147
+ }
148
+
149
+ export function compactDescribe(full) {
150
+ // Gate on the WORSE of the two coverage numbers (see SCORE_NOTE above), not just
151
+ // explainedArea. Reproduced directly against the real pipeline with a 2304-triangle
152
+ // hemisphere: one sphere surface plus one flat base segment cleanly
153
+ // (explainedArea 1.0, zero unassigned triangles) but sphere is not a
154
+ // candidate-eligible type for any of the four feature detectors, so accept.js is
155
+ // handed zero candidates and explainedVolumeFraction is 0.0. Gating on explainedArea
156
+ // alone let a report that reconstructs NONE of the part's shape present as clean —
157
+ // exactly backwards for a banner whose whole job is to catch that (fix round 1,
158
+ // CRITICAL 1). `?? 0` on each side means a missing number reads as unexplained, not
159
+ // as "fine" — the fail-safe direction for a report whose job is to not overclaim.
160
+ const explainedArea = full.score?.explainedArea ?? 0;
161
+ const explainedVolume = full.score?.explainedVolumeFraction ?? 0;
162
+ const coverage = Math.min(explainedArea, explainedVolume);
163
+
164
+ const out = {};
165
+ // Assigned FIRST — and therefore serialized first, both by Object.keys() and by
166
+ // JSON.stringify(), which both preserve insertion order for string keys — so an LLM
167
+ // reading the raw JSON hits the banner before anything it might otherwise take at
168
+ // face value. (Fix round 1, CRITICAL 2: this used to be assigned after the rest of
169
+ // `out` was built, which put it last in both orders — exactly backwards for a banner
170
+ // whose entire job is to be seen first.)
171
+ //
172
+ // Two INDEPENDENT warning sources feed this one field (fix round 2, IMPORTANT 1):
173
+ // low coverage (computed here, from `score`) and `full.warning === "budget-exceeded"`
174
+ // (set by describe.js when acceptCandidates ran out of boolean attempts before the
175
+ // residual converged — see that assignment's own comment). Before this fix
176
+ // `full.warning` was silently dropped: `compactDescribe` only ever set `out.warning`
177
+ // from its own LOW_COVERAGE check, so a report that hit BOTH conditions — a real,
178
+ // documented case, since an exhausted budget is exactly the kind of run that also
179
+ // leaves coverage low — presented as a clean report in every consumer that reads only
180
+ // the compact shape (the CLI's default text view among them). Both fire independently
181
+ // and neither may mask the other, so both are collected and joined rather than one
182
+ // overwriting the other.
183
+ const warnings = [];
184
+ if (coverage < LOW_COVERAGE) {
185
+ warnings.push(
186
+ `LOW COVERAGE: only ${(100 * coverage).toFixed(1)}% of this part is explained ` +
187
+ `(the worse of ${(100 * explainedArea).toFixed(1)}% surface area fit and ` +
188
+ `${(100 * explainedVolume).toFixed(1)}% shape reconstructed). Treat the feature ` +
189
+ `list as incomplete in BOTH directions — do not assume a feature is absent ` +
190
+ `because it is not listed, AND do not assume every listed feature is real: a ` +
191
+ `low-coverage segmentation can also type tessellation fragments (e.g. noise at ` +
192
+ `fillet corners) as their own small bosses/pockets. See residual.regions for ` +
193
+ `where the unexplained geometry is.`);
194
+ }
195
+ if (full.warning === "budget-exceeded") {
196
+ warnings.push(
197
+ `BUDGET EXCEEDED: the acceptance loop hit its boolean-attempt budget before the ` +
198
+ `residual converged. This report is honestly scored, not wrong — but it may be ` +
199
+ `incomplete for the same reason low coverage would be. Raise --budget and ` +
200
+ `re-describe, or treat the current feature list as partial.`);
201
+ }
202
+ if (warnings.length) out.warning = warnings.join("\n\n");
203
+ out.source = full.source;
204
+ out.frame = full.frame;
205
+ out.bounds = full.bounds;
206
+ // Pre-cap counts, straight from `full.counts` — see buildReport's own comment on why
207
+ // these must not be derived from the (possibly-capped) `full.surfaces`/`full.edges`.
208
+ out.counts = full.counts;
209
+ out.features = full.features;
210
+ out.patterns = full.patterns;
211
+ out.symmetry = full.symmetry;
212
+ out.residual = full.residual;
213
+ out.score = full.score;
214
+ out.suggestion = full.suggestion;
215
+ out.truncated = full.truncated;
216
+ return out;
217
+ }