partforge 0.76.0 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +150 -6
- package/docs/AUTHORING-PARTS.md +196 -0
- package/docs/ERROR-PATTERNS.md +42 -0
- package/package.json +1 -1
- package/src/framework/jobs.js +31 -0
- package/src/framework/lint/index.js +37 -3
- package/src/framework/lint/rules-schema.js +39 -11
- package/src/framework/lint/rules-source.js +108 -0
- package/src/framework/lint/source-scan.js +333 -0
- package/src/framework/oracle/describe/accept.js +188 -0
- package/src/framework/oracle/describe/features/dressups.js +173 -0
- package/src/framework/oracle/describe/features/holes.js +129 -0
- package/src/framework/oracle/describe/features/prismatic.js +454 -0
- package/src/framework/oracle/describe/features/sweeps.js +233 -0
- package/src/framework/oracle/describe/fit.js +535 -0
- package/src/framework/oracle/describe/hints.js +91 -0
- package/src/framework/oracle/describe/limits.js +19 -0
- package/src/framework/oracle/describe/patterns.js +494 -0
- package/src/framework/oracle/describe/ransac.js +391 -0
- package/src/framework/oracle/describe/report.js +217 -0
- package/src/framework/oracle/describe/segment.js +498 -0
- package/src/framework/oracle/describe/snap.js +83 -0
- package/src/framework/oracle/describe/surface-graph.js +396 -0
- package/src/framework/oracle/describe/topology.js +121 -0
- package/src/framework/oracle/describe.js +538 -0
- package/src/lint.js +5 -0
- package/src/testing.js +5 -0
- package/types/lint.d.ts +44 -2
- package/types/testing.d.ts +178 -0
|
@@ -0,0 +1,454 @@
|
|
|
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
|
+
}
|