partforge 0.80.0 → 0.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +50 -4
- package/docs/AUTHORING-PARTS.md +99 -161
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +1 -1
- package/src/framework/geometry/probe.js +21 -8
- package/src/framework/jobs.js +35 -12
- package/src/framework/lint/rules-build.js +12 -2
- package/src/framework/lint/rules-shape.js +19 -0
- package/src/framework/oracle/measure.js +87 -0
- package/src/framework/oracle/verify.js +4 -1
- package/src/framework/worker.js +6 -3
- package/src/oracle.js +18 -14
- package/src/parts/import-demo.js +22 -1
- package/types/oracle.d.ts +6 -11
- package/types/testing.d.ts +15 -177
- package/types/worker.d.ts +10 -2
- package/src/framework/oracle/describe/accept.js +0 -188
- package/src/framework/oracle/describe/features/dressups.js +0 -173
- package/src/framework/oracle/describe/features/holes.js +0 -129
- package/src/framework/oracle/describe/features/prismatic.js +0 -454
- package/src/framework/oracle/describe/features/sweeps.js +0 -233
- package/src/framework/oracle/describe/fit.js +0 -535
- package/src/framework/oracle/describe/hints.js +0 -91
- package/src/framework/oracle/describe/limits.js +0 -19
- package/src/framework/oracle/describe/patterns.js +0 -494
- package/src/framework/oracle/describe/ransac.js +0 -391
- package/src/framework/oracle/describe/report.js +0 -217
- package/src/framework/oracle/describe/segment.js +0 -498
- package/src/framework/oracle/describe/snap.js +0 -83
- package/src/framework/oracle/describe/surface-graph.js +0 -396
- package/src/framework/oracle/describe/topology.js +0 -121
- package/src/framework/oracle/describe.js +0 -643
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
// Efficient RANSAC (Schnabel/Wahl/Klein 2007) over the faces region growing could
|
|
2
|
-
// not claim. Growth is connectivity-driven and therefore blind to a surface split
|
|
3
|
-
// into disjoint islands by a feature crossing it — a plane interrupted by a boss,
|
|
4
|
-
// a wall broken by a slot. RANSAC is consensus-driven and does not care whether its
|
|
5
|
-
// inliers touch, so the two are complementary rather than redundant.
|
|
6
|
-
//
|
|
7
|
-
// DETERMINISM IS A HARD REQUIREMENT, not a nicety. Oracle output feeds a
|
|
8
|
-
// content-hash memo (spec §4.1) and the framework's purity rule forbids Math.random
|
|
9
|
-
// outright, so candidate sampling is built from the mesh's own topology and
|
|
10
|
-
// quantized normals instead of drawing randomly. Same input (same faces, in the
|
|
11
|
-
// same order — see `ransacPatches`'s own header note on that), same patches,
|
|
12
|
-
// every run, in every process.
|
|
13
|
-
//
|
|
14
|
-
// Pure leaf. See spec §2.3.
|
|
15
|
-
import { fitPlane, fitCylinder, fitCone, fitSphere, fitTorus, deviationOf } from "./fit.js";
|
|
16
|
-
import { facePoints } from "./segment.js";
|
|
17
|
-
|
|
18
|
-
const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
|
|
19
|
-
const unit = (a) => { const n = Math.hypot(a[0], a[1], a[2]); return n > 0 ? [a[0]/n, a[1]/n, a[2]/n] : [0, 0, 0]; };
|
|
20
|
-
|
|
21
|
-
// A minimal sample is 2 FACES (6 vertices, 6 normals — three per triangle). Two,
|
|
22
|
-
// not three, because this mop-up's own primary target (per the file header — a
|
|
23
|
-
// plane cut by a boss, a wall cut by a slot) is a flat CAD face reduced by
|
|
24
|
-
// tessellation to as few as TWO triangles, so a threshold of three would make
|
|
25
|
-
// that exact, common case unrecoverable by construction, not by any real
|
|
26
|
-
// shortfall of consensus: three DISTINCT triangles drawn from a mesh where a
|
|
27
|
-
// real plane is only ever two triangles wide can never all land on that one
|
|
28
|
-
// plane (verified directly on `boxMesh`: every 3-triangle sample mixes at least
|
|
29
|
-
// two different quads, and the least-squares plane through two different box
|
|
30
|
-
// faces' points misses tolerance by millimetres, never by noise). `minInliers`
|
|
31
|
-
// is the same number, for the same reason.
|
|
32
|
-
const SAMPLE_FACES = 2;
|
|
33
|
-
const MIN_INLIERS = SAMPLE_FACES;
|
|
34
|
-
|
|
35
|
-
// Quantization for "same normal direction, bucket together" — deliberately the
|
|
36
|
-
// same 1/24 grid `segment.js`'s own `seedOrder` uses for the identical purpose
|
|
37
|
-
// (grouping faces by Gauss-map direction), not a fresh constant invented here.
|
|
38
|
-
const NORMAL_BUCKET_SCALE = 24;
|
|
39
|
-
// Within one normal bucket, exhaustively pair only the first this-many members.
|
|
40
|
-
// A genuinely single real surface only needs ONE pair from its bucket to
|
|
41
|
-
// bootstrap (consensus then scans the WHOLE pool, not just the bucket, so it
|
|
42
|
-
// recovers every matching face regardless of which pair started it) — this cap
|
|
43
|
-
// only bounds the rarer case of one bucket holding several DIFFERENT parallel
|
|
44
|
-
// planes (same normal, different offset), where exhaustive intra-bucket
|
|
45
|
-
// pairing is what guarantees at least one same-offset pair is tried.
|
|
46
|
-
const BUCKET_PAIR_CAP = 24;
|
|
47
|
-
// Hard ceiling on total candidate pairs handed to the round below, applied
|
|
48
|
-
// AFTER generating the topological and normal-bucket pairs (see
|
|
49
|
-
// `candidatePairs`) — a cap on how much work gets EVALUATED, never a switch to
|
|
50
|
-
// a different, lossier way of choosing which pairs to generate. A prior
|
|
51
|
-
// version of this file used a coprime-stride walk as a fallback once the pool
|
|
52
|
-
// exceeded a budget, and that walk could — provably, not just theoretically —
|
|
53
|
-
// skip every same-facet pair a plane hypothesis needs (see `candidatePairs`'s
|
|
54
|
-
// own comment for the measured cylinder-wall regression this caused). The
|
|
55
|
-
// fix is not a bigger or smarter stride, it is to make the GENERATION itself
|
|
56
|
-
// structurally incapable of missing those pairs, and only THEN bound the
|
|
57
|
-
// total.
|
|
58
|
-
const PAIR_BUDGET = 65536;
|
|
59
|
-
|
|
60
|
-
// Controller ruling R28: `ransacPatches` measures at roughly O(n^2.7) in its face
|
|
61
|
-
// count — 80ms at 400 faces, 1.8s at 1600, 23.8s at 4000, ~60s around 5,500. That
|
|
62
|
-
// has been safe so far only because its one caller (`segment()`'s mop-up, below)
|
|
63
|
-
// hands it a small RESIDUAL — the faces region growing couldn't already claim, not
|
|
64
|
-
// the whole mesh. A future caller offering an unfiltered face list (the whole
|
|
65
|
-
// candidate surface of a complex mesh) could walk straight into the cliff above
|
|
66
|
-
// with no warning. `maxFaces` is that guardrail: past it, this function degrades —
|
|
67
|
-
// it processes a bounded, deterministic prefix of the pool and reports the rest as
|
|
68
|
-
// unassigned — rather than either refusing to run or silently taking minutes.
|
|
69
|
-
// 2000 is chosen to sit comfortably under the 1600-faces/1.8s point measured above
|
|
70
|
-
// (the worst case inside one bucket-pair/topology-neighbour pass is still bounded
|
|
71
|
-
// by PAIR_BUDGET regardless of pool size, but the per-candidate consensus SCAN is
|
|
72
|
-
// O(pool) per pair, which is where the n^2.7 comes from).
|
|
73
|
-
const DEFAULT_MAX_FACES = 2000;
|
|
74
|
-
|
|
75
|
-
// Angle band for "this face's own normal is compatible with the fit's local
|
|
76
|
-
// surface normal here", radians (30°) — reuses segment.js's own smoothness
|
|
77
|
-
// ceiling (`SMOOTH_DIHEDRAL_MAX`) rather than inventing a second unrelated
|
|
78
|
-
// constant for what is conceptually the same question: how much a face's flat
|
|
79
|
-
// normal may disagree with a claimed smooth surface before the two are
|
|
80
|
-
// considered different surfaces. Not re-imported (segment.js doesn't export
|
|
81
|
-
// it) because the two live in siblings with no shared parent to hoist a
|
|
82
|
-
// constant into without ransac.js importing FROM segment.js — a leaf importing
|
|
83
|
-
// its own caller — so the value is restated here with this comment as the
|
|
84
|
-
// tether between them.
|
|
85
|
-
const NORMAL_COS_MIN = Math.cos(Math.PI / 6);
|
|
86
|
-
|
|
87
|
-
const faceNormalOf = (topo, t) => [topo.faceNormal[3*t], topo.faceNormal[3*t+1], topo.faceNormal[3*t+2]];
|
|
88
|
-
function faceCentroid(topo, t) {
|
|
89
|
-
const c = [0, 0, 0];
|
|
90
|
-
for (let k = 0; k < 3; k++) {
|
|
91
|
-
const v = topo.tris[3*t + k] * 3;
|
|
92
|
-
c[0] += topo.verts[v]; c[1] += topo.verts[v+1]; c[2] += topo.verts[v+2];
|
|
93
|
-
}
|
|
94
|
-
return [c[0]/3, c[1]/3, c[2]/3];
|
|
95
|
-
}
|
|
96
|
-
// Worst distance from a face's three vertices to a fitted primitive. Exported because
|
|
97
|
-
// segment.js's growth predicate needs exactly the same question answered exactly the same
|
|
98
|
-
// way; the point-to-primitive distance itself lives in fit.js (ruling R19), so there is
|
|
99
|
-
// one definition and neither consumer can drift from it.
|
|
100
|
-
export function faceDeviation(topo, t, fit) {
|
|
101
|
-
let worst = 0;
|
|
102
|
-
for (let k = 0; k < 3; k++) {
|
|
103
|
-
const v = topo.tris[3*t + k] * 3;
|
|
104
|
-
const d = Math.abs(deviationOf(fit, [topo.verts[v], topo.verts[v+1], topo.verts[v+2]]));
|
|
105
|
-
if (d > worst) worst = d;
|
|
106
|
-
}
|
|
107
|
-
return worst;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// GRAD_H: the step of a central-difference gradient of `deviationOf`, taken as the
|
|
111
|
-
// fit's local unit outward normal. Not five hand-derived per-type normal formulas
|
|
112
|
-
// (one per fit in fit.js) — that would be five MORE places a normal convention
|
|
113
|
-
// could quietly drift from `deviationOf`'s own sign convention, exactly the
|
|
114
|
-
// duplication ruling R19 already forbids for distance. `deviationOf` is a smooth
|
|
115
|
-
// signed-distance-like field for every one of the five types by construction
|
|
116
|
-
// (fit.js's own docstring on the function), so its gradient anywhere near the
|
|
117
|
-
// surface IS that surface's local normal, generically, with no per-type case
|
|
118
|
-
// analysis needed. Fixed, not scaled to the fit's own size or the mesh's bbox:
|
|
119
|
-
// determinism forbids a step that depends on anything but the two arguments
|
|
120
|
-
// given, the same reason candidate generation below is built from the mesh's
|
|
121
|
-
// own topology and quantized normals rather than anything data-adaptive.
|
|
122
|
-
const GRAD_H = 1e-4;
|
|
123
|
-
function fitNormalAt(fit, point) {
|
|
124
|
-
// Plane fast path: `deviationOf`'s plane branch is `dot(normal, point) -
|
|
125
|
-
// offset`, exactly linear, so its gradient is `normal` EVERYWHERE, not just
|
|
126
|
-
// near the surface — this isn't an approximation for the plane case, it's
|
|
127
|
-
// the same answer the finite difference below converges to, without six
|
|
128
|
-
// extra `deviationOf` calls per face checked. Consensus in this file only
|
|
129
|
-
// ever calls this with a plane fit (`planeHypothesis` never hands back
|
|
130
|
-
// anything else), so this is the path actually taken; the general
|
|
131
|
-
// finite-difference fallback stays for `opts`/future callers that might
|
|
132
|
-
// pass a converged non-plane fit through the same check.
|
|
133
|
-
if (fit.type === "plane") return fit.normal;
|
|
134
|
-
const g = [0, 0, 0];
|
|
135
|
-
for (let a = 0; a < 3; a++) {
|
|
136
|
-
const hi = [...point], lo = [...point];
|
|
137
|
-
hi[a] += GRAD_H; lo[a] -= GRAD_H;
|
|
138
|
-
g[a] = deviationOf(fit, hi) - deviationOf(fit, lo);
|
|
139
|
-
}
|
|
140
|
-
return unit(g);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Why consensus needs BOTH a distance test and this one, not distance alone: a
|
|
144
|
-
// box's 8 corners lie, EXACTLY (to floating-point noise), on infinitely many
|
|
145
|
-
// circumscribing cylinders and spheres — any axis through the centroid parallel
|
|
146
|
-
// to a principal direction gives a cylinder every corner sits on; the
|
|
147
|
-
// circumsphere every corner sits on is a further such case. That is not a
|
|
148
|
-
// tessellation artifact, it is elementary solid geometry (a rectangular box's
|
|
149
|
-
// vertices are, by definition, equidistant from its centroid along suitably
|
|
150
|
-
// chosen axes), so it recurs on every rectangular fixture, not a fluke of one
|
|
151
|
-
// mesh — measured directly against all 60 cross-quad pairs of `boxMesh`'s 12
|
|
152
|
-
// triangles (the 66 total pairs of 12 triangles, less the 6 same-quad pairs):
|
|
153
|
-
// every one of those 60 fits a cylinder AND a sphere to machine precision. A
|
|
154
|
-
// distance-only consensus test can't tell that coincidence apart from a real
|
|
155
|
-
// surface. A real surface's own faces have flat
|
|
156
|
-
// normals that track the surface's local tangent as it's actually built; a box
|
|
157
|
-
// face dropped onto someone else's circumscribing sphere has a flat normal
|
|
158
|
-
// (say, straight down off the bottom) that has nothing to do with that
|
|
159
|
-
// sphere's local outward direction there (radially away from a center nowhere
|
|
160
|
-
// near below it) — measured directly at tens of degrees apart, well outside
|
|
161
|
-
// any plausible smoothness band. This is the standard Efficient RANSAC
|
|
162
|
-
// safeguard (Schnabel/Wahl/Klein score every inlier on normal compatibility,
|
|
163
|
-
// not distance alone), restated here because the immediate trigger for adding
|
|
164
|
-
// it was exactly this box fixture.
|
|
165
|
-
function faceNormalConsistent(topo, t, fit) {
|
|
166
|
-
return dot(fitNormalAt(fit, faceCentroid(topo, t)), faceNormalOf(topo, t)) >= NORMAL_COS_MIN;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// The minimal-sample hypothesis is PLANE ONLY, never cylinder/cone/sphere/torus,
|
|
170
|
-
// even though every fit is available (and used) once a consensus set has grown —
|
|
171
|
-
// see the REFIT step below. This is not a simplification of convenience: two
|
|
172
|
-
// faces supply only two independent surface-normal directions, and that is
|
|
173
|
-
// structurally too little to trust a 5-DOF ruled-surface fit (a cylinder's or
|
|
174
|
-
// cone's axis) with. Measured directly on `boxMesh`: EVERY pair of triangles
|
|
175
|
-
// drawn from two different, unrelated quads satisfies `fitCylinder` (and
|
|
176
|
-
// `fitSphere`) to machine-precision residual — a rectangular box's vertices are
|
|
177
|
-
// always equidistant from its centroid along the right axis, so a "cylinder"
|
|
178
|
-
// or "sphere" hypothesis born from almost any two unrelated flat facets isn't
|
|
179
|
-
// evidence of curvature at all, it's an artifact of how much freedom two
|
|
180
|
-
// normal directions leave a 5-DOF fit. No fixed normal-consistency or distance
|
|
181
|
-
// threshold closes this reliably (the more elongated a box's cross-section,
|
|
182
|
-
// the closer two unrelated facets' radial deviation drifts toward zero,
|
|
183
|
-
// eliminating any fixed-angle margin as the aspect ratio grows), so the
|
|
184
|
-
// principled fix is not to widen the FILTER but to narrow what a minimal
|
|
185
|
-
// sample is trusted to CLAIM: a plane needs only three points to be exactly
|
|
186
|
-
// determined (fit.js's own MIN_PTS), so two flat facets are already enough
|
|
187
|
-
// data to trust a plane hypothesis with no analogous coincidence risk (two
|
|
188
|
-
// unrelated, non-coplanar quads do NOT satisfy a common plane to any close
|
|
189
|
-
// tolerance — verified on the same fixture). This mirrors segment.js's own
|
|
190
|
-
// seed/grow split (a lone seed triangle is only ever classified provisionally,
|
|
191
|
-
// never asked to prove a curved type) rather than inventing a new principle.
|
|
192
|
-
function planeHypothesis(topo, sample, tol) {
|
|
193
|
-
const { pts, normals } = facePoints(topo, sample);
|
|
194
|
-
const f = fitPlane(pts);
|
|
195
|
-
if (!f || f.maxDev > tol) return null;
|
|
196
|
-
// fitPlane's normal is an eigenvector of a covariance matrix, and an
|
|
197
|
-
// eigenvector's sign is arbitrary — fit.js never canonicalizes it, because
|
|
198
|
-
// nothing in a bare point cloud says which side is "outward". A mesh face
|
|
199
|
-
// does say: `topo.faceNormal` is the winding-derived outward direction, the
|
|
200
|
-
// one thing `faceNormalConsistent` actually needs the fitted plane's normal
|
|
201
|
-
// to agree with. Without this, the two ends up antiparallel roughly half the
|
|
202
|
-
// time (whichever way the eigensolver happened to land), and
|
|
203
|
-
// `faceNormalConsistent` then rejects the SAME faces that produced the
|
|
204
|
-
// hypothesis in the first place — reproduced directly: `boxMesh`'s bottom
|
|
205
|
-
// quad fit a plane with normal (0,0,1) while its own triangles carry (0,0,-1),
|
|
206
|
-
// and the consensus step threw away the seed pair itself. Flipping both
|
|
207
|
-
// `normal` and `offset` together (so `dot(normal,p) = offset` still holds)
|
|
208
|
-
// re-expresses the identical plane with the sign the mesh's own winding
|
|
209
|
-
// agrees with; summing agreement across the whole sample rather than
|
|
210
|
-
// checking one face keeps the corrected sign robust to a single face's
|
|
211
|
-
// winding being the unusual one.
|
|
212
|
-
let agree = 0;
|
|
213
|
-
for (const n of normals) agree += dot(f.normal, n);
|
|
214
|
-
return agree < 0 ? { ...f, normal: [-f.normal[0], -f.normal[1], -f.normal[2]], offset: -f.offset } : f;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// FINAL classification of a converged consensus set, once RANSAC has stopped
|
|
218
|
-
// growing it: the full ascending-DOF ladder (plane, then cylinder/cone, then
|
|
219
|
-
// sphere/torus), first candidate within tolerance — same policy, and the same
|
|
220
|
-
// justification, as segment.js's `bestFit`. By the time this runs, `sample`
|
|
221
|
-
// is the WHOLE inlier set (already filtered on both distance and normal
|
|
222
|
-
// consistency against the seed plane), not a bare minimal sample, so it carries
|
|
223
|
-
// far more independent constraint than the two-facet hypothesis above and does
|
|
224
|
-
// not share that hypothesis's degenerate-fit risk.
|
|
225
|
-
function candidateFrom(topo, sample, tol) {
|
|
226
|
-
const { pts, normals } = facePoints(topo, sample);
|
|
227
|
-
for (const f of [fitPlane(pts), fitCylinder(pts, normals), fitCone(pts, normals),
|
|
228
|
-
fitSphere(pts), fitTorus(pts, normals)]) {
|
|
229
|
-
if (f && f.maxDev <= tol) return f;
|
|
230
|
-
}
|
|
231
|
-
return null;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Every candidate pair worth trying a plane hypothesis on, deterministically
|
|
235
|
-
// ordered. A plane needs matching normal direction (two faces from unrelated
|
|
236
|
-
// directions never fit one to any close tolerance — see `planeHypothesis`'s
|
|
237
|
-
// own comment), so the pairs that can EVER succeed are already a narrow,
|
|
238
|
-
// structured subset of all C(n,2) — this generates exactly that subset from
|
|
239
|
-
// two cheap, complementary sources instead of guessing at it with a stride:
|
|
240
|
-
//
|
|
241
|
-
// 1. TOPOLOGICAL NEIGHBOURS (`topo.faceEdges`): O(3n), and — this is the part
|
|
242
|
-
// a coprime-stride walk got wrong — GUARANTEED to include the diagonal
|
|
243
|
-
// pair of any single real flat facet still whole in the pool (a box quad's
|
|
244
|
-
// own two triangles, a cylinder wall segment's own two triangles: they
|
|
245
|
-
// share an edge by construction). A prior version of this file fell back
|
|
246
|
-
// to a stride walk once the pool exceeded a budget, and that walk could
|
|
247
|
-
// (measured directly: a 100-segment cylinder's 200 wall triangles, handed
|
|
248
|
-
// to `ransacPatches` with no adjacency hint, at that budget) skip EVERY
|
|
249
|
-
// one of the wall's 100 same-facet diagonal pairs, recovering only the two
|
|
250
|
-
// caps and leaving the entire wall — exactly half the mesh — unassigned,
|
|
251
|
-
// silently, with no error. Topological adjacency has no such blind spot:
|
|
252
|
-
// it doesn't sample a subset hoping to land on the right pairs, it reads
|
|
253
|
-
// the one relationship (shared edge) a real facet's own two triangles
|
|
254
|
-
// always have, for every face in the pool, unconditionally.
|
|
255
|
-
// 2. NORMAL BUCKETS (see `NORMAL_BUCKET_SCALE`): reaches a face with NO
|
|
256
|
-
// topological neighbour left in the pool at all — a genuinely isolated
|
|
257
|
-
// sliver, or one half of a plane a crossing feature disconnected from its
|
|
258
|
-
// other half — by pairing it with whatever ELSE in the pool shares its
|
|
259
|
-
// (quantized) normal direction, the one thing any two pieces of the same
|
|
260
|
-
// real flat surface are guaranteed to share. Bounded per bucket (see
|
|
261
|
-
// `BUCKET_PAIR_CAP`) rather than fully exhaustive, because a bucket that
|
|
262
|
-
// holds one real, already-topologically-connected surface already got its
|
|
263
|
-
// bootstrap pair from source 1 above — this only needs to guarantee
|
|
264
|
-
// covering pairs ACROSS disconnected pieces, which is cheap by
|
|
265
|
-
// construction (few faces per genuine disconnected remainder).
|
|
266
|
-
//
|
|
267
|
-
// `PAIR_BUDGET` truncates the COMBINED result as a hard cap on total pairs
|
|
268
|
-
// evaluated, applied last and only as a cap — never as a reason to swap in a
|
|
269
|
-
// worse generator (see `PAIR_BUDGET`'s own comment).
|
|
270
|
-
function candidatePairs(topo, pool) {
|
|
271
|
-
const seen = new Set();
|
|
272
|
-
const pairs = [];
|
|
273
|
-
const push = (a, b) => {
|
|
274
|
-
if (a === b) return;
|
|
275
|
-
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
|
|
276
|
-
if (seen.has(key)) return;
|
|
277
|
-
seen.add(key);
|
|
278
|
-
pairs.push(a < b ? [a, b] : [b, a]);
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
const inPool = new Set(pool);
|
|
282
|
-
for (const t of pool) {
|
|
283
|
-
for (const ei of topo.faceEdges[t]) {
|
|
284
|
-
const e = topo.edges[ei];
|
|
285
|
-
if (e.triB < 0) continue;
|
|
286
|
-
const nb = e.triA === t ? e.triB : e.triA;
|
|
287
|
-
if (inPool.has(nb)) push(t, nb);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const buckets = new Map();
|
|
292
|
-
for (const t of pool) {
|
|
293
|
-
const n = faceNormalOf(topo, t);
|
|
294
|
-
const key = `${Math.round(n[0]*NORMAL_BUCKET_SCALE)},${Math.round(n[1]*NORMAL_BUCKET_SCALE)},${Math.round(n[2]*NORMAL_BUCKET_SCALE)}`;
|
|
295
|
-
let bucket = buckets.get(key);
|
|
296
|
-
if (!bucket) buckets.set(key, bucket = []);
|
|
297
|
-
bucket.push(t);
|
|
298
|
-
}
|
|
299
|
-
for (const bucket of buckets.values()) {
|
|
300
|
-
const m = Math.min(bucket.length, BUCKET_PAIR_CAP);
|
|
301
|
-
for (let i = 0; i < m; i++) for (let j = i + 1; j < m; j++) push(bucket[i], bucket[j]);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return pairs.length > PAIR_BUDGET ? pairs.slice(0, PAIR_BUDGET) : pairs;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
export function ransacPatches(topo, faces, tol, opts = {}) {
|
|
308
|
-
const minInliers = opts.minInliers ?? MIN_INLIERS;
|
|
309
|
-
const maxFaces = opts.maxFaces ?? DEFAULT_MAX_FACES;
|
|
310
|
-
// Over the cap: split off a deterministic, order-independent prefix to actually
|
|
311
|
-
// process and defer the rest straight to `unassigned` (folded in below, after the
|
|
312
|
-
// main loop, alongside whatever the consensus search itself couldn't claim).
|
|
313
|
-
// Sorted by face INDEX — a property of the mesh, not of the caller's array — so
|
|
314
|
-
// which faces get processed vs deferred does not depend on the order `faces` was
|
|
315
|
-
// handed in, the same order-independence the rest of this function already
|
|
316
|
-
// guarantees (see the canonicalization comments below).
|
|
317
|
-
let overflow = [];
|
|
318
|
-
let bounded = faces;
|
|
319
|
-
if (faces.length > maxFaces) {
|
|
320
|
-
const sorted = [...faces].sort((a, b) => a - b);
|
|
321
|
-
bounded = sorted.slice(0, maxFaces);
|
|
322
|
-
overflow = sorted.slice(maxFaces);
|
|
323
|
-
}
|
|
324
|
-
let pool = [...bounded];
|
|
325
|
-
const patches = [];
|
|
326
|
-
|
|
327
|
-
while (pool.length >= minInliers) {
|
|
328
|
-
let best = null;
|
|
329
|
-
// Deterministic minimal samples: every pair `candidatePairs` generates, not
|
|
330
|
-
// drawn at random (see the file header — determinism is a hard requirement,
|
|
331
|
-
// not a style preference) and not a coprime-stride subset either (see
|
|
332
|
-
// `candidatePairs` for why that seemed reasonable and provably was not).
|
|
333
|
-
for (const sample of candidatePairs(topo, pool)) {
|
|
334
|
-
const fit = planeHypothesis(topo, sample, tol);
|
|
335
|
-
if (!fit) continue;
|
|
336
|
-
const inliers = pool.filter((t) => faceDeviation(topo, t, fit) <= tol && faceNormalConsistent(topo, t, fit));
|
|
337
|
-
if (inliers.length >= minInliers && (!best || inliers.length > best.inliers.length)) best = { fit, inliers };
|
|
338
|
-
}
|
|
339
|
-
if (!best) break;
|
|
340
|
-
// Refit and reclassify on the full consensus set: the minimal sample only
|
|
341
|
-
// located a plane, and every subsequent consumer reads these parameters as
|
|
342
|
-
// measurements. `candidateFrom` here can, and sometimes should, come back
|
|
343
|
-
// with a NON-plane type — an island whose faces all satisfy the seed plane's
|
|
344
|
-
// distance+normal test can still turn out to be better described some other
|
|
345
|
-
// way once every one of its faces (not just two) is fitted at once. Falls
|
|
346
|
-
// back to the minimal-sample fit (never null: the loop above only records
|
|
347
|
-
// `best` when `fit` succeeded) if the larger set fails every candidate
|
|
348
|
-
// outright — that can happen when the consensus set's extra faces are within
|
|
349
|
-
// `tol` of the seed plane individually but their combined least-squares
|
|
350
|
-
// refit drifts just past it.
|
|
351
|
-
const refit = candidateFrom(topo, best.inliers, tol) ?? best.fit;
|
|
352
|
-
patches.push({
|
|
353
|
-
faces: best.inliers, fit: refit,
|
|
354
|
-
area: best.inliers.reduce((a, t) => a + topo.faceArea[t], 0),
|
|
355
|
-
});
|
|
356
|
-
const claimed = new Set(best.inliers);
|
|
357
|
-
pool = pool.filter((t) => !claimed.has(t));
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Canonicalize order before numbering: the WHILE loop above discovers
|
|
361
|
-
// patches in an order driven by `pool`'s own current arrangement (ties in
|
|
362
|
-
// consensus size are broken by whichever candidate the pair generator
|
|
363
|
-
// happened to try first), and `pool`'s arrangement starts from whatever
|
|
364
|
-
// order the caller passed `faces` in. The set of faces in each patch is
|
|
365
|
-
// provably order-invariant (region growing's own connectivity doesn't
|
|
366
|
-
// enter into this file at all, and neither does array order — only which
|
|
367
|
-
// faces satisfy a fitted plane's distance+normal test, which doesn't
|
|
368
|
-
// depend on iteration order); the SEQUENCE they were discovered in is not,
|
|
369
|
-
// and this file's own header claims "same input, same patches, every
|
|
370
|
-
// run" — a claim that is only true if "same input" means the same SET of
|
|
371
|
-
// faces, not the same order too, unless output order is made to not
|
|
372
|
-
// depend on input order either. Sorting by each patch's own smallest face
|
|
373
|
-
// index — a property of the mesh's topology, not of the caller's array —
|
|
374
|
-
// gives a canonical order with no dependency on how `faces` happened to
|
|
375
|
-
// be listed, closing that gap rather than merely documenting it.
|
|
376
|
-
for (const p of patches) p.faces.sort((a, b) => a - b);
|
|
377
|
-
patches.sort((a, b) => a.faces[0] - b.faces[0]);
|
|
378
|
-
patches.forEach((p, i) => { p.id = `r${i}`; });
|
|
379
|
-
|
|
380
|
-
// Same reasoning for the residual: its CONTENT (which faces went unclaimed)
|
|
381
|
-
// is order-invariant, but `pool` carries whatever relative order the
|
|
382
|
-
// caller's `faces` had. `overflow` (the maxFaces deferral above, empty on
|
|
383
|
-
// the common in-budget path) folds in here rather than earlier, so a face
|
|
384
|
-
// the consensus search never even looked at is reported the same way as one
|
|
385
|
-
// it looked at and couldn't claim — both are just "unassigned" to the
|
|
386
|
-
// caller. A plain ascending sort is the canonical form for a bare list of
|
|
387
|
-
// face indices.
|
|
388
|
-
pool = pool.concat(overflow).sort((a, b) => a - b);
|
|
389
|
-
|
|
390
|
-
return { patches, unassigned: pool };
|
|
391
|
-
}
|
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
// The two report shapes, defined together on purpose.
|
|
2
|
-
//
|
|
3
|
-
// FULL is the archive: everything measured, written to disk by `partforge describe
|
|
4
|
-
// --json`. COMPACT is what a model reads: features, patterns, symmetry, score,
|
|
5
|
-
// residual, and the suggestion, with surfaces and edges elided to counts.
|
|
6
|
-
//
|
|
7
|
-
// Both live here rather than compact being invented by each consumer. A 24k-triangle
|
|
8
|
-
// part yields hundreds of surfaces, and every consumer that trims them independently
|
|
9
|
-
// trims them differently — cloud's model-facing view and the CLI's summary would drift
|
|
10
|
-
// apart within a release. mountManager.js's compactReport is the precedent for the
|
|
11
|
-
// principle (a full oracle report is not a model-facing artefact) and the warning: it
|
|
12
|
-
// lives downstream, and its field renames have to be kept in sync by hand.
|
|
13
|
-
//
|
|
14
|
-
// Pure leaf. See spec §3.
|
|
15
|
-
|
|
16
|
-
import { DESCRIBE_LIMITS } from "./limits.js";
|
|
17
|
-
|
|
18
|
-
// Below this fraction the geometry is not trustworthy as a description of the part,
|
|
19
|
-
// and saying so quietly is worse than saying nothing: an agent will build against a
|
|
20
|
-
// confident-looking list that covers 61% of the geometry. See compactDescribe's own
|
|
21
|
-
// comment for what "this fraction" is measured against (fix round 1: it's the worse
|
|
22
|
-
// of two independent numbers, not just one of them).
|
|
23
|
-
export const LOW_COVERAGE = 0.85;
|
|
24
|
-
|
|
25
|
-
// Caps are CEILINGS, not targets: slicing an oversized array is not a failure, it is
|
|
26
|
-
// the report doing its job. `flags[name]` records whether THIS array actually hit its
|
|
27
|
-
// ceiling, so `buildReport`'s `truncated` block only ever claims what really happened.
|
|
28
|
-
const cap = (arr, max, flags, name) => {
|
|
29
|
-
const a = arr ?? [];
|
|
30
|
-
flags[name] = a.length > max;
|
|
31
|
-
return a.slice(0, max);
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// score is passed straight through except for one added field: a plain-language note
|
|
35
|
-
// distinguishing its two coverage numbers. `explainedArea` (segment.js/surface-graph.js
|
|
36
|
-
// territory) is how much of the mesh's SURFACE was fit by a surface type; it says
|
|
37
|
-
// nothing about whether that surface was ever turned into a reconstructable feature.
|
|
38
|
-
// `explainedVolumeFraction` (accept.js) is how much of the part's VOLUME the accepted
|
|
39
|
-
// candidates actually rebuild — the number that matters to an agent deciding whether it
|
|
40
|
-
// can trust the feature list to rebuild the part. They can diverge totally: a sphere
|
|
41
|
-
// segments at explainedArea ~1 (it fits a sphere surface cleanly) while contributing
|
|
42
|
-
// explainedVolumeFraction 0 (no detector treats a sphere as a candidate-eligible
|
|
43
|
-
// feature type, so accept.js never gets a candidate to build from it at all). Spelled
|
|
44
|
-
// out here, in the data, because the consumer is an LLM that cannot go read accept.js's
|
|
45
|
-
// comments to work out that these are different measurements (fix round 1, IMPORTANT 5).
|
|
46
|
-
//
|
|
47
|
-
// Also covers each feature's own `volumeShare` (describe.js), the SAME underlying
|
|
48
|
-
// accept.js gain measurement applied per-feature rather than summed: how much of the
|
|
49
|
-
// part's volume THAT feature accounts for, not how sure the description is (round 2
|
|
50
|
-
// review, IMPORTANT). A small-but-certain feature legitimately reports a small share —
|
|
51
|
-
// spelled out here for the same reason the area/volume distinction is: the consumer
|
|
52
|
-
// cannot go read accept.js's comments to learn that "small" and "uncertain" are not
|
|
53
|
-
// the same axis.
|
|
54
|
-
const SCORE_NOTE =
|
|
55
|
-
"explainedArea is surface coverage from segmentation (how much of the mesh's area " +
|
|
56
|
-
"was fit by a surface); explainedVolumeFraction is shape coverage from " +
|
|
57
|
-
"reconstruction (how much of the part's volume the accepted features actually " +
|
|
58
|
-
"rebuild). These are different measurements and can diverge totally — a part can " +
|
|
59
|
-
"be ~100% segmented and 0% reconstructed. The LOW COVERAGE banner, if present, " +
|
|
60
|
-
"reflects whichever of the two is worse. Each feature's own volumeShare is the " +
|
|
61
|
-
"same reconstruction measurement applied per-feature: the fraction of the part's " +
|
|
62
|
-
"volume THAT feature accounts for. It is a measure of SIZE, not certainty — a " +
|
|
63
|
-
"small-but-certain feature (e.g. a precisely-fitted 3mm hole in a large plate) " +
|
|
64
|
-
"legitimately reports a small volumeShare because it is small, not because it is " +
|
|
65
|
-
"doubtful. For a genuine fit-quality/certainty signal, read that feature's own " +
|
|
66
|
-
"fitted surface rms/maxDev instead.";
|
|
67
|
-
|
|
68
|
-
function buildScore(score) {
|
|
69
|
-
return { ...(score ?? {}), note: SCORE_NOTE };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function buildReport(input) {
|
|
73
|
-
// Every array this report might cap gets a flag here, initialised false, before any
|
|
74
|
-
// capping happens — so the shape is TOTAL. A cap never hit, and an array the input
|
|
75
|
-
// never supplied at all (e.g. no suggestion yet, before Task 12's orchestrator is
|
|
76
|
-
// wired in), must both report `false`, not an absent key: a consumer that walks
|
|
77
|
-
// `Object.values(truncated)` needs every flag present every time, or "nothing was
|
|
78
|
-
// truncated" and "the flag is just missing" become indistinguishable (fix round 1,
|
|
79
|
-
// IMPORTANT 4).
|
|
80
|
-
const truncated = {
|
|
81
|
-
surfaces: false,
|
|
82
|
-
edges: false,
|
|
83
|
-
features: false,
|
|
84
|
-
patterns: false,
|
|
85
|
-
residualRegions: false,
|
|
86
|
-
suggestionSteps: false,
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const surfaces = cap(input.surfaces, DESCRIBE_LIMITS.MAX_SURFACES, truncated, "surfaces");
|
|
90
|
-
// input.arcs are the fitted edges between surface pairs (Tasks 6-7); reported here
|
|
91
|
-
// as "edges" since that is what a reader of the report calls them. Each carries a
|
|
92
|
-
// `convexity` of "convex" or "concave" — settled empirically to mean whether the
|
|
93
|
-
// edge rounds an outside or an inside corner of the PART, nothing about how a
|
|
94
|
-
// torus's own tube radius is parametrised. Easy to misread as the latter; it isn't.
|
|
95
|
-
const edges = cap(input.arcs, DESCRIBE_LIMITS.MAX_EDGES, truncated, "edges");
|
|
96
|
-
const features = cap(input.features, DESCRIBE_LIMITS.MAX_FEATURES, truncated, "features");
|
|
97
|
-
const patterns = cap(input.patterns, DESCRIBE_LIMITS.MAX_PATTERNS, truncated, "patterns");
|
|
98
|
-
const residualRegions = cap(
|
|
99
|
-
input.residual?.regions, DESCRIBE_LIMITS.MAX_RESIDUAL_REGIONS, truncated, "residualRegions"
|
|
100
|
-
);
|
|
101
|
-
// Capped even when there is no suggestion yet (input.suggestion null/undefined) so
|
|
102
|
-
// `truncated.suggestionSteps` is always `false` in that case, never absent — see the
|
|
103
|
-
// flag block's own comment above.
|
|
104
|
-
const suggestionSteps = cap(
|
|
105
|
-
input.suggestion?.steps, DESCRIBE_LIMITS.MAX_SUGGESTION_STEPS, truncated, "suggestionSteps"
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
return {
|
|
109
|
-
source: {
|
|
110
|
-
name: input.source?.name ?? null,
|
|
111
|
-
digest: input.source?.digest ?? null,
|
|
112
|
-
triangles: input.source?.triangles ?? 0,
|
|
113
|
-
watertight: input.source?.watertight ?? null,
|
|
114
|
-
units: "mm",
|
|
115
|
-
},
|
|
116
|
-
// Stated explicitly, every time. Z-up/Y-up confusion is a documented LLM failure
|
|
117
|
-
// mode (research doc §5) and one line defuses it. "as-imported" is the honest
|
|
118
|
-
// claim: describe never realigns, so the frame is whatever the file carried.
|
|
119
|
-
frame: { up: "+Z", note: "as-imported; no realignment applied" },
|
|
120
|
-
bounds: {
|
|
121
|
-
min: input.bounds.min,
|
|
122
|
-
max: input.bounds.max,
|
|
123
|
-
size: [0, 1, 2].map((i) => input.bounds.max[i] - input.bounds.min[i]),
|
|
124
|
-
},
|
|
125
|
-
// Pre-cap magnitudes, computed from the INPUT, not from `surfaces`/`edges` below.
|
|
126
|
-
// Those two arrays are what compactDescribe elides to counts, and a cap that fires
|
|
127
|
-
// must not also erase how big the thing it capped really was: 500 input surfaces
|
|
128
|
-
// against MAX_SURFACES=200 must still say 500 here, not silently report 200 as if
|
|
129
|
-
// that were the whole part (fix round 1, IMPORTANT 3).
|
|
130
|
-
counts: {
|
|
131
|
-
surfaces: (input.surfaces ?? []).length,
|
|
132
|
-
edges: (input.arcs ?? []).length,
|
|
133
|
-
},
|
|
134
|
-
surfaces,
|
|
135
|
-
edges,
|
|
136
|
-
features,
|
|
137
|
-
patterns,
|
|
138
|
-
symmetry: input.symmetry ?? [],
|
|
139
|
-
residual: {
|
|
140
|
-
areaFraction: input.residual?.areaFraction ?? 0,
|
|
141
|
-
regions: residualRegions,
|
|
142
|
-
},
|
|
143
|
-
score: buildScore(input.score),
|
|
144
|
-
suggestion: input.suggestion ? { ...input.suggestion, steps: suggestionSteps } : null,
|
|
145
|
-
truncated,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function compactDescribe(full) {
|
|
150
|
-
// Gate on the WORSE of the two coverage numbers (see SCORE_NOTE above), not just
|
|
151
|
-
// explainedArea. Reproduced directly against the real pipeline with a 2304-triangle
|
|
152
|
-
// hemisphere: one sphere surface plus one flat base segment cleanly
|
|
153
|
-
// (explainedArea 1.0, zero unassigned triangles) but sphere is not a
|
|
154
|
-
// candidate-eligible type for any of the four feature detectors, so accept.js is
|
|
155
|
-
// handed zero candidates and explainedVolumeFraction is 0.0. Gating on explainedArea
|
|
156
|
-
// alone let a report that reconstructs NONE of the part's shape present as clean —
|
|
157
|
-
// exactly backwards for a banner whose whole job is to catch that (fix round 1,
|
|
158
|
-
// CRITICAL 1). `?? 0` on each side means a missing number reads as unexplained, not
|
|
159
|
-
// as "fine" — the fail-safe direction for a report whose job is to not overclaim.
|
|
160
|
-
const explainedArea = full.score?.explainedArea ?? 0;
|
|
161
|
-
const explainedVolume = full.score?.explainedVolumeFraction ?? 0;
|
|
162
|
-
const coverage = Math.min(explainedArea, explainedVolume);
|
|
163
|
-
|
|
164
|
-
const out = {};
|
|
165
|
-
// Assigned FIRST — and therefore serialized first, both by Object.keys() and by
|
|
166
|
-
// JSON.stringify(), which both preserve insertion order for string keys — so an LLM
|
|
167
|
-
// reading the raw JSON hits the banner before anything it might otherwise take at
|
|
168
|
-
// face value. (Fix round 1, CRITICAL 2: this used to be assigned after the rest of
|
|
169
|
-
// `out` was built, which put it last in both orders — exactly backwards for a banner
|
|
170
|
-
// whose entire job is to be seen first.)
|
|
171
|
-
//
|
|
172
|
-
// Two INDEPENDENT warning sources feed this one field (fix round 2, IMPORTANT 1):
|
|
173
|
-
// low coverage (computed here, from `score`) and `full.warning === "budget-exceeded"`
|
|
174
|
-
// (set by describe.js when acceptCandidates ran out of boolean attempts before the
|
|
175
|
-
// residual converged — see that assignment's own comment). Before this fix
|
|
176
|
-
// `full.warning` was silently dropped: `compactDescribe` only ever set `out.warning`
|
|
177
|
-
// from its own LOW_COVERAGE check, so a report that hit BOTH conditions — a real,
|
|
178
|
-
// documented case, since an exhausted budget is exactly the kind of run that also
|
|
179
|
-
// leaves coverage low — presented as a clean report in every consumer that reads only
|
|
180
|
-
// the compact shape (the CLI's default text view among them). Both fire independently
|
|
181
|
-
// and neither may mask the other, so both are collected and joined rather than one
|
|
182
|
-
// overwriting the other.
|
|
183
|
-
const warnings = [];
|
|
184
|
-
if (coverage < LOW_COVERAGE) {
|
|
185
|
-
warnings.push(
|
|
186
|
-
`LOW COVERAGE: only ${(100 * coverage).toFixed(1)}% of this part is explained ` +
|
|
187
|
-
`(the worse of ${(100 * explainedArea).toFixed(1)}% surface area fit and ` +
|
|
188
|
-
`${(100 * explainedVolume).toFixed(1)}% shape reconstructed). Treat the feature ` +
|
|
189
|
-
`list as incomplete in BOTH directions — do not assume a feature is absent ` +
|
|
190
|
-
`because it is not listed, AND do not assume every listed feature is real: a ` +
|
|
191
|
-
`low-coverage segmentation can also type tessellation fragments (e.g. noise at ` +
|
|
192
|
-
`fillet corners) as their own small bosses/pockets. See residual.regions for ` +
|
|
193
|
-
`where the unexplained geometry is.`);
|
|
194
|
-
}
|
|
195
|
-
if (full.warning === "budget-exceeded") {
|
|
196
|
-
warnings.push(
|
|
197
|
-
`BUDGET EXCEEDED: the acceptance loop hit its boolean-attempt budget before the ` +
|
|
198
|
-
`residual converged. This report is honestly scored, not wrong — but it may be ` +
|
|
199
|
-
`incomplete for the same reason low coverage would be. Raise --budget and ` +
|
|
200
|
-
`re-describe, or treat the current feature list as partial.`);
|
|
201
|
-
}
|
|
202
|
-
if (warnings.length) out.warning = warnings.join("\n\n");
|
|
203
|
-
out.source = full.source;
|
|
204
|
-
out.frame = full.frame;
|
|
205
|
-
out.bounds = full.bounds;
|
|
206
|
-
// Pre-cap counts, straight from `full.counts` — see buildReport's own comment on why
|
|
207
|
-
// these must not be derived from the (possibly-capped) `full.surfaces`/`full.edges`.
|
|
208
|
-
out.counts = full.counts;
|
|
209
|
-
out.features = full.features;
|
|
210
|
-
out.patterns = full.patterns;
|
|
211
|
-
out.symmetry = full.symmetry;
|
|
212
|
-
out.residual = full.residual;
|
|
213
|
-
out.score = full.score;
|
|
214
|
-
out.suggestion = full.suggestion;
|
|
215
|
-
out.truncated = full.truncated;
|
|
216
|
-
return out;
|
|
217
|
-
}
|