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,454 +0,0 @@
1
- // Pocket, boss, and extrusion rules.
2
- //
3
- // All three are the same observation read at different scopes: a set of side walls sharing
4
- // one sweep direction, capped at one or both ends, is an extrusion of the capped profile.
5
- //
6
- // What separates a POCKET from a BOSS is NOT arc convexity (controller ruling R10). Both
7
- // leave 270 degrees of material where their walls meet the surrounding face — a pocket
8
- // floor and a boss base are each concave seams — so the label is identical on both and
9
- // carries no information. The real distinction is DISPLACEMENT: measure the feature's cap
10
- // plane against the surrounding base plane along their shared normal. A cap sunk into the
11
- // material is a pocket; a cap standing proud of it is a boss. If the walls ARE the part's
12
- // outer envelope, it is the base extrusion and neither.
13
- //
14
- // The extrusion direction comes from the walls, not the cap: a cylindrical wall's own
15
- // fitted axis IS the sweep direction directly (a cylinder is, by definition, swept along
16
- // it — no covariance trick needed, and none is possible from a single fitted axis vector,
17
- // whose own covariance is a degenerate rank-1 matrix with no real "least-spread" direction
18
- // to recover). Only when every wall is PLANAR does the covariance trick apply, and there
19
- // it recovers the direction every wall normal is perpendicular to — the same normal-
20
- // covariance trick fit.js's `ruledSurfaceAxis` uses for a cylinder's axis, just over the
21
- // walls' own fitted normals rather than raw per-face ones. Reading direction from the cap
22
- // normal instead of the walls would fail on a part whose base is not the largest face.
23
- //
24
- // CAP SELECTION runs in two passes, both over the same claimed-surface bookkeeping. Pass
25
- // one looks for the single BASE extrusion, largest area first: the biggest planar face is
26
- // the most likely base of the part's dominant extrusion, and claiming it (plus its walls)
27
- // first keeps the base from being described as a pocket in some smaller face's frame. Pass
28
- // two looks for pockets/bosses among what is left, SMALLEST area first — the reverse order,
29
- // deliberately: a counterbore's floor disk and its own wide mouth annulus both border the
30
- // same bore wall, and if the mouth (the bigger of the two) claimed that wall first, the
31
- // genuine floor would find nothing left to claim and the feature would be reported
32
- // backwards (or not at all). Smallest-first means the floor claims the shared wall before
33
- // its own surrounding mouth gets a chance to.
34
- //
35
- // ISLANDS (round 3 review). `mergeCoFamily` (surface-graph.js) folds two patches into one
36
- // SURFACE whenever they fit the same plane, regardless of whether they touch — a plane
37
- // interrupted by a boss IS one plane, and requiring adjacency there would re-break every
38
- // interrupted surface. But that means a cap surface can silently span TWO physically
39
- // disjoint regions: two same-height boss tops on the same plate merge into one `s<n>`
40
- // with two unconnected islands, and treating the whole surface as one feature built a
41
- // candidate spanning the gap between them — a "bridging box" 4-5x too large, rejected by
42
- // acceptCandidates for negative gain, silently dropping the smaller island's entire volume
43
- // (round 3 review's own repro: 3000 of 27000mm3, 11.1% of the part, unaccounted for and
44
- // under the LOW_COVERAGE threshold so nothing flagged it). `loops.length` alone cannot
45
- // tell "two islands" from "one island with a hole" apart (an annulus is one island, two
46
- // loops), so `islandsOf` walks actual face ADJACENCY instead — connectivity, not boundary
47
- // count. A cap with N islands yields N features, not one; a genuinely interrupted single
48
- // plane (a base with two boss holes cut into it, still one connected blob of material
49
- // around them) stays one island and still yields exactly one feature, unchanged. The same
50
- // merge can happen one level down, on a WALL — two different steps whose footprints share
51
- // one x or y coordinate put both steps' own side walls on the same plane too (found while
52
- // building this fix's own regression fixture) — so every wall is independently re-scoped
53
- // to just the island it actually borders (`wallIslandFor`) before it contributes to a
54
- // feature's depth or geometry. Both helpers need `topo` (the welded mesh topology); omit
55
- // it and every cap is treated as its own single island, matching this file's behaviour
56
- // before this fix.
57
- //
58
- // The `profile` is explicitly a PROPOSAL, not a measurement — it is the cap's boundary
59
- // loop reduced to a circle or a polygon. It exists so hints.js can suggest a sketch;
60
- // nothing in the facts layer depends on it being exact. For a multi-island cap it is
61
- // computed from the WHOLE merged surface's arcs/loops rather than the specific island
62
- // (splitting `graph.arcs`/`loops` by island would need the same per-edge walk
63
- // `wallIslandFor` already does, for a field nothing downstream treats as authoritative) —
64
- // a known, deliberately accepted imprecision in a proposal field, not in a fact.
65
- //
66
- // Pure leaf. See spec §2.5.
67
- import { arcsOf } from "../surface-graph.js";
68
- import { jacobiEigen } from "../fit.js";
69
-
70
- // Wall-vs-direction agreement bands. A planar wall's own normal must be nearly
71
- // PERPENDICULAR to the sweep direction (it is the extrusion's flank, not its cap); a
72
- // cylindrical wall's axis must be nearly PARALLEL to it (a bore or a boss shaft runs
73
- // straight along the sweep, it does not cut across it). Same numeric margin, opposite
74
- // sense, so one constant serves both: "within 0.08 of exactly perpendicular" and
75
- // "within 0.08 of exactly parallel" are the same tolerance read two ways.
76
- const PERPENDICULAR_DOT = 0.08;
77
- // A candidate cap's own boundary arcs must overwhelmingly agree on ONE convexity — see
78
- // `dominantConvexityFraction`'s own comment for the full reasoning and the fixture
79
- // numbers this threshold was picked against. 0.9 leaves room for real machining noise
80
- // (a slightly rounded corner reads as a hair off pure convex/concave) while sitting far
81
- // below the 100% every genuine cap in this suite measures, and far above the ~64% the
82
- // one fixture-proven false positive measures.
83
- const DOMINANT_CONVEXITY_FRAC = 0.9;
84
- const round3 = (v) => Math.round(v * 1000) / 1000;
85
- const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
86
- const byId = (graph) => new Map(graph.surfaces.map((s) => [s.id, s]));
87
- const other = (arc, id) => (arc.between[0] === id ? arc.between[1] : arc.between[0]);
88
-
89
- // What fraction of a candidate cap's own TOTAL boundary (by arc length, not arc count —
90
- // a short corner arc must not outvote a long edge) agrees on its single most common
91
- // convexity, and which convexity that is.
92
- //
93
- // A genuine cap sits at ONE END of a sweep, and every one of its own boundary arcs
94
- // shares the SAME relationship to its walls: a base extrusion's cap, or a boss's own
95
- // top, meets every one of its walls at an ordinary OUTWARD corner (all convex); a
96
- // pocket floor, or a boss's own base, meets every one of its walls at the 270-degree
97
- // INWARD corner every pocket floor and boss base share (all concave, ruling R10). A
98
- // surface whose neighbours are a genuine MIX of both is not itself the cap of any
99
- // single coherent sweep — it is some other feature's surface, caught here only because
100
- // it happens to border this one.
101
- //
102
- // Found empirically, not derived on paper: a wide-shallow pocket's own small SIDE wall
103
- // (`boxWithPocket(30,20,8, 10,6, 10,8, 3)`'s 8x3=24mm^2 wall) was passing every other
104
- // check in this file — `sweepDirection`'s covariance trick and the perpendicularity
105
- // filter both degenerate to trivially-satisfied on axis-aligned geometry once a
106
- // candidate's neighbours only span two of three orthogonal axes, which a plain
107
- // perpendicular-vs-direction test cannot tell apart from a genuine cap's walls (both
108
- // come out 100% "perpendicular"; verified directly by dumping the graph). That wall's
109
- // own arcs, measured by length: one 8mm CONVEX arc to the pocket's mouth (the
110
- // surrounding top face) and three arcs totalling 14mm CONCAVE (the floor, 8mm, plus the
111
- // two neighbouring pocket walls, 3mm each) — the dominant (concave) side is 14/22 =
112
- // 63.6% of this candidate's own perimeter, nowhere near a majority worth trusting, let
113
- // alone the genuine floor's own 100% (all four of ITS arcs — to the same four
114
- // neighbours — are concave). Without this gate the wall was accepted as a "cap" in its
115
- // own right, reporting a pocket of depth 20 (the plate's own unrelated width) with
116
- // `floorFace` bound to a 24mm^2 wall instead of the real 80mm^2 floor.
117
- function dominantConvexityFraction(arcs) {
118
- const byKind = { convex: 0, concave: 0, flat: 0 };
119
- let total = 0;
120
- for (const a of arcs) { byKind[a.convexity] = (byKind[a.convexity] ?? 0) + a.length; total += a.length; }
121
- if (!(total > 0)) return 0;
122
- return Math.max(byKind.convex, byKind.concave, byKind.flat) / total;
123
- }
124
-
125
- // The direction this feature is swept along, read off its walls rather than its cap
126
- // (see header). Cylindrical walls hand it over directly and exactly: average their own
127
- // axes, flipping any that happen to have fit with the opposite sign first so genuine
128
- // agreement cannot cancel to zero. Only when there is no cylindrical wall to ask does
129
- // this fall back to the covariance trick over the planar walls' own fitted normals.
130
- function sweepDirection(walls) {
131
- const axial = walls.filter((w) => w.type === "cylinder");
132
- if (axial.length > 0) {
133
- const ref = axial[0].fit.axis.direction;
134
- const sum = [0, 0, 0];
135
- for (const w of axial) {
136
- const d = w.fit.axis.direction;
137
- const s = dot(d, ref) < 0 ? -1 : 1;
138
- sum[0] += s*d[0]; sum[1] += s*d[1]; sum[2] += s*d[2];
139
- }
140
- const len = Math.hypot(sum[0], sum[1], sum[2]) || 1;
141
- return [sum[0]/len, sum[1]/len, sum[2]/len];
142
- }
143
- // Every planar wall's normal lies perpendicular to the sweep direction, so the
144
- // least-spread eigenvector of their covariance recovers it.
145
- const cov = [[0,0,0],[0,0,0],[0,0,0]];
146
- for (const w of walls) {
147
- const n = w.fit.normal;
148
- if (!n) continue;
149
- for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) cov[i][j] += n[i]*n[j];
150
- }
151
- const v = jacobiEigen(cov).vectors[0];
152
- const len = Math.hypot(v[0], v[1], v[2]) || 1;
153
- return [v[0]/len, v[1]/len, v[2]/len];
154
- }
155
-
156
- // Does this wall actually belong to a sweep along `direction`? A planar wall must be
157
- // perpendicular to it (a flank); a cylindrical wall must be parallel to it (a bore/boss
158
- // running straight through). Anything else is some other feature's surface, pulled in
159
- // only because it happened to neighbour this cap.
160
- function isSideWallOf(w, direction) {
161
- if (w.type === "plane") return Math.abs(dot(w.fit.normal, direction)) < PERPENDICULAR_DOT;
162
- if (w.type === "cylinder") return Math.abs(dot(w.fit.axis.direction, direction)) > 1 - PERPENDICULAR_DOT;
163
- return false;
164
- }
165
-
166
- // A cap's boundary reduced to something a sketch could be built from. One loop that is
167
- // a circle -> circle; a loop whose arcs are all straight -> polygon; anything else ->
168
- // mixed, and hints.js will decline to propose a sketch for it. Whole-cap, not
169
- // per-island — see this file's header for why that is an accepted imprecision here.
170
- function profileOf(graph, cap) {
171
- const arcs = arcsOf(graph, cap.id);
172
- const circles = arcs.filter((a) => a.kind === "circle");
173
- if (circles.length === 1 && arcs.length === 1) return { kind: "circle", radius: circles[0].radius };
174
- if (arcs.length && arcs.every((a) => a.kind === "line")) {
175
- return { kind: "polygon", points: (cap.loops[0] ?? []).length };
176
- }
177
- return { kind: "mixed" };
178
- }
179
-
180
- // Partitions a surface's own triangles into connected components by face ADJACENCY —
181
- // the actual test for "one island" vs "two", which boundary-loop count cannot make
182
- // (an annulus is one island with two loops; two disjoint same-plane patches are two
183
- // islands each with one). Restricted to `faces`' own membership, exactly like
184
- // describe.js's `residualRegions` island walk and segment.js's own connectivity
185
- // passes — the same primitive, reused rather than reinvented.
186
- function islandsOf(topo, faces) {
187
- const inSet = new Set(faces);
188
- const seen = new Set();
189
- const islands = [];
190
- for (const seed of faces) {
191
- if (seen.has(seed)) continue;
192
- const island = [];
193
- const stack = [seed];
194
- seen.add(seed);
195
- while (stack.length) {
196
- const t = stack.pop();
197
- island.push(t);
198
- for (const ei of topo.faceEdges[t]) {
199
- const e = topo.edges[ei];
200
- const nb = e.triA === t ? e.triB : e.triA;
201
- if (nb >= 0 && inSet.has(nb) && !seen.has(nb)) { seen.add(nb); stack.push(nb); }
202
- }
203
- }
204
- islands.push(island);
205
- }
206
- return islands;
207
- }
208
-
209
- // The subset of `wallSurf`'s own triangles that actually border `islandFaces` — not
210
- // the whole wall surface, which (the same merge, one level down) can itself span more
211
- // than one physical wall. Seeds from triangles with a direct edge into the island, then
212
- // grows within the wall surface's own face set only, so a wall genuinely shared between
213
- // two different islands (this file's header) is still fully credited to each of them
214
- // rather than pulling in the other island's own unrelated geometry.
215
- function wallIslandFor(topo, wallSurf, islandFaces) {
216
- const islandSet = new Set(islandFaces);
217
- const wallSet = new Set(wallSurf.faces);
218
- const seeds = [];
219
- for (const t of wallSurf.faces) {
220
- for (const ei of topo.faceEdges[t]) {
221
- const e = topo.edges[ei];
222
- const nb = e.triA === t ? e.triB : e.triA;
223
- if (nb >= 0 && islandSet.has(nb)) { seeds.push(t); break; }
224
- }
225
- }
226
- if (seeds.length === 0) return [];
227
- const seen = new Set(seeds);
228
- const stack = [...seeds];
229
- const out = [];
230
- while (stack.length) {
231
- const t = stack.pop();
232
- out.push(t);
233
- for (const ei of topo.faceEdges[t]) {
234
- const e = topo.edges[ei];
235
- const nb = e.triA === t ? e.triB : e.triA;
236
- if (nb >= 0 && wallSet.has(nb) && !seen.has(nb)) { seen.add(nb); stack.push(nb); }
237
- }
238
- }
239
- return out;
240
- }
241
-
242
- // Sum of `topo.faceArea` over a face list — the island-scoped twin of a surface's own
243
- // (whole-surface) `.area`.
244
- const facesArea = (topo, faces) => faces.reduce((a, t) => a + topo.faceArea[t], 0);
245
-
246
- // Vertex-average centroid of a face list (unweighted; a rough "where in the plane is
247
- // this" for key disambiguation, not a measurement anything else reads). Two same-size,
248
- // same-height, same-depth islands (a truly symmetric pair of identical bosses) would
249
- // otherwise share an identical key — `round3(area)` alone does not separate them, but
250
- // their positions do.
251
- function facesCentroid(topo, faces) {
252
- const c = [0, 0, 0];
253
- let n = 0;
254
- for (const t of faces) for (let k = 0; k < 3; k++) {
255
- const v = topo.tris[3*t + k] * 3;
256
- c[0] += topo.verts[v]; c[1] += topo.verts[v+1]; c[2] += topo.verts[v+2];
257
- n++;
258
- }
259
- return n ? [c[0]/n, c[1]/n, c[2]/n] : [0, 0, 0];
260
- }
261
-
262
- // `topo` is optional (matches sweeps.js's own `opts.bvh` precedent, Task 12 round 1):
263
- // omit it and every cap is treated as a single island, exactly this file's behaviour
264
- // before round 3's fix — every existing 1-arg `detectPrismatic(graph)` call (this
265
- // file's own tests included) keeps working unchanged.
266
- export function detectPrismatic(graph, topo) {
267
- const surfaces = byId(graph);
268
- const claimed = new Set();
269
- const out = [];
270
-
271
- const allCaps = graph.surfaces.filter((s) => s.type === "plane").sort((a, b) => b.area - a.area);
272
-
273
- // The plane a pocket sinks into or a boss stands on: must be something THIS
274
- // feature's OWN walls actually meet, not merely any co-oriented plane anywhere on
275
- // the part (fix round 2, CRITICAL — R37 added a perpendicularity requirement for
276
- // the WALLS and never required the SURROUND to actually surround anything. The old
277
- // `allCaps.find(dot > 0.98)` could match an unrelated co-oriented plane elsewhere
278
- // on the part — e.g. a compound-fillet corner's own near-flat micro-facet — with
279
- // no adjacency at all, and WHICH unrelated plane won that search was itself
280
- // orientation-dependent: the same real boss could report as a pocket depending on
281
- // how the part happened to be rotated, which is the worst single thing this
282
- // feature can tell a rebuilding agent). Reachable means: walk each of this
283
- // feature's own walls' arcs (`arcsOf`) for a co-oriented plane on the other side —
284
- // a plane those walls also meet, exactly like a pocket floor or boss base
285
- // physically has to. More than one candidate can survive that (two different walls,
286
- // or one wall at a compound corner, each bordering their own genuinely-adjacent
287
- // co-oriented plane) — broken by shared boundary LENGTH, not first-found: the
288
- // plane the walls spend the most edge actually touching is the one surrounding
289
- // the feature.
290
- function findSurround(cap, islandWalls) {
291
- const byLength = new Map(); // surface id -> accumulated shared arc length
292
- for (const { w } of islandWalls) {
293
- for (const a of arcsOf(graph, w.id)) {
294
- const id = other(a, w.id);
295
- if (id === cap.id) continue;
296
- const s = surfaces.get(id);
297
- if (!s || s.type !== "plane" || dot(s.fit.normal, cap.fit.normal) <= 0.98) continue;
298
- byLength.set(id, (byLength.get(id) ?? 0) + a.length);
299
- }
300
- }
301
- let best = null, bestLen = -1;
302
- for (const [id, len] of byLength) if (len > bestLen) { bestLen = len; best = surfaces.get(id); }
303
- return best;
304
- }
305
-
306
- // Builds ONE feature from one island's own faces plus the wall segments (already
307
- // scoped to that same island by the caller) that border it. Factored out of `tryCap`
308
- // so the ordinary single-island case and the multi-island split share exactly one
309
- // implementation rather than two copies that could drift apart.
310
- function buildFeature(cap, isBase, islandFaces, islandWalls, direction, arcs) {
311
- // Depth from a cylindrical wall's own axial extent, when there is one.
312
- let lo = Infinity, hi = -Infinity;
313
- for (const { w } of islandWalls) {
314
- if (w.type === "cylinder") { lo = Math.min(lo, w.fit.extent[0]); hi = Math.max(hi, w.fit.extent[1]); }
315
- }
316
- const hasCylExtent = Number.isFinite(lo);
317
-
318
- let type = "extrusion", depth;
319
- if (isBase) {
320
- if (hasCylExtent) {
321
- depth = hi - lo;
322
- } else {
323
- // Planar walls carry no extent, so read the thickness off the opposing cap.
324
- // Once R31 has oriented every plane normal outward, this is exact rather than a
325
- // guess: two opposing faces of a solid have ANTI-PARALLEL outward normals, and
326
- // for `offset = n . p` the separation between them is simply the SUM of the
327
- // offsets.
328
- const opposite = allCaps.find((c) => c.id !== cap.id && dot(c.fit.normal, cap.fit.normal) < -0.98);
329
- if (!opposite) return null;
330
- depth = cap.fit.offset + opposite.fit.offset;
331
- if (!(depth > 0)) return null; // not a solid pair; no depth to report
332
- }
333
- } else {
334
- // Recessed or raised? Compare this cap's plane against the plane its OWN walls
335
- // are actually adjacent to (see `findSurround`'s own comment, above) — the
336
- // surrounding face a pocket sinks into or a boss stands on.
337
- const surround = findSurround(cap, islandWalls);
338
- const displacement = surround ? cap.fit.offset - surround.fit.offset : 0;
339
- type = displacement < 0 ? "pocket" : "boss";
340
- if (hasCylExtent) depth = hi - lo;
341
- else if (surround) depth = displacement;
342
- else return null; // no cylindrical wall and no surrounding plane: nothing to measure
343
- }
344
- depth = Math.abs(depth);
345
-
346
- const islandArea = topo ? facesArea(topo, islandFaces) : cap.area;
347
- const centroid = topo ? facesCentroid(topo, islandFaces) : [0, 0, 0];
348
- const faceScope = { [cap.id]: islandFaces };
349
- for (const { w, faces } of islandWalls) faceScope[w.id] = faces;
350
-
351
- return {
352
- id: null,
353
- // Geometry-derived, not `cap.id` (a segmentation surface id assigned in
354
- // triangle-DISCOVERY order — round 2 review: permuting a mesh's own triangle
355
- // order, same geometry, moved a boss's key). `cap.fit.offset`/`normal` pin
356
- // down the cap's own PLANE (canonicalised by `orientPlaneOutward`,
357
- // surface-graph.js, independent of input order); the island's own area AND
358
- // centroid (round 3 review) separate two same-depth, same-plane islands —
359
- // two co-height bosses on the same face, or even two identically-sized ones
360
- // at different positions, which area alone cannot tell apart. `round3`
361
- // absorbs the float-associativity noise a permuted summation order
362
- // introduces (Task 4's own ruling, R29).
363
- key: `${type}:${round3(depth)}:${round3(cap.fit.offset)}:` +
364
- `${cap.fit.normal.map(round3).join(",")}:${round3(islandArea)}:${centroid.map(round3).join(",")}`,
365
- type, depth, direction,
366
- floorFace: cap.id,
367
- wallFaces: islandWalls.map(({ w }) => w.id),
368
- profile: profileOf(graph, cap),
369
- surfaces: [cap.id, ...islandWalls.map(({ w }) => w.id)],
370
- // Per-surface face lists SCOPED to this specific island — describe.js's
371
- // candidate builder reads this (when present) instead of a named surface's
372
- // WHOLE face list, which is what let a merged wall or cap silently pull in
373
- // a neighbouring island's geometry (round 3 review; describe.js's own
374
- // `surfaceVertices` docs the consuming side).
375
- faceScope,
376
- evidence: {
377
- walls: islandWalls.length,
378
- // Whole-cap arc counts, shared across every island of the same merged
379
- // surface — a diagnostic field, not a measurement anything downstream
380
- // depends on being island-exact.
381
- concaveArcs: arcs.filter((a) => a.convexity === "concave").length,
382
- convexArcs: arcs.filter((a) => a.convexity === "convex").length,
383
- },
384
- };
385
- }
386
-
387
- // Attempt one cap as a feature's own defining plane. `isBase` picks which depth
388
- // fallback and which type this cap is allowed to resolve to; returns an ARRAY of
389
- // pushed features (one per island — almost always exactly one), or null if this cap
390
- // does not resolve into any (already claimed, no unclaimed walls left to it, or no
391
- // way to measure a depth on any of its islands).
392
- function tryCap(cap, isBase) {
393
- if (claimed.has(cap.id)) return null;
394
- const arcs = arcsOf(graph, cap.id);
395
- if (arcs.length === 0) return null;
396
- // Reject outright — not "proceed with a filtered subset" — when this candidate's own
397
- // boundary does not overwhelmingly agree on one convexity. See
398
- // `dominantConvexityFraction`'s own comment: this is what actually distinguishes a
399
- // genuine cap from a wall being mistaken for one, since the perpendicularity check
400
- // below cannot (both come out 100% "perpendicular" on axis-aligned geometry).
401
- if (dominantConvexityFraction(arcs) < DOMINANT_CONVEXITY_FRAC) return null;
402
- // Only UNCLAIMED neighbours count as this cap's own walls. Without this filter, the
403
- // base extrusion's OTHER end cap (a plain box's top, once its bottom has already
404
- // claimed all four side walls) would be re-examined here, find those same walls
405
- // still attached, and get reported as a second, spurious feature — with no
406
- // surrounding co-oriented plane to compare against, it would default to "boss" with
407
- // zero displacement. Filtering to unclaimed walls means a cap with nothing left to
408
- // claim is recognised as just the far side of an already-described feature.
409
- const walls = arcs.map((a) => surfaces.get(other(a, cap.id))).filter(Boolean).filter((w) => !claimed.has(w.id));
410
- if (walls.length === 0) return null;
411
-
412
- const direction = sweepDirection(walls);
413
- const sideWalls = walls.filter((w) => isSideWallOf(w, direction));
414
- if (sideWalls.length === 0) return null;
415
-
416
- // ISLANDS — see this file's header for the full reasoning. Almost always exactly
417
- // one; `topo`'s absence (a caller that hasn't wired it) is treated the same as
418
- // "definitely one island", which is this file's pre-fix behaviour exactly.
419
- const capIslands = topo ? islandsOf(topo, cap.faces) : [cap.faces];
420
-
421
- const features = [];
422
- for (const islandFaces of capIslands) {
423
- // Re-scope every side wall to just the component actually touching THIS
424
- // island — a wall can itself be multi-island (this file's header), and a
425
- // wall with nothing bordering this particular island contributes nothing
426
- // to it.
427
- const islandWalls = sideWalls
428
- .map((w) => ({ w, faces: topo ? wallIslandFor(topo, w, islandFaces) : w.faces }))
429
- .filter((x) => x.faces.length > 0);
430
- if (islandWalls.length === 0) continue;
431
-
432
- const f = buildFeature(cap, isBase, islandFaces, islandWalls, direction, arcs);
433
- if (f) features.push(f);
434
- }
435
- if (features.length === 0) return null;
436
-
437
- claimed.add(cap.id);
438
- for (const w of sideWalls) claimed.add(w.id);
439
- return features;
440
- }
441
-
442
- // Pass one: the single base extrusion, largest cap first.
443
- for (const cap of allCaps) {
444
- const feats = tryCap(cap, out.length === 0);
445
- if (feats) { out.push(...feats); break; }
446
- }
447
- // Pass two: everything left, smallest cap first (see header for why the order flips).
448
- for (const cap of [...allCaps].sort((a, b) => a.area - b.area)) {
449
- const feats = tryCap(cap, false);
450
- if (feats) out.push(...feats);
451
- }
452
-
453
- return out;
454
- }