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,498 @@
|
|
|
1
|
+
// Mesh faces -> primitive patches. The classic reverse-engineering segmentation,
|
|
2
|
+
// implemented rather than invented (spec "Prior art"): seed in normal space, grow
|
|
3
|
+
// on the dual graph under a primitive predicate, refit as the region grows, repeat
|
|
4
|
+
// to stability.
|
|
5
|
+
//
|
|
6
|
+
// Why BOTH seeding and growing, when either alone half-works: Gauss-map bucketing
|
|
7
|
+
// alone cannot separate two parallel planes at different offsets, and it shreds a
|
|
8
|
+
// tessellated cylinder into one bucket per facet. Region growing alone has no idea
|
|
9
|
+
// where to start and picks up whatever its arbitrary seed happened to touch. Seeds
|
|
10
|
+
// give growth a well-conditioned starting hypothesis; growth gives seeds their
|
|
11
|
+
// spatial coherence. This is the same structure Efficient RANSAC and VSA arrive at
|
|
12
|
+
// from different directions.
|
|
13
|
+
//
|
|
14
|
+
// The patches this produces are CANDIDATES, not truth (spec §2.8) — accept.js
|
|
15
|
+
// decides what is real. So a slightly over-eager grow here is recoverable, and the
|
|
16
|
+
// tolerances lean permissive on purpose.
|
|
17
|
+
//
|
|
18
|
+
// Pure leaf. See spec §2.3.
|
|
19
|
+
import { fitPlane, fitCylinder, fitCone, fitSphere, fitTorus, deviationOf, intrinsicFrame } from "./fit.js";
|
|
20
|
+
import { ransacPatches } from "./ransac.js";
|
|
21
|
+
|
|
22
|
+
// Fit acceptance band, as a fraction of the mesh's own characteristic length
|
|
23
|
+
// (`intrinsicFrame`'s `diagonal`, fit.js). A CAD tessellation's chord error is
|
|
24
|
+
// bounded and small; this sits an order of magnitude above it so faceting never
|
|
25
|
+
// breaks a surface apart, and well below any real feature size so two genuinely
|
|
26
|
+
// different surfaces never merge.
|
|
27
|
+
// Exported: surface-graph.js's post-merge residual guard needs the identical
|
|
28
|
+
// fraction, not a second constant that could quietly drift from this one — a
|
|
29
|
+
// merged patch is only trustworthy if it clears the SAME acceptance band region
|
|
30
|
+
// growing itself used to validate its two halves.
|
|
31
|
+
//
|
|
32
|
+
// The value below (fix round 5) is a ONE-TIME DELIBERATE RETUNE, not an
|
|
33
|
+
// invariant, and is documented as such rather than left as `3e-4` unexplained.
|
|
34
|
+
// This constant was originally tuned against `diagonal` computed as a
|
|
35
|
+
// bounding-box extent (a max-extent measure); `diagonal` is now a radius of
|
|
36
|
+
// gyration (`2 * sqrt(trace/n)`, an RMS measure — fit.js's `intrinsicFrame`),
|
|
37
|
+
// which reads SMALLER on the same mesh, and fit.js deliberately does not
|
|
38
|
+
// compensate for that itself (that function's own comment explains why: the
|
|
39
|
+
// ratio between the two measures is shape-dependent, so no single constant
|
|
40
|
+
// folded into the primitive could keep every part's effective tolerance
|
|
41
|
+
// unchanged; `filletedBox` alone needed a 9% different correction than the
|
|
42
|
+
// reference shape this value IS calibrated against). Measured on that
|
|
43
|
+
// reference shape (a 30x20x14mm hollow box, already correct — asymmetric, no
|
|
44
|
+
// PCA degeneracy — under the OLD bbox-diagonal formula): old diagonal
|
|
45
|
+
// 38.678159mm, new (bare radius-of-gyration) diagonal 35.552778mm, ratio
|
|
46
|
+
// 1.0879082239773115. `3e-4 * 1.0879082239773115 = 3.263725e-4` is the value
|
|
47
|
+
// that reproduces the SAME effective millimetre tolerance on THAT shape as the
|
|
48
|
+
// original `3e-4` did against the old, larger `diagonal` — carrying the old
|
|
49
|
+
// tuning forward onto the new scale, not preserving it everywhere in general.
|
|
50
|
+
// Other shapes will see a real, expected, previously-reported shift (see
|
|
51
|
+
// task-15-report.md, fix rounds 3-4) — this is a documented starting point for
|
|
52
|
+
// the constant, not a claim that segmentation is now unchanged.
|
|
53
|
+
export const FIT_TOL_FRAC = 3.263725e-4;
|
|
54
|
+
// Reserved dial, not currently load-bearing: every seed is a single triangle,
|
|
55
|
+
// three points always define SOME plane exactly (rms/maxDev = 0), so `bestFit`
|
|
56
|
+
// (with MIN_PTS.plane = 3) can only fail on a seed whose points are so close to
|
|
57
|
+
// collinear that fit.js's own rank guard rejects it as degenerate — everything
|
|
58
|
+
// else clears `faces.length < MIN_PATCH_FACES` at its default of 1 trivially.
|
|
59
|
+
// Left in place (rather than deleted) as the one knob a future revision would
|
|
60
|
+
// raise to also discard small-but-valid patches as noise; it does nothing at 1.
|
|
61
|
+
const MIN_PATCH_FACES = 1;
|
|
62
|
+
const REFIT_ROUNDS = 3;
|
|
63
|
+
|
|
64
|
+
// Fold-angle ceiling for a growth candidate's shared edge, in radians (30°).
|
|
65
|
+
// This is what keeps growth from ever crossing a genuine surface boundary, and
|
|
66
|
+
// it has to be checked BEFORE the adaptive tolerance below is allowed to widen
|
|
67
|
+
// anything: unlike a chord-error tolerance, a dihedral angle is SCALE-INVARIANT
|
|
68
|
+
// under retessellation — a cylinder wall's own internal facet-to-facet fold is
|
|
69
|
+
// 2π/segs and shrinks toward zero as the mesh gets finer, while a genuine
|
|
70
|
+
// cylinder-to-cap edge stays at 90° no matter how fine the mesh gets. Gating on
|
|
71
|
+
// this first is what makes it safe to widen tolerance adaptively next: without
|
|
72
|
+
// this gate, the adaptive term below would inflate the tolerance most at
|
|
73
|
+
// exactly the sharp edges it must never cross (a large dihedral would imply a
|
|
74
|
+
// large permitted deviation, backwards from what's needed). `topology.js`
|
|
75
|
+
// already computes signed dihedrals per edge, so this check costs nothing extra.
|
|
76
|
+
const SMOOTH_DIHEDRAL_MAX = Math.PI / 6;
|
|
77
|
+
|
|
78
|
+
// Scales the adaptive tolerance below. Calibrated, not guessed: measured the
|
|
79
|
+
// ACTUAL worst-vertex deviation of a wall facet's true first neighbour against
|
|
80
|
+
// `w * dihedral` (see `leverArm`) across cylinderMesh(4, 10, N) for N = 16..240
|
|
81
|
+
// and found the two track each other almost exactly (ratio 0.99-1.00 at every
|
|
82
|
+
// N tried) — `w * dihedral` IS the chord sagitta this predicate needs to admit,
|
|
83
|
+
// not merely proportional to it. 1.5 keeps a real margin above that measured
|
|
84
|
+
// 1:1 correspondence without weakening the dihedral gate above, which is what
|
|
85
|
+
// actually protects genuinely different surfaces from merging.
|
|
86
|
+
const FACET_K = 1.5;
|
|
87
|
+
|
|
88
|
+
// `FACET_K * leverArm * |dihedral|` has NO ceiling: leverArm grows with facet
|
|
89
|
+
// size without limit, so a large, coarsely-tessellated flat face (exactly the
|
|
90
|
+
// shape a CAD exporter emits for a big plane — ONE quad) gets an arbitrarily
|
|
91
|
+
// loose effective tolerance at a FIXED fold angle. Measured directly: two flat
|
|
92
|
+
// quads hinged at 10°-29° merge into a single mis-typed "cylinder" patch at
|
|
93
|
+
// every facet width from 5 to 100mm, a regression this dihedral+adaptive
|
|
94
|
+
// design introduced (verified absent on the pre-adaptive code). The dihedral
|
|
95
|
+
// gate alone can't catch this — 10-29° is exactly the range it's SUPPOSED to
|
|
96
|
+
// let through for real curvature.
|
|
97
|
+
//
|
|
98
|
+
// The physical distinction: on a genuinely tessellated curve, `leverArm /
|
|
99
|
+
// dihedral` (the IMPLIED RADIUS of curvature — see `impliedRadius`) is close
|
|
100
|
+
// to CONSTANT and density-independent: every internal edge implies close to
|
|
101
|
+
// the same radius, because that's what "the same curved surface, sampled at
|
|
102
|
+
// different densities" means. On a flat-to-flat fold, that same ratio is
|
|
103
|
+
// UNBOUNDED (a fixed design angle over an arbitrarily large facet), and
|
|
104
|
+
// crucially there is only ONE such edge — a real polygon corner borders
|
|
105
|
+
// exactly one other face along it, alone, with no repetition. A genuinely
|
|
106
|
+
// curved patch, by contrast, always has at least one MORE internal edge with
|
|
107
|
+
// a closely matching implied radius and sign nearby (the facet on the
|
|
108
|
+
// curve's other side), because the curvature that produced this edge's fold
|
|
109
|
+
// didn't stop existing one facet later.
|
|
110
|
+
//
|
|
111
|
+
// So the adaptive allowance is granted only when the candidate edge's fold
|
|
112
|
+
// has a WITNESS: another edge of the same sign and comparable IMPLIED RADIUS,
|
|
113
|
+
// either already confirmed inside the growing patch, or discovered in the
|
|
114
|
+
// very same growth pass (see `established`/`sameFamily` in `segment`). A lone
|
|
115
|
+
// fold, however permissive the raw sagitta arithmetic would allow, never
|
|
116
|
+
// gets one.
|
|
117
|
+
//
|
|
118
|
+
// Comparing on implied radius rather than on raw dihedral matters, not just
|
|
119
|
+
// as a style choice: measured directly on a flat plane tangent (G1-smooth) to
|
|
120
|
+
// a cylinder wall, sharing one edge with it — the FIRST-order tangent
|
|
121
|
+
// transition edge has EXACTLY HALF the dihedral of the wall's own internal
|
|
122
|
+
// wall-to-wall edges (1.875° vs 3.75° at N=96; 0.9626° vs 1.925° at N=187 —
|
|
123
|
+
// consistently a factor of 2, at every density tried, so this is a general
|
|
124
|
+
// property of a tangent boundary, not a fixture artifact), while its
|
|
125
|
+
// `leverArm` is IDENTICAL to the internal edge's. Raw-dihedral matching
|
|
126
|
+
// would treat "half the internal step" as plausibly the same family (well
|
|
127
|
+
// within a loose ratio bound) and merge the flat plane into the cylinder
|
|
128
|
+
// through a small leaked chunk — reproduced directly before this fix.
|
|
129
|
+
// Implied radius makes the gap explicit and large: the tangent edge's
|
|
130
|
+
// leverArm/dihedral comes out at ~2x the wall's own true radius (~8mm vs the
|
|
131
|
+
// fixture's actual r=4mm) at every density tried, a robust, structural
|
|
132
|
+
// distinction (a G1-tangent boundary is HALF a facet-step removed from the
|
|
133
|
+
// curve's own interior sampling, definitionally) rather than a coincidence
|
|
134
|
+
// of one test case.
|
|
135
|
+
//
|
|
136
|
+
// `MATCH_RATIO` sits between that measured 1x (genuine internal consistency)
|
|
137
|
+
// and 2x (the tangent-boundary artifact) gap: loose enough that a uniformly
|
|
138
|
+
// tessellated surface's implied radius (identical edge to edge on every
|
|
139
|
+
// fixture measured) clears it with margin, tight enough that the 2x tangent
|
|
140
|
+
// artifact does not.
|
|
141
|
+
const MATCH_RATIO = 1.5;
|
|
142
|
+
|
|
143
|
+
// The radius of curvature this edge's fold IMPLIES, if it were one facet's
|
|
144
|
+
// worth of sampling a circular arc: `leverArm` (the chord's perpendicular
|
|
145
|
+
// throw) over the fold angle, since for small angles the two are related by
|
|
146
|
+
// `leverArm ≈ R * dihedral`. Meaningless (and never called) for a flat
|
|
147
|
+
// (dihedral = 0) edge.
|
|
148
|
+
const impliedRadius = (leverArm, dihedral) => leverArm / Math.abs(dihedral);
|
|
149
|
+
|
|
150
|
+
// Same sign (same convexity direction) and within a MATCH_RATIO factor of
|
|
151
|
+
// each other in IMPLIED RADIUS — the corroboration test `established`/
|
|
152
|
+
// `pending` signatures call to decide whether a candidate edge's fold is "the
|
|
153
|
+
// same curvature sampled again" rather than an unrelated coincidence (a
|
|
154
|
+
// design corner, or a tangent boundary onto a DIFFERENT surface — see the
|
|
155
|
+
// comment on MATCH_RATIO for why implied radius, not raw dihedral, is what
|
|
156
|
+
// gets compared).
|
|
157
|
+
function sameFamily(sigA, sigB) {
|
|
158
|
+
if (sigA.sign !== sigB.sign) return false;
|
|
159
|
+
const ratio = sigA.R / sigB.R;
|
|
160
|
+
return ratio >= 1 / MATCH_RATIO && ratio <= MATCH_RATIO;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The corroboration signature for one nonzero-dihedral edge: its convexity
|
|
164
|
+
// sign and its implied radius. Built once, wherever an edge is about to enter
|
|
165
|
+
// `established` or `pending`, rather than re-derived ad hoc at each compare.
|
|
166
|
+
const familySignature = (edge, arm) => ({ sign: Math.sign(edge.dihedral), R: impliedRadius(arm, edge.dihedral) });
|
|
167
|
+
|
|
168
|
+
// All five candidates over the same trial set, in ascending order of degrees of
|
|
169
|
+
// freedom. Two different policies read this list for two different purposes
|
|
170
|
+
// below (`bestFit` vs `growthFit`) — see the comment on `growthFit` for why one
|
|
171
|
+
// list needs two different consumers rather than one.
|
|
172
|
+
function candidateFits(pts, normals) {
|
|
173
|
+
return [fitPlane(pts), fitCylinder(pts, normals), fitCone(pts, normals), fitSphere(pts), fitTorus(pts, normals)];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// FINAL classification, once a patch has stopped growing: the FIRST candidate
|
|
177
|
+
// (ascending DOF) that fits within tolerance, never the best-scoring one. A plane
|
|
178
|
+
// is a degenerate cylinder of infinite radius and a cylinder is a degenerate cone
|
|
179
|
+
// of zero angle, so "best RMS" would routinely dress a flat face as a huge-radius
|
|
180
|
+
// cylinder and produce a technically-accurate, semantically-useless report.
|
|
181
|
+
function bestFit(pts, normals, tol) {
|
|
182
|
+
for (const f of candidateFits(pts, normals)) if (f && f.maxDev <= tol) return f;
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// GROWTH driver: of the candidates within tolerance, the one with the SMALLEST
|
|
187
|
+
// residual — deliberately NOT `bestFit`'s ascending-DOF policy, and this is not
|
|
188
|
+
// a stylistic difference, it is the difference between a segmenter that finds
|
|
189
|
+
// cylinders and one that cannot. Every smooth surface is locally flat: a patch
|
|
190
|
+
// of only a few facets sits well inside a tangent plane's chord tolerance no
|
|
191
|
+
// matter what it will turn out to be, so `bestFit` calls it a plane the moment
|
|
192
|
+
// it is asked, before there is enough of the patch gathered to tell a genuine
|
|
193
|
+
// flat from the first few facets of a cylinder. If THAT verdict is what drives
|
|
194
|
+
// growth (candidates tested against it via `faceDeviation`), the region cannot
|
|
195
|
+
// escape it: every newly admitted face must itself pass the same plane test, so
|
|
196
|
+
// the accumulated set can never carry enough curvature to invalidate its own
|
|
197
|
+
// classification — growth halts exactly at the tangent plane's tolerance
|
|
198
|
+
// boundary, one facet short of ever trying the cylinder that was available the
|
|
199
|
+
// whole time (verified empirically during this task: a tessellated cylinder
|
|
200
|
+
// wall seeded and refit under `bestFit` converges to a stable ~3-facet "plane"
|
|
201
|
+
// and then permanently stops growing, regardless of tessellation fineness,
|
|
202
|
+
// because the growth test and the classification test were the same policy).
|
|
203
|
+
// A true surface's residual falls toward zero as its patch grows (more of a
|
|
204
|
+
// real cylinder is still exactly a cylinder), while an increasingly strained
|
|
205
|
+
// plane's residual grows with it — the two curves cross well before the
|
|
206
|
+
// plane's tolerance is exhausted, so picking the smaller one lets growth adopt
|
|
207
|
+
// the cylinder as soon as there is enough data to prefer it, and keep going.
|
|
208
|
+
// `bestFit` still gets the final say (see below): once growth has converged,
|
|
209
|
+
// re-running its ascending-DOF policy on the finished point set is what stops
|
|
210
|
+
// a genuinely flat patch (whose non-plane candidates fail outright on
|
|
211
|
+
// degenerate normals, or the same trap risk elsewhere) from being reported as
|
|
212
|
+
// whatever curved thing this driver preferred mid-growth.
|
|
213
|
+
function growthFit(pts, normals, tol) {
|
|
214
|
+
let best = null;
|
|
215
|
+
for (const f of candidateFits(pts, normals)) {
|
|
216
|
+
if (f && f.maxDev <= tol && (!best || f.maxDev < best.maxDev)) best = f;
|
|
217
|
+
}
|
|
218
|
+
return best;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const faceNormalOf = (topo, t) => [topo.faceNormal[3*t], topo.faceNormal[3*t+1], topo.faceNormal[3*t+2]];
|
|
222
|
+
|
|
223
|
+
// All three vertices of a face, so a fit sees the real surface rather than a cloud
|
|
224
|
+
// of centroids — a cylinder fitted from centroids alone comes out systematically
|
|
225
|
+
// under-radius by the sagitta of one facet.
|
|
226
|
+
//
|
|
227
|
+
// Exported: this is the same helper `ransac.js` and `surface-graph.js` both need
|
|
228
|
+
// (a point/normal cloud for a face set is not specific to region growing), and it
|
|
229
|
+
// used to exist as three near-identical private copies. `ransac.js` imports it
|
|
230
|
+
// from here rather than keeping its own — a function declaration, not a `const`,
|
|
231
|
+
// specifically so the resulting segment.js <-> ransac.js circular import resolves
|
|
232
|
+
// cleanly (function declarations are hoisted and live before either module's
|
|
233
|
+
// top-level code runs, unlike a `const` binding, which would be in its temporal
|
|
234
|
+
// dead zone at the point the cycle closes).
|
|
235
|
+
export function facePoints(topo, faces) {
|
|
236
|
+
const pts = [], normals = [];
|
|
237
|
+
for (const t of faces) {
|
|
238
|
+
const n = faceNormalOf(topo, t);
|
|
239
|
+
for (let k = 0; k < 3; k++) {
|
|
240
|
+
const v = topo.tris[3*t + k] * 3;
|
|
241
|
+
pts.push([topo.verts[v], topo.verts[v+1], topo.verts[v+2]]);
|
|
242
|
+
normals.push(n);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { pts, normals };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Worst distance from a face's three vertices to a fitted primitive. The growth
|
|
249
|
+
// predicate (ruling R19): cheap, allocation-light, and it reuses the ONE definition of
|
|
250
|
+
// point-to-primitive distance that fit.js owns, so growth and RANSAC can never disagree
|
|
251
|
+
// about what "within tolerance" means.
|
|
252
|
+
function faceDeviation(topo, t, fit) {
|
|
253
|
+
let worst = 0;
|
|
254
|
+
for (let k = 0; k < 3; k++) {
|
|
255
|
+
const v = topo.tris[3*t + k] * 3;
|
|
256
|
+
const d = Math.abs(deviationOf(fit, [topo.verts[v], topo.verts[v+1], topo.verts[v+2]]));
|
|
257
|
+
if (d > worst) worst = d;
|
|
258
|
+
}
|
|
259
|
+
return worst;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const sub = (a, b) => [a[0]-b[0], a[1]-b[1], a[2]-b[2]];
|
|
263
|
+
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]];
|
|
264
|
+
|
|
265
|
+
// Neighbour faces across non-boundary edges, paired with the edge itself (its
|
|
266
|
+
// signed dihedral and endpoints) rather than bare face ids — the growth
|
|
267
|
+
// predicate below needs both to gate on fold sharpness and to size its
|
|
268
|
+
// tolerance to the actual local facet geometry, not a global constant.
|
|
269
|
+
function neighbourEdges(topo, t) {
|
|
270
|
+
const out = [];
|
|
271
|
+
for (const ei of topo.faceEdges[t]) {
|
|
272
|
+
const e = topo.edges[ei];
|
|
273
|
+
if (e.triB < 0) continue;
|
|
274
|
+
out.push({ face: e.triA === t ? e.triB : e.triA, edge: e });
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// The local facet "width" a chord-error tolerance needs to scale with: NOT the
|
|
280
|
+
// shared edge's own length (on this mesh family that edge commonly runs ALONG
|
|
281
|
+
// the surface's straight axis — e.g. a cylinder wall's wall-to-wall edge is
|
|
282
|
+
// vertical, its full length the part's height, entirely unrelated to how far
|
|
283
|
+
// apart the facets are AROUND the curve) but the LEVER ARM the fold actually
|
|
284
|
+
// pivots on: the perpendicular distance from the shared edge's line out to the
|
|
285
|
+
// candidate face's farthest vertex. A dihedral fold of angle θ about that line
|
|
286
|
+
// displaces a point at lever arm L by approximately L·θ for small θ — exactly
|
|
287
|
+
// the chord sagitta this predicate needs to admit, confirmed by measuring this
|
|
288
|
+
// quantity against the real worst-vertex deviation across N = 16..240 on
|
|
289
|
+
// cylinderMesh (see FACET_K) rather than assumed.
|
|
290
|
+
function leverArm(topo, edge, nbFace) {
|
|
291
|
+
const v0 = [topo.verts[3*edge.v0], topo.verts[3*edge.v0+1], topo.verts[3*edge.v0+2]];
|
|
292
|
+
const v1 = [topo.verts[3*edge.v1], topo.verts[3*edge.v1+1], topo.verts[3*edge.v1+2]];
|
|
293
|
+
const dir = sub(v1, v0);
|
|
294
|
+
const len = Math.hypot(dir[0], dir[1], dir[2]);
|
|
295
|
+
if (len === 0) return 0;
|
|
296
|
+
const u = [dir[0]/len, dir[1]/len, dir[2]/len];
|
|
297
|
+
let worst = 0;
|
|
298
|
+
for (let k = 0; k < 3; k++) {
|
|
299
|
+
const v = topo.tris[3*nbFace + k] * 3;
|
|
300
|
+
const perp = cross(sub([topo.verts[v], topo.verts[v+1], topo.verts[v+2]], v0), u);
|
|
301
|
+
const dist = Math.hypot(perp[0], perp[1], perp[2]);
|
|
302
|
+
if (dist > worst) worst = dist;
|
|
303
|
+
}
|
|
304
|
+
return worst;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Classifies one growth candidate against the standing fit. Returns
|
|
308
|
+
// `{ verdict, leverArm }`. `tol` alone is NOT a safe unconditional pass for a
|
|
309
|
+
// NONZERO-dihedral edge, even though it always was for the pre-adaptive
|
|
310
|
+
// design: `tol` is a fraction of the WHOLE MESH's bbox diagonal, so one large
|
|
311
|
+
// feature anywhere in the same part (a big flat face, say) inflates it for
|
|
312
|
+
// every OTHER feature too — measured directly, a coarse flat plane merely
|
|
313
|
+
// 50mm across was enough to inflate `tol` past the true, small deviation of a
|
|
314
|
+
// genuine flat-to-cylinder transition edge, letting it through with no
|
|
315
|
+
// dihedral or corroboration check ever firing. So only a genuinely FLAT
|
|
316
|
+
// continuation (`edge.convexity === "flat"` — the two triangles of one
|
|
317
|
+
// tessellated quad, or any edge a real curve's own sampling makes coplanar)
|
|
318
|
+
// is unconditionally safe ("accept", no corroboration: there is no fold to
|
|
319
|
+
// be wrong about).
|
|
320
|
+
//
|
|
321
|
+
// This MUST be `topology.js`'s own `convexity` band (`|dihedral| <
|
|
322
|
+
// FLAT_EPS`), never an exact `dihedral === 0` check: a genuinely coplanar
|
|
323
|
+
// pair of triangles only comes out bit-exact zero when the mesh happens to
|
|
324
|
+
// be axis-aligned, which is a property of the FIXTURE, not of the geometry.
|
|
325
|
+
// Rotate the very same mesh by an arbitrary angle and the identical
|
|
326
|
+
// coplanar diagonal computes to something like `-9.4e-17` — a real zero
|
|
327
|
+
// with the wrong bit pattern from accumulated floating-point rounding in
|
|
328
|
+
// the cross/dot/atan2 chain `topology.js` derives it through. An exact
|
|
329
|
+
// equality test sends that edge down the "pending" path instead, where its
|
|
330
|
+
// own numerically-noisy implied radius has nothing to corroborate against
|
|
331
|
+
// (it is the ONLY internal edge a single seed triangle has), and growth
|
|
332
|
+
// never gets past one triangle — verified directly: every mesh in this
|
|
333
|
+
// module's own test fixtures is axis-aligned, which is exactly why this
|
|
334
|
+
// shipped working and rotating ANY of them (a cylinder, a box, a washer,
|
|
335
|
+
// at every density tried) collapsed growth entirely.
|
|
336
|
+
//
|
|
337
|
+
// Every other edge within the smoothness ceiling — whether it clears the
|
|
338
|
+
// plain `tol` or only the facet-scaled adaptive allowance — is "pending":
|
|
339
|
+
// admissible only with a witness (see `sameFamily`/`familySignature` and
|
|
340
|
+
// the growth loop in `segment`), because a nonzero fold is exactly the
|
|
341
|
+
// case a witness-less allowance can quietly get wrong. `leverArm` is
|
|
342
|
+
// reported whenever the edge clears the dihedral gate so the caller can
|
|
343
|
+
// build a corroboration signature without recomputing it.
|
|
344
|
+
function classifyCandidate(topo, edge, nb, fit, tol) {
|
|
345
|
+
if (Math.abs(edge.dihedral) > SMOOTH_DIHEDRAL_MAX) return { verdict: "reject" };
|
|
346
|
+
const dev = faceDeviation(topo, nb, fit);
|
|
347
|
+
if (edge.convexity === "flat") return { verdict: dev <= tol ? "accept" : "reject" };
|
|
348
|
+
const arm = leverArm(topo, edge, nb);
|
|
349
|
+
const adaptive = FACET_K * arm * Math.abs(edge.dihedral);
|
|
350
|
+
return { verdict: dev <= Math.max(tol, adaptive) ? "pending" : "reject", leverArm: arm };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Seed order: bucket faces by quantized normal on the Gauss sphere, then visit the
|
|
354
|
+
// buckets largest-area-first. Big flat regions get claimed while the fit is
|
|
355
|
+
// best-conditioned, and the fiddly transition strips (fillets, chamfers) are left
|
|
356
|
+
// for last instead of being grown into by accident.
|
|
357
|
+
// `axes`: the part's OWN principal directions (fit.js's intrinsicFrame), not world
|
|
358
|
+
// XYZ — quantizing a world-frame normal puts the Gauss-sphere bucket boundaries at a
|
|
359
|
+
// fixed place in space, and an arbitrary rotation of the part slides every facet's
|
|
360
|
+
// normal across them at once, reshuffling which facets seed together on any patch
|
|
361
|
+
// whose normal varies quickly enough to have neighbors near a boundary (exactly the
|
|
362
|
+
// compound-fillet corner blends this pass is warned about below). Bucketing in the
|
|
363
|
+
// mesh's own frame moves the boundaries WITH the geometry instead.
|
|
364
|
+
function seedOrder(topo, axes) {
|
|
365
|
+
const buckets = new Map();
|
|
366
|
+
for (let t = 0; t < topo.faceArea.length; t++) {
|
|
367
|
+
if (topo.faceArea[t] <= 0) continue;
|
|
368
|
+
const w = faceNormalOf(topo, t);
|
|
369
|
+
const n = [
|
|
370
|
+
w[0]*axes[0][0] + w[1]*axes[0][1] + w[2]*axes[0][2],
|
|
371
|
+
w[0]*axes[1][0] + w[1]*axes[1][1] + w[2]*axes[1][2],
|
|
372
|
+
w[0]*axes[2][0] + w[1]*axes[2][1] + w[2]*axes[2][2],
|
|
373
|
+
];
|
|
374
|
+
const key = `${Math.round(n[0]*24)},${Math.round(n[1]*24)},${Math.round(n[2]*24)}`;
|
|
375
|
+
if (!buckets.has(key)) buckets.set(key, { area: 0, faces: [] });
|
|
376
|
+
const b = buckets.get(key);
|
|
377
|
+
b.area += topo.faceArea[t];
|
|
378
|
+
b.faces.push(t);
|
|
379
|
+
}
|
|
380
|
+
return [...buckets.values()].sort((a, b) => b.area - a.area).flatMap((b) => b.faces);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function segment(topo, opts = {}) {
|
|
384
|
+
// intrinsicFrame(), not a plain world-axis min/max: a tilted mesh's AABB diagonal is
|
|
385
|
+
// NOT the same number as its axis-aligned twin's (see intrinsicFrame's own comment —
|
|
386
|
+
// a 29-degree tilt inflated one reference part's diagonal ~35%), and this tolerance
|
|
387
|
+
// must come out identical under any rigid rotation of the same input. The same frame
|
|
388
|
+
// also drives seedOrder's normal bucketing below, for the same reason.
|
|
389
|
+
const frame = intrinsicFrame(topo.verts);
|
|
390
|
+
const tol = opts.tol ?? frame.diagonal * FIT_TOL_FRAC;
|
|
391
|
+
|
|
392
|
+
const owner = new Int32Array(topo.faceArea.length).fill(-1);
|
|
393
|
+
const patches = [];
|
|
394
|
+
|
|
395
|
+
for (const seed of seedOrder(topo, frame.axes)) {
|
|
396
|
+
if (owner[seed] >= 0) continue;
|
|
397
|
+
let faces = [seed];
|
|
398
|
+
owner[seed] = patches.length;
|
|
399
|
+
let fit = growthFit(...Object.values(facePoints(topo, faces)), tol);
|
|
400
|
+
if (!fit) { owner[seed] = -1; continue; }
|
|
401
|
+
|
|
402
|
+
// Corroboration signatures (sign + implied radius, see `familySignature`)
|
|
403
|
+
// for the nonzero-dihedral edges this patch has already grown across,
|
|
404
|
+
// carried across REFIT_ROUNDS (not reset per round) so a later, coarser
|
|
405
|
+
// round can corroborate against curvature evidence an earlier round already
|
|
406
|
+
// established — see `sameFamily` / `classifyCandidate` for why a "pending"
|
|
407
|
+
// (adaptive-only) candidate needs a witness here before it can be admitted.
|
|
408
|
+
const established = [];
|
|
409
|
+
|
|
410
|
+
// Grow, refit, grow again. Refitting matters: a patch seeded on one facet of a
|
|
411
|
+
// cylinder starts out fitted as a PLANE, and only once it has grown across a few
|
|
412
|
+
// facets does the cylinder fit become the better description. Without the refit
|
|
413
|
+
// rounds the whole wall would come out as a fan of tiny planes.
|
|
414
|
+
for (let round = 0; round < REFIT_ROUNDS; round++) {
|
|
415
|
+
// Candidates are tested against the CURRENT fit's parameters, not by re-fitting
|
|
416
|
+
// the whole trial set (controller ruling R19). Re-fitting per candidate would call
|
|
417
|
+
// growthFit/bestFit — and therefore fitTorus, the most expensive fit at ~3-17ms —
|
|
418
|
+
// on every REJECTED neighbour, which is most of them: a trial set spanning two
|
|
419
|
+
// surfaces fits nothing, so it falls through every cheaper fit first. That is
|
|
420
|
+
// thousands of full fits per part. A deviation check against the standing fit is
|
|
421
|
+
// the standard region-growing formulation, is orders of magnitude cheaper, and is
|
|
422
|
+
// correctness-neutral because the refit below re-converges the patch each round.
|
|
423
|
+
let grew = false;
|
|
424
|
+
// Fixed-point loop: a pass may admit some faces outright (flat `tol`) and
|
|
425
|
+
// shelve others as "pending" (adaptive-only, awaiting a witness). Promoting
|
|
426
|
+
// a pending candidate can itself supply the witness a DIFFERENT pending
|
|
427
|
+
// candidate was waiting on (or open up brand-new neighbours), so the whole
|
|
428
|
+
// scan repeats until a full pass changes nothing.
|
|
429
|
+
let changed = true;
|
|
430
|
+
while (changed) {
|
|
431
|
+
changed = false;
|
|
432
|
+
const queue = [...faces];
|
|
433
|
+
const pending = [];
|
|
434
|
+
const pendingOwned = new Set();
|
|
435
|
+
while (queue.length) {
|
|
436
|
+
for (const { face: nb, edge } of neighbourEdges(topo, queue.pop())) {
|
|
437
|
+
if (owner[nb] >= 0 || topo.faceArea[nb] <= 0 || pendingOwned.has(nb)) continue;
|
|
438
|
+
const { verdict, leverArm: arm } = classifyCandidate(topo, edge, nb, fit, tol);
|
|
439
|
+
if (verdict === "reject") continue;
|
|
440
|
+
if (verdict === "accept") {
|
|
441
|
+
faces.push(nb); owner[nb] = patches.length; queue.push(nb);
|
|
442
|
+
grew = true; changed = true;
|
|
443
|
+
if (edge.convexity !== "flat") established.push(familySignature(edge, arm));
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
pending.push({ nb, edge, leverArm: arm }); pendingOwned.add(nb); // verdict === "pending"
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const p of pending) {
|
|
450
|
+
if (owner[p.nb] >= 0) continue; // claimed by another pending promotion this pass
|
|
451
|
+
const sig = familySignature(p.edge, p.leverArm);
|
|
452
|
+
const witnessed = established.some((s) => sameFamily(s, sig)) ||
|
|
453
|
+
pending.some((q) => q !== p && sameFamily(familySignature(q.edge, q.leverArm), sig));
|
|
454
|
+
if (!witnessed) continue;
|
|
455
|
+
faces.push(p.nb); owner[p.nb] = patches.length;
|
|
456
|
+
established.push(sig);
|
|
457
|
+
grew = true; changed = true;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (!grew) break;
|
|
461
|
+
const { pts, normals } = facePoints(topo, faces);
|
|
462
|
+
// growthFit, not bestFit, drives the NEXT round's growth test — see the
|
|
463
|
+
// comment on growthFit for why using the ascending-DOF classification here
|
|
464
|
+
// would permanently trap a curved patch under its own youngest plane fit.
|
|
465
|
+
fit = growthFit(pts, normals, tol) ?? fit;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (faces.length < MIN_PATCH_FACES) { for (const t of faces) owner[t] = -1; continue; }
|
|
469
|
+
// The growth loop's `fit` was chosen to keep growth moving, not to name the
|
|
470
|
+
// surface; re-run the ascending-DOF classification on the now-converged point
|
|
471
|
+
// set for the type actually reported, so a genuinely flat patch that
|
|
472
|
+
// growthFit happened to grow under a curved fit (its normals are degenerate
|
|
473
|
+
// for every non-plane candidate, so this risk is chiefly theoretical, but the
|
|
474
|
+
// final call belongs to `bestFit`'s stricter policy regardless) is still
|
|
475
|
+
// reported as what it is.
|
|
476
|
+
const { pts, normals } = facePoints(topo, faces);
|
|
477
|
+
const finalFit = bestFit(pts, normals, tol) ?? fit;
|
|
478
|
+
patches.push({
|
|
479
|
+
id: `q${patches.length}`, faces, fit: finalFit,
|
|
480
|
+
area: faces.reduce((a, t) => a + topo.faceArea[t], 0),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const unassigned = [];
|
|
485
|
+
for (let t = 0; t < owner.length; t++) if (owner[t] < 0 && topo.faceArea[t] > 0) unassigned.push(t);
|
|
486
|
+
|
|
487
|
+
// Region growing is connectivity-bound; RANSAC is not. Anything growth could not
|
|
488
|
+
// claim gets one consensus pass before it is declared residual, so a surface split
|
|
489
|
+
// into islands by a crossing feature is recovered rather than reported as a hole in
|
|
490
|
+
// the description. `opts.ransac === false` skips it — used by ransac.js's own tests
|
|
491
|
+
// and by the budget path in accept.js.
|
|
492
|
+
if (opts.ransac !== false && unassigned.length) {
|
|
493
|
+
const mop = ransacPatches(topo, unassigned, tol);
|
|
494
|
+
patches.push(...mop.patches);
|
|
495
|
+
return { patches, unassigned: mop.unassigned };
|
|
496
|
+
}
|
|
497
|
+
return { patches, unassigned };
|
|
498
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Measurement -> intent. A tessellated CAD export puts a 12mm wall at 11.9976 and a
|
|
2
|
+
// clearance hole at 5.2996, and handing those numbers to an agent produces a part
|
|
3
|
+
// parameterised on scanning artefacts. Snapping converts them back into the numbers a
|
|
4
|
+
// human actually typed.
|
|
5
|
+
//
|
|
6
|
+
// The invariant that makes this safe: snapping NEVER destroys the measurement. Every
|
|
7
|
+
// snap returns {raw, to, note} and report.js writes both, so a reader can always see
|
|
8
|
+
// what was measured and what it was interpreted as, and disagree with the second
|
|
9
|
+
// without losing the first (spec §3.1 principle 3).
|
|
10
|
+
//
|
|
11
|
+
// Candidates are ordered coarsest-first and the FIRST match within tolerance wins, not
|
|
12
|
+
// the nearest. 11.4998 must become 11.5 rather than 11.5 losing to 12 on some
|
|
13
|
+
// tie-break, and a coarse-first walk with a tight band gives that for free.
|
|
14
|
+
//
|
|
15
|
+
// Pure leaf. See spec §2.7.
|
|
16
|
+
|
|
17
|
+
// Snap band, relative to the value. Tight enough that a real 11.73 never becomes 11.75,
|
|
18
|
+
// loose enough to absorb any chord tolerance a sane exporter produces.
|
|
19
|
+
export const SNAP_TOL_FRAC = 5e-4;
|
|
20
|
+
const ABS_FLOOR = 1e-4; // below this a relative band is meaninglessly small
|
|
21
|
+
|
|
22
|
+
// ISO 273 metric clearance holes, close fit, in millimetres. Keyed by the drilled
|
|
23
|
+
// diameter a CAD model actually carries, which is what a mesh can show us — the thread
|
|
24
|
+
// size is the annotation, not the measurement.
|
|
25
|
+
//
|
|
26
|
+
// Checked against a live reference (ISO 273 close-fit column, cross-checked across
|
|
27
|
+
// multiple fastener-hardware references) rather than shipped as given: the brief's
|
|
28
|
+
// draft table used the *close*-fit diameters for M5-M10 (5.3, 6.4, 8.4, 10.5) but the
|
|
29
|
+
// *medium/normal*-fit diameters for M2-M4 (2.4, 2.9, 3.4, 4.5) while labelling every
|
|
30
|
+
// row "close fit" — an internally inconsistent table that would misidentify an M2/
|
|
31
|
+
// M2.5/M3/M4 close-fit hole as one size larger. Corrected M2-M4 to their true
|
|
32
|
+
// close-fit diameters (2.2, 2.7, 3.2, 4.3); M5-M10 were already correct and are
|
|
33
|
+
// unchanged.
|
|
34
|
+
const CLEARANCE = [
|
|
35
|
+
{ d: 2.2, note: "M2 clearance (close fit)" },
|
|
36
|
+
{ d: 2.7, note: "M2.5 clearance (close fit)" },
|
|
37
|
+
{ d: 3.2, note: "M3 clearance (close fit)" },
|
|
38
|
+
{ d: 4.3, note: "M4 clearance (close fit)" },
|
|
39
|
+
{ d: 5.3, note: "M5 clearance (close fit)" },
|
|
40
|
+
{ d: 6.4, note: "M6 clearance (close fit)" },
|
|
41
|
+
{ d: 8.4, note: "M8 clearance (close fit)" },
|
|
42
|
+
{ d: 10.5, note: "M10 clearance (close fit)" },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const near = (a, b) => Math.abs(a - b) <= Math.max(Math.abs(b) * SNAP_TOL_FRAC, ABS_FLOOR);
|
|
46
|
+
|
|
47
|
+
export function snapValue(raw, opts = {}) {
|
|
48
|
+
if (!Number.isFinite(raw)) return null;
|
|
49
|
+
const steps = opts.steps ?? [10, 5, 1, 0.5, 0.25, 0.1, 0.05];
|
|
50
|
+
for (const step of steps) {
|
|
51
|
+
const to = Math.round(raw / step) * step;
|
|
52
|
+
// Re-round to kill float dust from the divide: 0.4999/0.5 -> 0.5, not 0.5000000001.
|
|
53
|
+
const clean = Math.round(to * 1e6) / 1e6;
|
|
54
|
+
if (clean !== 0 && near(raw, clean)) return { raw, to: clean, note: null };
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function snapHoleDiameter(raw) {
|
|
60
|
+
for (const c of CLEARANCE) if (near(raw, c.d)) return { raw, to: c.d, note: c.note };
|
|
61
|
+
return snapValue(raw);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The coarsest step every value is a multiple of. Reported so the hints layer can
|
|
65
|
+
// propose parameters on that grid, and so a report reader can see at a glance whether
|
|
66
|
+
// the part was designed in whole millimetres or in something finer.
|
|
67
|
+
//
|
|
68
|
+
// Candidate list stops at 0.25, not 0.1: at SNAP_TOL_FRAC's band, any value written to
|
|
69
|
+
// one decimal place is *always* within tolerance of the nearest 0.1 multiple — that is
|
|
70
|
+
// what "one decimal place" means, independent of whether the part has any real grid.
|
|
71
|
+
// A 0.1 candidate therefore matches every input and never returns anything but a false
|
|
72
|
+
// positive; unlike 0.25, it carries no information. Confirmed by running the brief's
|
|
73
|
+
// original [10, 5, 2.5, 2, 1, 0.5, 0.25, 0.1] list against [3.1, 7.7, 11.3]: it reports
|
|
74
|
+
// {grid: 0.1, coverage: 1} instead of the `null` its own test requires.
|
|
75
|
+
export function inferGrid(values) {
|
|
76
|
+
const finite = values.filter((v) => Number.isFinite(v) && Math.abs(v) > ABS_FLOOR);
|
|
77
|
+
if (finite.length < 2) return null;
|
|
78
|
+
for (const grid of [10, 5, 2.5, 2, 1, 0.5, 0.25]) {
|
|
79
|
+
const hits = finite.filter((v) => near(v, Math.round(v / grid) * grid));
|
|
80
|
+
if (hits.length === finite.length) return { grid, coverage: 1 };
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|