partforge 0.77.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.
@@ -0,0 +1,396 @@
1
+ // Patches -> the attributed adjacency graph (Joshi & Chang 1988), the structure every
2
+ // feature rule in features/ is written against. Nodes are surfaces; arcs are the
3
+ // shared boundaries between them, each labelled convex or concave and carrying its own
4
+ // geometry.
5
+ //
6
+ // TWO attributes come out of here, and confusing them is the trap this file exists to
7
+ // close (controller ruling R10, found when Task 1's fixture review contradicted itself).
8
+ //
9
+ // ARC convexity says whether an edge is an outer or an inner corner, and nothing more. It
10
+ // is decided by the interior dihedral measured through the material: under 180° convex,
11
+ // over 180° concave. A through-hole's rim leaves a 90° wedge of material and is CONVEX —
12
+ // exactly like the outer edge of the same plate. A pocket floor or a boss base leaves 270°
13
+ // and is CONCAVE. So arc convexity cannot, on its own, tell a hole from a boss: it is the
14
+ // same label on both.
15
+ //
16
+ // SURFACE curvature is what does tell them apart. A bore is a concave cylinder — its
17
+ // outward normals point toward its own axis — while a shaft or a boss is a convex one,
18
+ // normals pointing away. That sign is a property of the surface, not of any edge, and it
19
+ // survives tessellation density, partial arcs, and whatever the neighbouring faces do.
20
+ //
21
+ // Both are derived here, once, and every feature rule downstream reads them rather than
22
+ // recomputing either.
23
+ //
24
+ // An arc's convexity is the SIGN-MAJORITY of its constituent edges weighted by length,
25
+ // not the first edge's label. A tessellated circular seam has a handful of edges whose
26
+ // individual dihedral wobbles around zero at the facet joins; taking one of them as
27
+ // the verdict makes a hole intermittently read as a boss.
28
+ //
29
+ // Pure leaf. See spec §2.4.
30
+ import { fitPlane, fitSphere, fitCylinder, fitCone, fitTorus, intrinsicScale } from "./fit.js";
31
+ import { facePoints, FIT_TOL_FRAC } from "./segment.js";
32
+
33
+ const sub = (a, b) => [a[0]-b[0], a[1]-b[1], a[2]-b[2]];
34
+ const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
35
+ const dist = (a, b) => Math.hypot(a[0]-b[0], a[1]-b[1], a[2]-b[2]);
36
+
37
+ // A patch pair is joined by an arc only if their shared boundary has at least this
38
+ // many edges. Left at 1 (every real adjacency clears it trivially) rather than
39
+ // deleted, same reasoning as segment.js's MIN_PATCH_FACES: the one knob a future
40
+ // revision would raise to also discard a stray single-facet contact as noise.
41
+ const MIN_ARC_EDGES = 1;
42
+ // Circularity band: an arc is a circle when its edge midpoints are equidistant from
43
+ // their own centroid to within this fraction of the mean radius.
44
+ const CIRCLE_TOL_FRAC = 0.02;
45
+
46
+ const vertOf = (topo, v) => [topo.verts[3*v], topo.verts[3*v+1], topo.verts[3*v+2]];
47
+
48
+ // Classify an arc's shape from its edge midpoints. Straight arcs have collinear
49
+ // midpoints; circular arcs have midpoints on a common circle. Anything else is
50
+ // "mixed" and no rule is allowed to assume geometry about it.
51
+ function arcKind(pts) {
52
+ if (pts.length < 3) return { kind: "line", radius: null, axis: null, center: null };
53
+ const c = pts.reduce((a, p) => [a[0]+p[0]/pts.length, a[1]+p[1]/pts.length, a[2]+p[2]/pts.length], [0,0,0]);
54
+ const radii = pts.map((p) => dist(p, c));
55
+ const mean = radii.reduce((a, b) => a + b, 0) / radii.length;
56
+ if (mean < 1e-12) return { kind: "line", radius: null, axis: null, center: null };
57
+ const spread = Math.max(...radii.map((r) => Math.abs(r - mean))) / mean;
58
+ if (spread > CIRCLE_TOL_FRAC) {
59
+ // Not a circle. Collinear midpoints are the common case here — one straight
60
+ // edge chain, e.g. a box corner — and must not be reported as a huge-radius
61
+ // circle just because a handful of points near-collinear technically have
62
+ // *some* circumscribing circle.
63
+ const d0 = sub(pts[1], pts[0]);
64
+ const len = Math.hypot(d0[0], d0[1], d0[2]);
65
+ const straight = len > 0 && pts.every((p) => {
66
+ const d = sub(p, pts[0]);
67
+ const t = dot(d, d0) / (len*len);
68
+ return Math.hypot(d[0]-t*d0[0], d[1]-t*d0[1], d[2]-t*d0[2]) < len * CIRCLE_TOL_FRAC;
69
+ });
70
+ return { kind: straight ? "line" : "mixed", radius: null, axis: null, center: null };
71
+ }
72
+ // Circle: the axis is the normal of the plane the midpoints lie in, taken from
73
+ // the first three points — conditioned well enough for a genuine circular seam,
74
+ // whose points are never near-collinear (that path already returned above).
75
+ const a = sub(pts[1], pts[0]), b = sub(pts[2], pts[0]);
76
+ const n = [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];
77
+ const nl = Math.hypot(n[0], n[1], n[2]) || 1;
78
+ return { kind: "circle", radius: mean, axis: [n[0]/nl, n[1]/nl, n[2]/nl], center: c };
79
+ }
80
+
81
+ // Does this curved patch bend away from its own centre of curvature, or around it?
82
+ // Take each face's outward normal against the radial vector from the surface's own
83
+ // axis (or centre, for a sphere) out to that face's centroid: a normal pointing
84
+ // outward (same sense as the radial vector) means a convex surface — a shaft, a
85
+ // boss, a plate's outer wall. A normal pointing back toward the axis means a
86
+ // concave one — a bore. Area-weighted so one bad facet normal cannot flip the
87
+ // verdict on a large patch built from many small ones.
88
+ //
89
+ // Planes get null: a plane has no centre and no side, and forcing it into this
90
+ // vocabulary would invent a distinction the geometry does not carry.
91
+ function curvatureOf(topo, patch) {
92
+ const fit = patch.fit;
93
+ const centre = fit.type === "cylinder" ? fit.axis.origin
94
+ : fit.type === "cone" ? fit.apex
95
+ : fit.type === "sphere" ? fit.center
96
+ : fit.type === "torus" ? fit.center : null;
97
+ if (!centre) return null;
98
+ const axis = fit.type === "cylinder" ? fit.axis.direction
99
+ : fit.type === "cone" ? fit.direction
100
+ : fit.type === "torus" ? fit.axis : null;
101
+ let vote = 0;
102
+ for (const t of patch.faces) {
103
+ const c = [0, 0, 0];
104
+ for (let k = 0; k < 3; k++) {
105
+ const v = topo.tris[3*t + k] * 3;
106
+ for (let a = 0; a < 3; a++) c[a] += topo.verts[v + a] / 3;
107
+ }
108
+ let radial = sub(c, centre);
109
+ if (axis) {
110
+ // Only the component perpendicular to the axis is radial; the axial part
111
+ // (how far along the bore/boss the face sits) says nothing about which
112
+ // way the surface curves and would only dilute the vote.
113
+ const ax = dot(radial, axis);
114
+ radial = [radial[0]-ax*axis[0], radial[1]-ax*axis[1], radial[2]-ax*axis[2]];
115
+ }
116
+ const rl = Math.hypot(radial[0], radial[1], radial[2]);
117
+ if (rl < 1e-12) continue; // a face centred on its own axis has no radial direction to vote with
118
+ const n = [topo.faceNormal[3*t], topo.faceNormal[3*t+1], topo.faceNormal[3*t+2]];
119
+ vote += topo.faceArea[t] * dot(n, radial) / rl;
120
+ }
121
+ if (Math.abs(vote) < 1e-12) return null;
122
+ return vote > 0 ? "convex" : "concave";
123
+ }
124
+
125
+ // `fitPlane`'s normal sign is an artifact of the eigensolver's own convention (whichever
126
+ // way the smallest-eigenvalue eigenvector happened to point), not tied to which side the
127
+ // material is on (controller ruling R31). Task 6 verified this directly: a washer's two
128
+ // caps — translated copies of the same ring shape, hence identical PCA covariance — both
129
+ // come back with fit.normal = (0,0,1), never the physically opposite pair the caps
130
+ // actually have. Left uncorrected, this breaks the pocket-vs-boss rule below (it compares
131
+ // SIGNED cap displacement, meaningless against an arbitrary sign) and makes the report's
132
+ // own `surfaces[].fit.normal` actively misstate which way a face points.
133
+ //
134
+ // Fixed here, once, so every consumer inherits an outward-pointing normal rather than an
135
+ // arbitrary one: average this patch's OWN faces' outward normals — `topo.faceNormal` is
136
+ // outward by construction, exactly what this file's header says every dihedral sign
137
+ // already depends on — and if the fitted normal opposes that average, negate BOTH
138
+ // `normal` and `offset` together. Flipping only one would silently break `offset = dot
139
+ // (normal, pointOnPlane)`, not just leave the direction looking wrong.
140
+ function orientPlaneOutward(topo, patch) {
141
+ const fit = patch.fit;
142
+ if (fit.type !== "plane") return fit;
143
+ const meanN = [0, 0, 0];
144
+ for (const t of patch.faces) {
145
+ const a = topo.faceArea[t];
146
+ meanN[0] += a * topo.faceNormal[3*t]; meanN[1] += a * topo.faceNormal[3*t+1]; meanN[2] += a * topo.faceNormal[3*t+2];
147
+ }
148
+ if (dot(meanN, fit.normal) >= 0) return fit;
149
+ return { ...fit, normal: [-fit.normal[0], -fit.normal[1], -fit.normal[2]], offset: -fit.offset };
150
+ }
151
+
152
+ // Do two fits describe the same surface? Same primitive type, and parameters agreeing
153
+ // within a relative band — coaxial cylinders of equal radius, coplanar planes of equal
154
+ // normal and offset, and so on. Relative, never absolute: these are millimetre parts but
155
+ // the code must not assume a scale. Compare directions with |dot| so an antiparallel axis
156
+ // (the same line, traversed the other way) still matches.
157
+ const SAME_REL = 1e-3;
158
+ const closeRel = (a, b) => Math.abs(a - b) <= Math.max(Math.abs(a), Math.abs(b), 1) * SAME_REL;
159
+ const sameDir = (a, b) => Math.abs(dot(a, b)) > 1 - SAME_REL;
160
+
161
+ // Exported for direct unit testing only (the module's real contract is
162
+ // `surfaceGraph`/`arcsOf`): the post-merge residual guard in `mergeCoFamily` makes
163
+ // most position mismatches large enough to also fail on refit residual alone, at
164
+ // this file's own tolerance scale, which would otherwise mask a regression in this
165
+ // function's own position checks behind that second, coarser safety net. Testing
166
+ // this directly against fabricated fit-parameter objects (no mesh required)
167
+ // exercises the exact band each branch checks, independent of that backstop.
168
+ export function sameSurface(a, b) {
169
+ if (a.type !== b.type) return false;
170
+ if (a.type === "plane") {
171
+ if (!sameDir(a.normal, b.normal)) return false;
172
+ // `offset` is `dot(normal, pointOnPlane)`, so it is only comparable between
173
+ // fits whose normals point the SAME way. Comparing |offset| independently
174
+ // (rather than canonicalizing orientation first) is wrong, not merely
175
+ // imprecise: a plate centred on the origin has its two opposite faces at
176
+ // z=-t/2 and z=+t/2, with outward normals (0,0,-1) and (0,0,1) — offsets
177
+ // dot((0,0,-1),(0,0,-t/2))=t/2 and dot((0,0,1),(0,0,t/2))=t/2 come out
178
+ // IDENTICAL in magnitude for two genuinely different, parallel-but-distinct
179
+ // planes. Flipping the antiparallel one's (normal, offset) pair together
180
+ // before comparing raw offsets is what tells that case apart from the same
181
+ // plane seen through two windings (see ransac.js's planeHypothesis for the
182
+ // same flip, applied for the same reason).
183
+ const bOffset = dot(a.normal, b.normal) < 0 ? -b.offset : b.offset;
184
+ return closeRel(a.offset, bOffset);
185
+ }
186
+ if (a.type === "sphere") return closeRel(a.radius, b.radius) && closeRel(0, dist(a.center, b.center));
187
+ if (a.type === "cylinder") {
188
+ // Coaxial means parallel AND collinear: parallel axes at different offsets are two
189
+ // different bores of the same size, which must NOT merge.
190
+ const d = sub(a.axis.origin, b.axis.origin);
191
+ const along = dot(d, b.axis.direction);
192
+ const perp = Math.hypot(d[0]-along*b.axis.direction[0], d[1]-along*b.axis.direction[1], d[2]-along*b.axis.direction[2]);
193
+ return closeRel(a.radius, b.radius) && sameDir(a.axis.direction, b.axis.direction) && perp <= a.radius * SAME_REL;
194
+ }
195
+ if (a.type === "cone") return sameDir(a.direction, b.direction) && closeRel(a.halfAngle, b.halfAngle) && closeRel(0, dist(a.apex, b.apex));
196
+ if (a.type === "torus") {
197
+ // Same shape (axis direction, both radii) is NOT the same surface without also
198
+ // checking POSITION — every other branch above gates on it (plane: offset;
199
+ // sphere/cone: centre/apex distance; cylinder: perpendicular offset from the
200
+ // axis), and the torus branch originally didn't, which is exactly the kind of
201
+ // gap this file's header warns about: two unrelated O-ring grooves of the same
202
+ // size, on the same shaft but at different heights, or two identical grooves at
203
+ // opposite ends of a part, share axis direction and both radii and would merge
204
+ // into one impossible surface. Same decomposition as the cylinder branch: split
205
+ // centre-to-centre into the component ALONG the axis (must be small — two
206
+ // coaxial grooves at different heights are different grooves) and the
207
+ // component PERPENDICULAR to it (must be small — two grooves off to the side
208
+ // of each other, sharing a parallel axis, are different grooves too).
209
+ const d = sub(a.center, b.center);
210
+ const along = dot(d, b.axis);
211
+ const perp = Math.hypot(d[0]-along*b.axis[0], d[1]-along*b.axis[1], d[2]-along*b.axis[2]);
212
+ return sameDir(a.axis, b.axis) && closeRel(a.majorRadius, b.majorRadius) && closeRel(a.minorRadius, b.minorRadius)
213
+ && perp <= a.majorRadius * SAME_REL && Math.abs(along) <= a.majorRadius * SAME_REL;
214
+ }
215
+ return false;
216
+ }
217
+
218
+ // Re-run one specific fit over a merged patch's points. Not `bestFit`: the merged patch is
219
+ // already classified, and re-classifying could flip its type on a fragment boundary.
220
+ const REFITTERS = {
221
+ plane: (pts) => fitPlane(pts),
222
+ sphere: (pts) => fitSphere(pts),
223
+ cylinder: (pts, normals) => fitCylinder(pts, normals),
224
+ cone: (pts, normals) => fitCone(pts, normals),
225
+ torus: (pts, normals) => fitTorus(pts, normals),
226
+ };
227
+ const refitAs = (type, pts, normals) => REFITTERS[type]?.(pts, normals) ?? null;
228
+
229
+ // Adjacent patches describing the SAME surface, merged before anything is numbered.
230
+ // Segmentation can split one true surface in two — a bore tessellated coarse on one half
231
+ // and fine on the other splits into two coaxial cylinder patches of identical radius
232
+ // (controller ruling R25). Left unmerged those become two `surfaces`, and because Task 6
233
+ // walks every concave cylinder independently and both fragments reach both end planes,
234
+ // the SAME hole is detected twice with the SAME geometry-derived `key` — a collision that
235
+ // breaks the stable-id invariant the whole report rests on.
236
+ //
237
+ // Merging here rather than deduplicating in Task 6 is deliberate: this stage is already
238
+ // "merge patches into surfaces", the duplicate is a segmentation artefact rather than a
239
+ // feature-rule concern, and fixing it at the source means no downstream rule has to know
240
+ // the artefact exists.
241
+ function mergeCoFamily(topo, rawPatches, tol) {
242
+ const merged = rawPatches.map((p) => ({ ...p, faces: [...p.faces] }));
243
+
244
+ // NOTE: adjacency is deliberately NOT required (controller ruling R26). A plane
245
+ // interrupted by a boss, or a bore crossed by a slot, comes out of segmentation as two
246
+ // or more DISCONNECTED patches of identical geometry — and growth never routes them
247
+ // through `unassigned`, so Task 4's mop-up never sees them either. They are one surface;
248
+ // the fact that a feature crosses it does not make it two. The `loops` array preserves
249
+ // the island structure, so anything downstream that genuinely needs the disjointness
250
+ // can still read it, while every feature rule gets the one surface it should be
251
+ // reasoning about.
252
+ //
253
+ // The risk this accepts: two genuinely independent same-geometry faces — the two feet of
254
+ // a bracket lying in one plane — also merge. That is the right trade for this oracle.
255
+ // A hole is a hole whichever foot it sits in, and the alternative silently splits every
256
+ // interrupted surface, which breaks hole detection outright.
257
+ //
258
+ // `sameSurface` agreeing on FIT PARAMETERS is necessary but never sufficient on its
259
+ // own — it is a finite list of bands over a finite list of fields, and any gap in it
260
+ // (the torus branch above was missing a position check entirely until this was found
261
+ // in review) turns two unrelated patches into one fabricated surface with full
262
+ // confidence. So every tentative merge below is refit over the ACTUAL COMBINED point
263
+ // cloud before it is accepted, and rejected if that combined fit is null or its
264
+ // `maxDev` exceeds the same `tol` region growing itself used to decide these patches
265
+ // were valid in the first place. This is deliberately a POST hoc check on the real
266
+ // geometry, not a smarter `sameSurface` — it makes `sameSurface`'s correctness a
267
+ // PERFORMANCE concern (a gap there costs a merge that should have happened but
268
+ // didn't) rather than a correctness one (a gap there fabricating a surface that
269
+ // should never have existed). Any future gap degrades to "failed to merge", which is
270
+ // the safe direction.
271
+ let changed = true;
272
+ while (changed) {
273
+ changed = false;
274
+ for (let i = 0; i < merged.length && !changed; i++) {
275
+ if (!merged[i]) continue;
276
+ for (let j = i + 1; j < merged.length && !changed; j++) {
277
+ if (!merged[j] || !sameSurface(merged[i].fit, merged[j].fit)) continue;
278
+ const faces = [...merged[i].faces, ...merged[j].faces];
279
+ const { pts, normals } = facePoints(topo, faces);
280
+ const fit = refitAs(merged[i].fit.type, pts, normals);
281
+ if (!fit || fit.maxDev > tol) continue; // sameSurface said yes, the geometry says no — don't merge
282
+ merged[i] = { ...merged[i], faces, fit, area: merged[i].area + merged[j].area };
283
+ merged[j] = null;
284
+ changed = true; // restart: a merge can make a third patch's fit agree too
285
+ }
286
+ }
287
+ }
288
+
289
+ return merged.filter(Boolean);
290
+ }
291
+
292
+ // Same acceptance band region growing itself used (segment.js's own `FIT_TOL_FRAC`,
293
+ // imported rather than restated), scaled to THIS mesh's own bbox diagonal rather than
294
+ // segment.js's — surfaceGraph is not handed segment.js's `topo`-derived tol directly,
295
+ // and recomputing it from the identical fraction is simpler than plumbing one more
296
+ // value through every caller for a quantity `topo` already has everything needed to
297
+ // reproduce exactly. intrinsicScale(), not a plain world-axis min/max, for the same
298
+ // reason segment.js switched: it must agree with segment.js's own tol on an
299
+ // arbitrarily-rotated input, and a world AABB diagonal does not (see intrinsicScale's
300
+ // comment in fit.js) — this is the twin of that fix, kept in step on purpose.
301
+ function defaultTol(topo) {
302
+ return intrinsicScale(topo.verts) * FIT_TOL_FRAC;
303
+ }
304
+
305
+ export function surfaceGraph(topo, rawPatches, opts = {}) {
306
+ const tol = opts.tol ?? defaultTol(topo);
307
+ const patches = mergeCoFamily(topo, rawPatches, tol);
308
+ const surfaces = patches.map((p, i) => {
309
+ const fit = orientPlaneOutward(topo, p);
310
+ return {
311
+ id: `s${i}`, type: fit.type, fit, faces: p.faces, area: p.area, loops: [],
312
+ curvature: curvatureOf(topo, p),
313
+ };
314
+ });
315
+ const owner = new Int32Array(topo.faceArea.length).fill(-1);
316
+ patches.forEach((p, i) => { for (const t of p.faces) owner[t] = i; });
317
+
318
+ // Group boundary edges by the unordered pair of surfaces they separate. Edges
319
+ // interior to one surface never reach here (owner[a] === owner[b]) — the boundary
320
+ // loops below are built from exactly this same set instead.
321
+ const between = new Map();
322
+ const boundaryEdges = new Map(); // surface index -> edges on its rim
323
+ for (const e of topo.edges) {
324
+ if (e.triB < 0) continue; // a true mesh boundary, not a surface-to-surface seam
325
+ const a = owner[e.triA], b = owner[e.triB];
326
+ if (a < 0 || b < 0 || a === b) continue;
327
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`;
328
+ if (!between.has(key)) between.set(key, { a: Math.min(a,b), b: Math.max(a,b), edges: [] });
329
+ between.get(key).edges.push(e);
330
+ for (const s of [a, b]) {
331
+ if (!boundaryEdges.has(s)) boundaryEdges.set(s, []);
332
+ boundaryEdges.get(s).push(e);
333
+ }
334
+ }
335
+
336
+ const arcs = [];
337
+ for (const { a, b, edges } of between.values()) {
338
+ if (edges.length < MIN_ARC_EDGES) continue;
339
+ // Length-weighted majority vote (see the header comment on why not first-edge).
340
+ let convexLen = 0, concaveLen = 0, total = 0;
341
+ const mids = [];
342
+ for (const e of edges) {
343
+ const p0 = vertOf(topo, e.v0), p1 = vertOf(topo, e.v1);
344
+ const len = dist(p0, p1);
345
+ total += len;
346
+ if (e.convexity === "convex") convexLen += len;
347
+ else if (e.convexity === "concave") concaveLen += len;
348
+ mids.push([(p0[0]+p1[0])/2, (p0[1]+p1[1])/2, (p0[2]+p1[2])/2]);
349
+ }
350
+ // Never `===` on floats (global constraint — Task 3 shipped exactly this shape of
351
+ // bug as `dihedral === 0` and it collapsed segmentation on any non-axis-aligned
352
+ // mesh). `convexLen`/`concaveLen` are sums of real edge lengths accumulated in
353
+ // whatever order `edges` happens to iterate in, so even a genuine tie (every edge
354
+ // on this arc flat, or a symmetric mix of convex/concave halves) is not
355
+ // guaranteed to land on the same last bit both ways — an epsilon relative to the
356
+ // arc's own total length is the same "relative, scale-free" policy this file
357
+ // already uses everywhere else (`closeRel`, `CIRCLE_TOL_FRAC`), not a one-off.
358
+ const convexity = Math.abs(convexLen - concaveLen) <= total * 1e-9 ? "flat"
359
+ : convexLen > concaveLen ? "convex" : "concave";
360
+ const { kind, radius, axis } = arcKind(mids);
361
+ arcs.push({
362
+ between: [surfaces[a].id, surfaces[b].id],
363
+ convexity, kind, radius: radius ?? null, axis: axis ?? null, length: total,
364
+ });
365
+ }
366
+
367
+ // Boundary loops: chain each surface's rim edges end to end. A surface with an
368
+ // island hole in it (an annulus cap) yields two loops, which is exactly the signal
369
+ // the pocket and hole rules read.
370
+ for (const [si, edges] of boundaryEdges) {
371
+ const adj = new Map();
372
+ for (const e of edges) {
373
+ if (!adj.has(e.v0)) adj.set(e.v0, []);
374
+ if (!adj.has(e.v1)) adj.set(e.v1, []);
375
+ adj.get(e.v0).push(e.v1); adj.get(e.v1).push(e.v0);
376
+ }
377
+ const seen = new Set();
378
+ for (const start of adj.keys()) {
379
+ if (seen.has(start)) continue;
380
+ const loop = [];
381
+ let cur = start, prev = -1;
382
+ while (cur !== undefined && !seen.has(cur)) {
383
+ seen.add(cur); loop.push(cur);
384
+ const next = (adj.get(cur) ?? []).find((v) => v !== prev && !seen.has(v));
385
+ prev = cur; cur = next;
386
+ }
387
+ if (loop.length >= 3) surfaces[si].loops.push(loop);
388
+ }
389
+ }
390
+
391
+ return { surfaces, arcs };
392
+ }
393
+
394
+ export function arcsOf(graph, surfaceId) {
395
+ return graph.arcs.filter((a) => a.between[0] === surfaceId || a.between[1] === surfaceId);
396
+ }
@@ -0,0 +1,121 @@
1
+ // Mesh → welded topology: the shared substrate every describe stage reads. Two
2
+ // jobs nothing downstream should have to repeat. (1) NORMALIZE: Manifold hands us
3
+ // a non-indexed soup and OCCT an indexed mesh, and no detector should branch on
4
+ // which. (2) SIGN THE DIHEDRALS: the signed angle across each shared edge is what
5
+ // separates a boss from a pocket and a fillet from a chamfer, and it is the one
6
+ // quantity the whole feature vocabulary rests on. Getting its sign convention
7
+ // wrong inverts every feature rule at once, so it is asserted directly in tests
8
+ // rather than only through the rules that consume it.
9
+ //
10
+ // Pure leaf: no kernel, no BVH, no DOM. See docs/superpowers/specs/
11
+ // 2026-08-21-semantic-mesh-oracle-design.md §2.1.
12
+ import { meshTriangles } from "../bvh.js";
13
+ import { intrinsicScale } from "./fit.js";
14
+
15
+ // Coplanarity band for calling an edge "flat" rather than convex/concave. A
16
+ // tessellated cylinder's wall edges are genuinely convex at a small angle and must
17
+ // NOT be swallowed by this, so the band is much tighter than any facet step a
18
+ // reasonable chord tolerance produces: 1e-4 rad is ~0.006°.
19
+ export const FLAT_EPS = 1e-4;
20
+
21
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
22
+ const cross = (a, b) => [a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2], a[0]*b[1] - a[1]*b[0]];
23
+ const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
24
+ const norm = (a) => Math.hypot(a[0], a[1], a[2]);
25
+
26
+ // Weld tolerance defaults to a fraction of the mesh's own characteristic length
27
+ // rather than an absolute number: a 2mm part and a 2m part both need welding, and an
28
+ // absolute epsilon is wrong for one of them. Callers with a known chord tolerance
29
+ // override. intrinsicScale() (fit.js), not a plain world-axis min/max, for the same
30
+ // reason segment.js's fit tolerance switched: a naive AABB diagonal is not the same
31
+ // number under rotation, so this weld epsilon would silently drift with orientation
32
+ // too — the effect is negligible at this fraction's scale (nanometres either way),
33
+ // but the convention should not have a second, differently-computed copy for the
34
+ // next reader to (wrongly) treat as a template.
35
+ //
36
+ // The `1e-7` fraction is retuned the same one-time, deliberate way `FIT_TOL_FRAC`
37
+ // (segment.js) is, and for the identical reason: `intrinsicScale` now returns a bare
38
+ // radius of gyration (no calibration folded in — fit.js's `intrinsicFrame`), smaller
39
+ // than the bbox-diagonal figure this fraction was original tuned against.
40
+ // `1e-7 * 1.0879082239773115 = 1.0879082e-7` carries that tuning forward onto the new
41
+ // scale on the same reference shape `FIT_TOL_FRAC`'s comment measures — practically
42
+ // inconsequential at this magnitude (nanometres either way), kept consistent with the
43
+ // other retuned constants so a future reader has one pattern to copy, not two.
44
+ function weldTolerance(triples) {
45
+ return intrinsicScale(triples.flat(2)) * 1.0879082e-7;
46
+ }
47
+
48
+ export function buildTopology(mesh, opts = {}) {
49
+ const triples = meshTriangles(mesh);
50
+ const tol = opts.weld ?? weldTolerance(triples);
51
+ // Quantized grid hash. Snapping to a grid of `tol` merges coordinates that agree
52
+ // to that scale; probing the 26 neighbouring cells too would be more correct at
53
+ // cell boundaries, but a CAD tessellation emits bit-identical shared vertices, so
54
+ // the exact-cell hit is the normal case and the neighbour probe is not worth its
55
+ // cost here. Real scans get the same treatment via a caller-supplied `weld`.
56
+ const key = (v) => `${Math.round(v[0] / tol)},${Math.round(v[1] / tol)},${Math.round(v[2] / tol)}`;
57
+ const index = new Map();
58
+ const verts = [];
59
+ const vid = (v) => {
60
+ const k = key(v);
61
+ let i = index.get(k);
62
+ if (i === undefined) { i = verts.length / 3; verts.push(v[0], v[1], v[2]); index.set(k, i); }
63
+ return i;
64
+ };
65
+
66
+ const tris = new Uint32Array(triples.length * 3);
67
+ const faceNormal = new Float64Array(triples.length * 3);
68
+ const faceArea = new Float64Array(triples.length);
69
+ for (let t = 0; t < triples.length; t++) {
70
+ const [a, b, c] = triples[t];
71
+ tris[3*t] = vid(a); tris[3*t+1] = vid(b); tris[3*t+2] = vid(c);
72
+ const n = cross(sub(b, a), sub(c, a));
73
+ const len = norm(n);
74
+ faceArea[t] = len / 2;
75
+ // A degenerate triangle has no normal. Store zeros rather than NaN: downstream
76
+ // code filters on zero area, and NaN would silently poison every dot product.
77
+ for (let k = 0; k < 3; k++) faceNormal[3*t+k] = len > 0 ? n[k] / len : 0;
78
+ }
79
+
80
+ // Edge table keyed by the UNORDERED vertex pair, but each half-edge remembers the
81
+ // ORDER it was traversed in. That order is the winding, and the winding is what
82
+ // gives the dihedral its sign.
83
+ const half = new Map();
84
+ const edges = [];
85
+ const faceEdges = Array.from({ length: triples.length }, () => []);
86
+ for (let t = 0; t < triples.length; t++) {
87
+ for (let k = 0; k < 3; k++) {
88
+ const v0 = tris[3*t + k], v1 = tris[3*t + (k + 1) % 3];
89
+ const ek = v0 < v1 ? `${v0}:${v1}` : `${v1}:${v0}`;
90
+ const prev = half.get(ek);
91
+ if (prev === undefined) {
92
+ half.set(ek, { v0, v1, triA: t, triB: -1, index: edges.length });
93
+ edges.push(half.get(ek));
94
+ } else {
95
+ prev.triB = t;
96
+ }
97
+ faceEdges[t].push(half.get(ek).index);
98
+ }
99
+ }
100
+
101
+ for (const e of edges) {
102
+ if (e.triB < 0) { e.dihedral = 0; e.convexity = "boundary"; continue; }
103
+ const nA = [faceNormal[3*e.triA], faceNormal[3*e.triA+1], faceNormal[3*e.triA+2]];
104
+ const nB = [faceNormal[3*e.triB], faceNormal[3*e.triB+1], faceNormal[3*e.triB+2]];
105
+ const p0 = [verts[3*e.v0], verts[3*e.v0+1], verts[3*e.v0+2]];
106
+ const p1 = [verts[3*e.v1], verts[3*e.v1+1], verts[3*e.v1+2]];
107
+ const dir = sub(p1, p0);
108
+ const len = norm(dir);
109
+ if (len === 0) { e.dihedral = 0; e.convexity = "flat"; continue; }
110
+ const u = [dir[0]/len, dir[1]/len, dir[2]/len];
111
+ // Signed angle between the two outward normals about the shared edge. With CCW
112
+ // winding and outward normals, a positive angle means the surface turns away
113
+ // from the material — convex. Negative means it folds into it — concave.
114
+ const s = dot(cross(nA, nB), u);
115
+ const c = dot(nA, nB);
116
+ e.dihedral = Math.atan2(s, c);
117
+ e.convexity = Math.abs(e.dihedral) < FLAT_EPS ? "flat" : e.dihedral > 0 ? "convex" : "concave";
118
+ }
119
+
120
+ return { verts: Float64Array.from(verts), tris, faceNormal, faceArea, edges, faceEdges };
121
+ }