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.
- package/bin/cli.js +125 -1
- package/docs/AUTHORING-PARTS.md +165 -1
- package/docs/ERROR-PATTERNS.md +30 -0
- package/package.json +8 -1
- package/src/framework/jobs.js +66 -9
- 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/oracle.js +27 -0
- package/src/testing.js +4 -12
- package/types/oracle.d.ts +27 -0
- package/types/testing.d.ts +178 -0
|
@@ -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
|
+
}
|