partforge 0.77.0 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,535 @@
1
+ // Least-squares fits for the five analytic surfaces the describe vocabulary uses.
2
+ //
3
+ // EVERY fit returns its own error (`rms`, `maxDev`) and no fit is ever returned
4
+ // without one. That is not decoration: the report's entire claim to honesty is
5
+ // that a surface carries the residual of the primitive it was called, so a caller
6
+ // can tell a real cylinder from a lightly-curved freeform patch that a fitter was
7
+ // willing to call one. A fit function that returned only parameters would make the
8
+ // report unfalsifiable.
9
+ //
10
+ // The algebraic (rather than geometric/iterative) formulations are deliberate.
11
+ // They are exact for exact data — which is the v1 input class, CAD-exported
12
+ // tessellation — closed-form, dependency-free, and fast enough to run inside a
13
+ // region-growing refit loop. They bias slightly under heavy noise; that is the
14
+ // known cost to revisit if real scans become a target (spec §9).
15
+ //
16
+ // Pure leaf. See spec §2.2.
17
+
18
+ const MIN_PTS = { plane: 3, sphere: 4, circle: 3 };
19
+
20
+ const sub = (a, b) => [a[0]-b[0], a[1]-b[1], a[2]-b[2]];
21
+ const dot = (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 scale = (a, s) => [a[0]*s, a[1]*s, a[2]*s];
24
+ const unit = (a) => { const n = Math.hypot(a[0], a[1], a[2]); return n > 0 ? scale(a, 1/n) : [0,0,0]; };
25
+ const mean = (pts) => {
26
+ const c = [0,0,0];
27
+ for (const p of pts) { c[0]+=p[0]; c[1]+=p[1]; c[2]+=p[2]; }
28
+ return scale(c, 1/pts.length);
29
+ };
30
+ // Deviations → {rms, maxDev}. One place, so no fit can invent its own error metric.
31
+ const errors = (devs) => {
32
+ let s = 0, m = 0;
33
+ for (const d of devs) { s += d*d; if (Math.abs(d) > m) m = Math.abs(d); }
34
+ return { rms: Math.sqrt(s / devs.length), maxDev: m };
35
+ };
36
+
37
+ // SIGNED distance from a point to the surface a completed fit describes:
38
+ // positive outside the surface (further from the solid than the fit claims),
39
+ // negative inside, ~0 on it. Every fit below computes exactly this arithmetic
40
+ // once, inline, over its own residual loop, to produce rms/maxDev — this is
41
+ // that arithmetic factored out into the one place it belongs, rather than a
42
+ // sixth private copy. Two callers outside this file need the identical
43
+ // definition and must never be allowed to drift apart on it: segment.js's
44
+ // region-growing predicate (a candidate face joins a patch only if its
45
+ // deviation from the STANDING fit is small) and Task 4's RANSAC consensus
46
+ // test (a point counts as an inlier under the identical rule). Takes a
47
+ // completed fit's parameters (type plus whatever fields that type carries —
48
+ // normal/offset, center/radius, axis/origin/radius, apex/direction/halfAngle,
49
+ // or center/axis/majorRadius/minorRadius); rms/maxDev are not read.
50
+ export function deviationOf(fit, point) {
51
+ switch (fit.type) {
52
+ case "plane":
53
+ return dot(fit.normal, point) - fit.offset;
54
+ case "sphere": {
55
+ const d = sub(point, fit.center);
56
+ return Math.hypot(d[0], d[1], d[2]) - fit.radius;
57
+ }
58
+ case "cylinder": {
59
+ const d = sub(point, fit.axis.origin), dir = fit.axis.direction;
60
+ const ax = dot(d, dir);
61
+ return Math.hypot(d[0]-ax*dir[0], d[1]-ax*dir[1], d[2]-ax*dir[2]) - fit.radius;
62
+ }
63
+ case "cone": {
64
+ const d = sub(point, fit.apex), dir = fit.direction;
65
+ const ax = dot(d, dir);
66
+ const rad = Math.hypot(d[0]-ax*dir[0], d[1]-ax*dir[1], d[2]-ax*dir[2]);
67
+ // Perpendicular (not radial) distance to the cone's slanted surface: the
68
+ // radial gap at this height, projected onto the surface normal via
69
+ // cos(halfAngle) — same derivation fitCone's own residual used below,
70
+ // recomputing tan/cos of halfAngle rather than threading a precomputed
71
+ // tanA through, since a fit's stored parameters are its only contract.
72
+ return (rad - ax * Math.tan(fit.halfAngle)) * Math.cos(fit.halfAngle);
73
+ }
74
+ case "torus": {
75
+ const d = sub(point, fit.center), dir = fit.axis;
76
+ const ax = dot(d, dir);
77
+ const rad = Math.hypot(d[0]-ax*dir[0], d[1]-ax*dir[1], d[2]-ax*dir[2]);
78
+ return Math.hypot(rad - fit.majorRadius, ax) - fit.minorRadius;
79
+ }
80
+ default:
81
+ throw new Error(`deviationOf: unknown fit type "${fit.type}"`);
82
+ }
83
+ }
84
+
85
+ // Cyclic Jacobi eigendecomposition of a symmetric 3x3, returned smallest-first.
86
+ // Chosen over the analytic cubic because the cubic loses precision badly on nearly
87
+ // degenerate spectra — which is exactly the case here, since a well-fit plane's
88
+ // covariance HAS a near-zero eigenvalue and that eigenvector is the answer.
89
+ export function jacobiEigen(m) {
90
+ const a = [[m[0][0], m[0][1], m[0][2]], [m[1][0], m[1][1], m[1][2]], [m[2][0], m[2][1], m[2][2]]];
91
+ let v = [[1,0,0],[0,1,0],[0,0,1]];
92
+ for (let sweep = 0; sweep < 24; sweep++) {
93
+ let off = 0;
94
+ for (const [p, q] of [[0,1],[0,2],[1,2]]) off += a[p][q] * a[p][q];
95
+ if (off < 1e-30) break;
96
+ for (const [p, q] of [[0,1],[0,2],[1,2]]) {
97
+ if (Math.abs(a[p][q]) < 1e-300) continue;
98
+ const theta = (a[q][q] - a[p][p]) / (2 * a[p][q]);
99
+ const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta*theta + 1));
100
+ const c = 1 / Math.sqrt(t*t + 1), s = t * c;
101
+ for (let k = 0; k < 3; k++) {
102
+ const akp = a[k][p], akq = a[k][q];
103
+ a[k][p] = c*akp - s*akq; a[k][q] = s*akp + c*akq;
104
+ }
105
+ for (let k = 0; k < 3; k++) {
106
+ const apk = a[p][k], aqk = a[q][k];
107
+ a[p][k] = c*apk - s*aqk; a[q][k] = s*apk + c*aqk;
108
+ }
109
+ for (let k = 0; k < 3; k++) {
110
+ const vkp = v[k][p], vkq = v[k][q];
111
+ v[k][p] = c*vkp - s*vkq; v[k][q] = s*vkp + c*vkq;
112
+ }
113
+ }
114
+ }
115
+ const order = [0,1,2].sort((i, j) => a[i][i] - a[j][j]);
116
+ return {
117
+ values: order.map((i) => a[i][i]),
118
+ vectors: order.map((i) => [v[0][i], v[1][i], v[2][i]]),
119
+ };
120
+ }
121
+
122
+ // Rotation-invariant characteristic frame of a point set: an orthonormal basis
123
+ // (`axes`) aligned to its OWN principal directions, plus a rotation-invariant
124
+ // characteristic length `diagonal` — for TOLERANCE SCALING and DIRECTION
125
+ // QUANTIZATION only, never for a reported bound (describe.js's `bounds()`
126
+ // deliberately stays world-frame; a caller's solid really is embedded in world
127
+ // space and the report must reflect that).
128
+ //
129
+ // `diagonal` (fix round 3, CRITICAL — this replaces a PCA-bbox version of this
130
+ // same function that was itself orientation-dependent on exactly the shapes it
131
+ // most needed to cover; see the history below). segment.js and surface-graph.js
132
+ // each scale their fit-acceptance band off "the mesh's own diagonal", and
133
+ // sweeps.js's shell gate divides by it directly — read off the wrong quantity,
134
+ // tilting a part changes the accept/reject boundary for reasons that have
135
+ // nothing to do with the part's geometry. Computed here as `2 * sqrt(trace /
136
+ // n)`, twice the radius of gyration about the centroid: `trace` (the sum of
137
+ // squared distances from the centroid, accumulated below alongside the
138
+ // covariance `cov` already being built for `axes`) is invariant under ANY
139
+ // rotation by construction — a similarity transform preserves trace — so this
140
+ // needs no eigen-decomposition and has NO degenerate case to worry about, unlike
141
+ // the bbox-diagonal version it replaces.
142
+ //
143
+ // This function returns EXACTLY that quantity, no calibration folded in (fix
144
+ // round 5 — a round 4 version multiplied the result by a constant measured on
145
+ // one fixture, so that shape's effective tolerance matched the old bbox-diagonal
146
+ // formula's; withdrawn on review, because the ratio between a bounding-box
147
+ // diagonal and a radius of gyration is SHAPE-DEPENDENT — measured directly, the
148
+ // reference fixture needed 1.08791 and filletedBox's own true ratio was 1.18510,
149
+ // ~9% apart, driven by vertex DENSITY and DISTRIBUTION (3631 fillet vertices vs.
150
+ // 16 sparse corners), not by anything the reference shape's own number could
151
+ // capture for a different mesh. No single constant here can make "effective
152
+ // tolerance unchanged" true in general, so this function does not claim to: it
153
+ // is a geometry primitive, and a geometry primitive should return the quantity
154
+ // it claims to return. Any one-time recalibration needed to keep a SPECIFIC
155
+ // downstream constant's effective value close to its pre-Task-15 tuning belongs
156
+ // at that constant, documented as a deliberate retune — see `FIT_TOL_FRAC`
157
+ // (segment.js) for the one this codebase actually carries.
158
+ //
159
+ // HISTORY, kept because the mistake is instructive: an earlier version of this
160
+ // function measured `diagonal` as a bounding-box extent projected onto `axes`
161
+ // (the PCA basis below) instead of trace. That was a real improvement over a
162
+ // plain world-axis bbox diagonal for an ASYMMETRIC part (fixed a genuine ~35%
163
+ // inflation under rotation on a 40x30x16 filleted box), but for a shape whose
164
+ // three extents are close to equal — a cube or near-cube, an ORDINARY part, not
165
+ // a rare one — PCA's eigenvalues are (near-)degenerate, its eigenvectors within
166
+ // that eigenspace are numerically arbitrary, and a cube's bounding-box diagonal
167
+ // genuinely DOES differ depending on which specific direction you measure along
168
+ // (`s` along a face normal, `s*sqrt(3)` along a body diagonal) even though the
169
+ // covariance — equal eigenvalues — cannot distinguish those directions at all.
170
+ // Measured directly: an exact 20mm cube swung 34.64mm -> 57.03mm (65% relative)
171
+ // across six rotations; even a barely-asymmetric 20x20x20.5 near-cube swung
172
+ // 28%. A scale introduced specifically to make tolerances rotation-invariant,
173
+ // which is itself orientation-dependent on cubes and blocks, is worse than the
174
+ // world-axis version it replaced — it reads as safe. Trace has no such failure
175
+ // mode: it sums squared distances from the centroid, which does not care what
176
+ // direction anything points in.
177
+ //
178
+ // `axes`: still PCA (`jacobiEigen` on the covariance), still used for
179
+ // DIRECTION-dependent work only — segment.js's seed order reads face normals in
180
+ // world XYZ to decide which facets seed together, quantizing in this intrinsic
181
+ // basis instead moves the quantization grid WITH the geometry instead of
182
+ // leaving it nailed to the world's axes. Eigenvector SIGN is ambiguous
183
+ // (jacobiEigen doesn't canonicalize it) and axis ORDER can only be trusted where
184
+ // eigenvalues are well separated — bucketing stability survives a sign flip (it
185
+ // only permutes bucket labels) and a part's three extents being separated is
186
+ // what makes axis order stable under rotation. The degeneracy problem above is
187
+ // specifically an AXES problem, not (any longer) a SCALE problem: `axes` keeps
188
+ // its documented near-cubic/near-cylindrical caveat — an arbitrary, rotation-
189
+ // dependent basis on a cube — for any caller that reads actual directions out
190
+ // of it (segment.js's bucketing does); `diagonal` no longer inherits it.
191
+ export function intrinsicFrame(verts) {
192
+ const n = verts.length / 3;
193
+ const c = [0, 0, 0];
194
+ for (let i = 0; i < n; i++) { c[0] += verts[i*3]; c[1] += verts[i*3+1]; c[2] += verts[i*3+2]; }
195
+ c[0] /= n; c[1] /= n; c[2] /= n;
196
+ const cov = [[0,0,0],[0,0,0],[0,0,0]];
197
+ let trace = 0;
198
+ for (let i = 0; i < n; i++) {
199
+ const x = verts[i*3]-c[0], y = verts[i*3+1]-c[1], z = verts[i*3+2]-c[2];
200
+ cov[0][0] += x*x; cov[0][1] += x*y; cov[0][2] += x*z;
201
+ cov[1][1] += y*y; cov[1][2] += y*z; cov[2][2] += z*z;
202
+ trace += x*x + y*y + z*z;
203
+ }
204
+ cov[1][0] = cov[0][1]; cov[2][0] = cov[0][2]; cov[2][1] = cov[1][2];
205
+ const { vectors: axes } = jacobiEigen(cov);
206
+ return { axes, diagonal: 2 * Math.sqrt(trace / n) };
207
+ }
208
+
209
+ // Just the diagonal, for callers (surface-graph.js) that don't also need the axes.
210
+ export function intrinsicScale(verts) {
211
+ return intrinsicFrame(verts).diagonal;
212
+ }
213
+
214
+ // Dense Gaussian elimination with partial pivoting. n is 3 or 4 here, so the naive
215
+ // implementation is the right one; returns null on a singular system rather than
216
+ // producing Infinities that would look like a successful fit.
217
+ function solve(A, b) {
218
+ const n = b.length, M = A.map((row, i) => [...row, b[i]]);
219
+ for (let col = 0; col < n; col++) {
220
+ let piv = col;
221
+ for (let r = col + 1; r < n; r++) if (Math.abs(M[r][col]) > Math.abs(M[piv][col])) piv = r;
222
+ if (Math.abs(M[piv][col]) < 1e-12) return null;
223
+ [M[col], M[piv]] = [M[piv], M[col]];
224
+ for (let r = 0; r < n; r++) {
225
+ if (r === col) continue;
226
+ const f = M[r][col] / M[col][col];
227
+ for (let c = col; c <= n; c++) M[r][c] -= f * M[col][c];
228
+ }
229
+ }
230
+ return M.map((row, i) => row[n] / row[i]);
231
+ }
232
+
233
+ // Relative (not absolute) threshold for "this eigenvalue is effectively zero".
234
+ // These eigenvalues are raw sums of squared deviations/components over however
235
+ // many points or normals were handed in — an unnormalised quantity whose scale
236
+ // grows with the input count and with the size of the geometry in mm. An
237
+ // absolute cutoff would be tuned for one input and wrong for the next; comparing
238
+ // each eigenvalue against the largest one in the SAME decomposition is scale-free
239
+ // and works whether the caller passed 8 points or 8000.
240
+ const ZERO_EIGEN_REL = 1e-6;
241
+
242
+ export function fitPlane(pts) {
243
+ if (pts.length < MIN_PTS.plane) return null;
244
+ const c = mean(pts);
245
+ const cov = [[0,0,0],[0,0,0],[0,0,0]];
246
+ for (const p of pts) {
247
+ const d = sub(p, c);
248
+ for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) cov[i][j] += d[i] * d[j];
249
+ }
250
+ // The smallest-eigenvalue eigenvector of the covariance is the direction of least
251
+ // spread — the plane normal. Its eigenvalue is the summed squared deviation.
252
+ const { values, vectors } = jacobiEigen(cov);
253
+ // Collinear (or coincident) points make the covariance rank <= 1: the null
254
+ // space is 2-D or 3-D, so the SECOND-smallest eigenvalue is also ~0 and
255
+ // whichever vector the eigensolver happens to land on in that null space gets
256
+ // reported as "the" normal — with a false rms of ~0, the worst possible
257
+ // combination (garbage parameters paired with a claim of a perfect fit).
258
+ // A genuine plane's points span a real 2-D spread, so its SECOND eigenvalue is
259
+ // NOT negligible next to the largest one; only the smallest (the true normal
260
+ // direction) is. Checking values[1] here (not values[0], which is expected to
261
+ // be small for any good planar fit) is what catches the degenerate rank-1 case
262
+ // without rejecting legitimate flat data. Coincident points are the further
263
+ // degenerate case where even the LARGEST eigenvalue is ~0 (no spread at all in
264
+ // any direction) — guard that first, since "values[1] < values[2] * REL" is
265
+ // vacuously false when values[2] itself is 0 (0 is not < 0) and would
266
+ // otherwise let three identical points through as a "perfect" plane fit.
267
+ if (!(values[2] > 0) || values[1] < values[2] * ZERO_EIGEN_REL) return null;
268
+ const normal = unit(vectors[0]);
269
+ if (normal[0] === 0 && normal[1] === 0 && normal[2] === 0) return null;
270
+ const offset = dot(normal, c);
271
+ const fit = { type: "plane", normal, offset };
272
+ return { ...fit, ...errors(pts.map((p) => deviationOf(fit, p))) };
273
+ }
274
+
275
+ export function fitSphere(pts) {
276
+ if (pts.length < MIN_PTS.sphere) return null;
277
+ // Algebraic form: |p|^2 = 2c·p + k, linear in (c, k). Four unknowns, one row per
278
+ // point, solved through the 4x4 normal equations.
279
+ const A = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]], b = [0,0,0,0];
280
+ for (const p of pts) {
281
+ const row = [2*p[0], 2*p[1], 2*p[2], 1], rhs = dot(p, p);
282
+ for (let i = 0; i < 4; i++) { for (let j = 0; j < 4; j++) A[i][j] += row[i]*row[j]; b[i] += row[i]*rhs; }
283
+ }
284
+ const x = solve(A, b);
285
+ if (!x) return null;
286
+ const center = [x[0], x[1], x[2]];
287
+ const r2 = x[3] + dot(center, center);
288
+ if (!(r2 > 0)) return null;
289
+ const radius = Math.sqrt(r2);
290
+ const fit = { type: "sphere", center, radius };
291
+ return { ...fit, ...errors(pts.map((p) => deviationOf(fit, p))) };
292
+ }
293
+
294
+ // 2D algebraic circle fit — the planar twin of fitSphere, used by the cylinder and
295
+ // torus fits after they project into a plane perpendicular to their axis.
296
+ function fitCircle2D(uv) {
297
+ if (uv.length < MIN_PTS.circle) return null;
298
+ const A = [[0,0,0],[0,0,0],[0,0,0]], b = [0,0,0];
299
+ for (const [u, v] of uv) {
300
+ const row = [2*u, 2*v, 1], rhs = u*u + v*v;
301
+ for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) A[i][j] += row[i]*row[j]; b[i] += row[i]*rhs; }
302
+ }
303
+ const x = solve(A, b);
304
+ if (!x) return null;
305
+ const r2 = x[2] + x[0]*x[0] + x[1]*x[1];
306
+ if (!(r2 > 0)) return null;
307
+ return { cu: x[0], cv: x[1], radius: Math.sqrt(r2) };
308
+ }
309
+
310
+ // An orthonormal basis with `w` as its third axis. Picking the seed axis as the one
311
+ // `w` is LEAST aligned with keeps the cross product well-conditioned.
312
+ function basis(w) {
313
+ const seed = Math.abs(w[0]) < 0.9 ? [1,0,0] : [0,1,0];
314
+ const u = unit(cross(w, seed));
315
+ return [u, cross(w, u), w];
316
+ }
317
+
318
+ // Recover a RULED SURFACE's (a cylinder's) axis from its normal field's
319
+ // covariance. Every normal of a cylinder is exactly perpendicular to the axis,
320
+ // so the axis component of the normal has EXACTLY ZERO variance — an algebraic
321
+ // fact, not a consequence of sampling a full sweep, so it holds at any arc
322
+ // width down to a sliver (verified to 0.5°) and survives real tessellation
323
+ // facet noise (a cylinder's facet normals still average to exactly
324
+ // perpendicular-to-axis, since faceting only perturbs direction WITHIN that
325
+ // perpendicular plane). The axis is therefore always the smallest eigenvalue's
326
+ // eigenvector, unconditionally — no fallback needed, because this function is
327
+ // only ever called for a ruled surface. (A torus does NOT have this property —
328
+ // its normal is only perpendicular to the axis at the crown/root of the tube —
329
+ // so fitTorus recovers its axis a different way entirely: see the comment
330
+ // there. Two earlier fix rounds tried to extend this same normals-only
331
+ // covariance trick to the torus case via gap-comparison and half-trace rules;
332
+ // both were provably wrong on some combination of partial main-sweep and
333
+ // partial tube-sweep coverage, which is why fitTorus no longer calls this.)
334
+ function ruledSurfaceAxis(normals) {
335
+ const cov = [[0,0,0],[0,0,0],[0,0,0]];
336
+ for (const n of normals) for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) cov[i][j] += n[i]*n[j];
337
+ return unit(jacobiEigen(cov).vectors[0]);
338
+ }
339
+
340
+ export function fitCylinder(pts, normals) {
341
+ if (pts.length < 6 || !normals || normals.length !== pts.length) return null;
342
+ // Every normal of a cylinder is perpendicular to its axis, so the normals span a
343
+ // plane whose own normal IS the axis. Recovering direction from the normal field
344
+ // rather than from the points is what makes this robust on a partial arc, where
345
+ // the points alone barely constrain it.
346
+ const direction = ruledSurfaceAxis(normals);
347
+ const [u, v] = basis(direction);
348
+ const c = mean(pts);
349
+ const circle = fitCircle2D(pts.map((p) => { const d = sub(p, c); return [dot(d, u), dot(d, v)]; }));
350
+ if (!circle) return null;
351
+ const origin = [
352
+ c[0] + circle.cu*u[0] + circle.cv*v[0],
353
+ c[1] + circle.cu*u[1] + circle.cv*v[1],
354
+ c[2] + circle.cu*u[2] + circle.cv*v[2],
355
+ ];
356
+ const axials = pts.map((p) => dot(sub(p, origin), direction));
357
+ const fit = { type: "cylinder", axis: { origin, direction }, radius: circle.radius,
358
+ extent: [Math.min(...axials), Math.max(...axials)] };
359
+ return { ...fit, ...errors(pts.map((p) => deviationOf(fit, p))) };
360
+ }
361
+
362
+ export function fitCone(pts, normals) {
363
+ if (pts.length < 6 || !normals || normals.length !== pts.length) return null;
364
+ // On a cone of half-angle a, every outward normal satisfies n·axis = -sin(a) — a
365
+ // constant. So the normals lie on a PLANE in normal space, and fitting that plane
366
+ // gives the axis (its normal) and the half-angle (its offset) in one step.
367
+ const pf = fitPlane(normals);
368
+ if (!pf) return null;
369
+ const direction = pf.normal;
370
+ const sinA = -pf.offset;
371
+ const halfAngle = Math.asin(Math.max(-1, Math.min(1, Math.abs(sinA))));
372
+ if (!(halfAngle > 1e-4) || halfAngle > Math.PI/2 - 1e-4) return null; // a plane or a cylinder, not a cone
373
+ // Apex: the point minimising distance to every surface normal's plane. Each point
374
+ // contributes n·x = n·p, and the least-squares intersection of those planes is the
375
+ // apex, since every cone normal's plane passes through it.
376
+ const A = [[0,0,0],[0,0,0],[0,0,0]], b = [0,0,0];
377
+ for (let i = 0; i < pts.length; i++) {
378
+ const n = normals[i], rhs = dot(n, pts[i]);
379
+ for (let r = 0; r < 3; r++) { for (let cc = 0; cc < 3; cc++) A[r][cc] += n[r]*n[cc]; b[r] += n[r]*rhs; }
380
+ }
381
+ const apex = solve(A, b);
382
+ if (!apex) return null;
383
+ const axis = dot(sub(pts[0], apex), direction) < 0 ? scale(direction, -1) : direction;
384
+ const fit = { type: "cone", apex, direction: axis, halfAngle };
385
+ return { ...fit, ...errors(pts.map((p) => deviationOf(fit, p))) };
386
+ }
387
+
388
+ // Search the minor radius r that makes {p_i - r*n_i} as coplanar as possible
389
+ // (see fitTorus for why that particular objective picks out the true r), using
390
+ // a golden-section minimisation. FIXED iteration counts throughout — no
391
+ // data-dependent stopping criterion — because this module's whole contract is
392
+ // pure, reproducible fits (a later stage memoizes by content hash, so a search
393
+ // that iterated a variable number of times depending on floating-point noise
394
+ // would poison that cache even for byte-identical input).
395
+ //
396
+ // The objective, `fitPlane({p_i - r*n_i}).rms`, is not guaranteed unimodal
397
+ // over its whole domain (it can wobble before settling near the true r), so a
398
+ // blind golden-section search over the full range risks converging to a local
399
+ // dip instead of the global minimum. A coarse, evenly-spaced scan finds a
400
+ // bracket around whichever sample is smallest — cheap, and robust to a
401
+ // non-unimodal objective, because it evaluates the entire range rather than
402
+ // trusting a local descent — and golden-section then refines within that
403
+ // bracket, where the objective is well-behaved (essentially quadratic near a
404
+ // true minimum for exact or near-exact data).
405
+ //
406
+ // SIGNED r, searched over BOTH signs (round-2 review, controller ruling — this
407
+ // plan's Task 6 flagged it after finding `fitTorus` silently mis-measured a
408
+ // concave-corner fillet). The coplanarity identity `t = p - r*n` assumes the
409
+ // outward normal `n` points AWAY from the tube cross-section's own centre —
410
+ // true for a torus's convex half (a full closed torus; a fillet rounding a
411
+ // CONVEX corner, e.g. a shaft's top rim). On the CONCAVE half (a fillet
412
+ // rounding an inside corner — a pocket floor, a boss's base) the true outward
413
+ // normal points TOWARD that centre instead, and the correct r for the SAME
414
+ // identity to collapse onto the main circle is NEGATIVE. A positive-only
415
+ // search cannot represent that r at all: it was converging on whichever
416
+ // spurious positive value happened to look locally best and returning it with
417
+ // no null and no elevated residual — a silent wrong answer, not a failure.
418
+ // Searching both signs and keeping the globally better one fixes this without
419
+ // changing anything about the convex case, which already found its optimum on
420
+ // the positive side and still does.
421
+ const TORUS_R_COARSE_STEPS = 48; // scan resolution: bracket-finding, not final precision
422
+ const TORUS_R_REFINE_ITERS = 40; // golden-section iterations inside the bracket
423
+ // A thin band around r=0 is excluded from the search on both sides: at r=0 the
424
+ // "derived" set is just the input points themselves, whose planarity reflects
425
+ // the raw patch's own flatness rather than anything about a candidate radius —
426
+ // it can look spuriously good by coincidence and pull the coarse scan's
427
+ // bracket onto a meaningless near-zero radius. A physical tube's true minor
428
+ // radius is never a sliver of the whole patch's own bounding-box diagonal, so
429
+ // excluding a sliver of `rMax` around zero from both signs costs no real
430
+ // coverage.
431
+ const TORUS_R_DEADZONE_FRAC = 1 / (TORUS_R_COARSE_STEPS * 4);
432
+
433
+ function torusMinorRadius(pts, normals) {
434
+ // No candidate minor radius can exceed half the bounding-box diagonal: every
435
+ // point of {p_i - r*n_i} would already have to reach outside the box the
436
+ // input itself lives in. intrinsicScale() (this file, above), not a plain
437
+ // world-axis min/max — this is only a loose search bound, not a decision
438
+ // threshold, so a world-frame diagonal was never going to change which radius
439
+ // wins, but the convention should not have a second, differently-computed copy
440
+ // sitting a few hundred lines from the one that matters.
441
+ const rMax = intrinsicScale(pts.flat()) / 2;
442
+ if (!(rMax > 0)) return 0;
443
+ const planarity = (r) => {
444
+ const derived = pts.map((p, i) => sub(p, scale(normals[i], r)));
445
+ const pf = fitPlane(derived);
446
+ return pf ? pf.rms : Infinity; // a degenerate derived set is an infinitely bad r, not a crash
447
+ };
448
+ const deadzone = rMax * TORUS_R_DEADZONE_FRAC;
449
+ const GOLDEN = (Math.sqrt(5) - 1) / 2;
450
+
451
+ // Coarse-scan-then-golden-section-refine over the magnitude, on one given
452
+ // sign, exactly as the original single-sided search did — just parameterised
453
+ // on `sign` so it runs identically for +1 and -1 below.
454
+ const searchSide = (sign) => {
455
+ const samples = [];
456
+ for (let i = 0; i < TORUS_R_COARSE_STEPS; i++) {
457
+ samples.push(deadzone + ((rMax - deadzone) * (i + 1)) / TORUS_R_COARSE_STEPS);
458
+ }
459
+ let bestI = 0, bestVal = Infinity;
460
+ for (let i = 0; i < TORUS_R_COARSE_STEPS; i++) {
461
+ const val = planarity(sign * samples[i]);
462
+ if (val < bestVal) { bestVal = val; bestI = i; }
463
+ }
464
+ let a = bestI > 0 ? samples[bestI - 1] : deadzone;
465
+ let b = bestI < TORUS_R_COARSE_STEPS - 1 ? samples[bestI + 1] : Math.min(rMax, samples[bestI] * 1.5);
466
+ let c = b - GOLDEN*(b-a), d = a + GOLDEN*(b-a);
467
+ let fc = planarity(sign*c), fd = planarity(sign*d);
468
+ for (let k = 0; k < TORUS_R_REFINE_ITERS; k++) {
469
+ if (fc < fd) { b = d; d = c; fd = fc; c = b - GOLDEN*(b-a); fc = planarity(sign*c); }
470
+ else { a = c; c = d; fc = fd; d = a + GOLDEN*(b-a); fd = planarity(sign*d); }
471
+ }
472
+ const r = sign * (a + b) / 2;
473
+ return { r, val: planarity(r) };
474
+ };
475
+
476
+ const positive = searchSide(1);
477
+ const negative = searchSide(-1);
478
+ return positive.val <= negative.val ? positive.r : negative.r;
479
+ }
480
+
481
+ export function fitTorus(pts, normals) {
482
+ if (pts.length < 8 || !normals || normals.length !== pts.length) return null;
483
+ // A torus's tube cross-section is ITSELF a circle, so the outward normal at
484
+ // any surface point p points directly away from that cross-section's own
485
+ // centre: for the TRUE minor radius r, `t = p - r*n` collapses every point
486
+ // back onto the MAIN circle (radius R, centred on the axis) — regardless of
487
+ // how little of the main sweep OR the tube is covered. Equivalently: `t` is
488
+ // COPLANAR (all points lie in the plane through the centre, perpendicular to
489
+ // the axis) exactly when r is correct, and increasingly scattered out of any
490
+ // plane the more r is wrong in either direction. That gives a single scalar
491
+ // objective — the planarity residual of {p_i - r*n_i} — whose minimiser IS
492
+ // the minor radius, and whose minimising plane's normal IS the axis.
493
+ //
494
+ // This replaces two earlier, and both wrong, attempts to recover the axis
495
+ // from the NORMAL FIELD ALONE (a gap-comparison rule, then a half-trace
496
+ // rule): each was exact in one of {full main sweep, full tube sweep} and
497
+ // silently wrong in the other, because normals alone don't carry a single
498
+ // invariant that survives an arbitrary combination of partial main-sweep and
499
+ // partial tube-sweep coverage. Using POSITIONS AND NORMALS TOGETHER removes
500
+ // the ambiguity entirely: coplanarity of the derived point set is a property
501
+ // of the actual 3-D shape, not of how the sweep happened to be sampled.
502
+ //
503
+ // `torusMinorRadius` now returns a SIGNED r (see its own comment): positive
504
+ // for the convex-corner/full-torus case its formula was originally derived
505
+ // for, negative for the concave-corner case, where the true outward normal
506
+ // points toward the tube's own centre rather than away from it. The signed
507
+ // value is what the `derived = p - r*n` identity actually needs to collapse
508
+ // onto the main circle in EITHER case; `minorRadius` itself is reported
509
+ // positive, as every other radius in this module is, with the sign carried
510
+ // separately in `concaveTube` — cheap to keep (the sign is already computed;
511
+ // discarding it would just be throwing away a fact the fit already knows),
512
+ // and it independently corroborates the `curvature` surface-graph.js derives
513
+ // from the mesh's own face normals rather than from this fit's parameters.
514
+ const signedR = torusMinorRadius(pts, normals);
515
+ const minorRadius = Math.abs(signedR);
516
+ if (!(minorRadius > 0)) return null;
517
+ const derived = pts.map((p, i) => sub(p, scale(normals[i], signedR)));
518
+ const pf = fitPlane(derived);
519
+ if (!pf) return null;
520
+ const axis = pf.normal;
521
+ const [u, v] = basis(axis);
522
+ // `pf.normal`/`pf.offset` give the axis and the plane's distance from the
523
+ // origin; the plane's IN-PLANE position (the centre's u,v coordinates) still
524
+ // needs a 2-D circle fit, same as fitCylinder's origin recovery.
525
+ const uv = derived.map((t) => [dot(t, u), dot(t, v)]);
526
+ const circle = fitCircle2D(uv);
527
+ if (!circle || !(circle.radius > minorRadius)) return null; // degenerate / self-intersecting
528
+ const center = [
529
+ circle.cu*u[0] + circle.cv*v[0] + pf.offset*axis[0],
530
+ circle.cu*u[1] + circle.cv*v[1] + pf.offset*axis[1],
531
+ circle.cu*u[2] + circle.cv*v[2] + pf.offset*axis[2],
532
+ ];
533
+ const fit = { type: "torus", center, axis, majorRadius: circle.radius, minorRadius, concaveTube: signedR < 0 };
534
+ return { ...fit, ...errors(pts.map((p) => deviationOf(fit, p))) };
535
+ }
@@ -0,0 +1,91 @@
1
+ // The `suggestion` layer: a proposed reconstruction in partforge terms.
2
+ //
3
+ // It is physically separate from the facts and labelled as unverified because it is a
4
+ // different KIND of claim. The facts layer says "there is a 5.3mm cylinder here with
5
+ // concave arcs to two parallel planes" — a measurement. The suggestion says "so build a
6
+ // box and cut a hole" — an interpretation, and one the agent is free to reject.
7
+ //
8
+ // Its step order is not chosen by a heuristic. It is acceptance order, which is the
9
+ // order the candidates actually reduced the error in — the payoff of propose-then-
10
+ // confirm (spec §2.8). A build order arrived at this way is one we can defend.
11
+ //
12
+ // Note on what's absent: this layer proposes dimensions (via snap.js's `snapValue`) but
13
+ // deliberately does not propose a design GRID. `inferGrid` (snap.js) no longer offers a
14
+ // 0.1mm candidate — it's provably degenerate at SNAP_TOL_FRAC, matching any one-decimal
15
+ // value whether or not the part was actually designed on a grid. A part genuinely
16
+ // modelled on real 0.1mm grid therefore reports no grid rather than a wrong one, and
17
+ // this layer doesn't paper over that by inventing one from bounds or dimensions alone.
18
+ //
19
+ // Pure leaf. See spec §3.2.
20
+
21
+ import { snapValue, SNAP_TOL_FRAC } from "./snap.js";
22
+
23
+ const DISCLAIMER =
24
+ "Proposed reconstruction, not measurement. The facts above are authoritative; " +
25
+ "this is one way to rebuild them and may be wrong about intent.";
26
+
27
+ // A relative-tolerance "same value" test, on the same band snap.js snaps with (never
28
+ // an exact float ==) — used below to tell "the same dimension seen again" apart from
29
+ // "a different dimension that happens to want the same name".
30
+ const ABS_FLOOR = 1e-4;
31
+ const near = (a, b) => Math.abs(a - b) <= Math.max(Math.abs(b) * SNAP_TOL_FRAC, ABS_FLOOR);
32
+
33
+ export function buildHints(accepted, patterns, bounds) {
34
+ const params = [];
35
+ // name -> raw values already recorded under it, so a same-named, different-valued
36
+ // collision can be told apart from the same value turning up twice.
37
+ const valuesByName = new Map();
38
+ const addParam = (name, value, from) => {
39
+ if (!Number.isFinite(value)) return;
40
+ const existing = valuesByName.get(name);
41
+ if (existing) {
42
+ // The same value under this name already: not a new parameter (e.g. two
43
+ // candidates that share a dimension), so skip it rather than duplicate it.
44
+ if (existing.some((v) => near(v, value))) return;
45
+ // A DIFFERENT value collided with an already-used name — e.g. two
46
+ // distinct-diameter, non-pattern holes both falling back to the same generic
47
+ // paramName. Suffix rather than drop it: a parameter silently missing from the
48
+ // suggestion is worse than one with an ugly name (fix round 1, MINOR).
49
+ existing.push(value);
50
+ params.push({ name: `${name}_${existing.length}`, value: snapValue(value)?.to ?? value, from });
51
+ return;
52
+ }
53
+ valuesByName.set(name, [value]);
54
+ params.push({ name, value: snapValue(value)?.to ?? value, from });
55
+ };
56
+
57
+ const size = [0, 1, 2].map((i) => bounds.max[i] - bounds.min[i]);
58
+ addParam("width", size[0], "bounds.size[0]");
59
+ addParam("depth", size[1], "bounds.size[1]");
60
+ addParam("height", size[2], "bounds.size[2]");
61
+
62
+ const byKey = new Map();
63
+ for (const p of patterns) for (const m of p.members) byKey.set(m, p.id);
64
+
65
+ const steps = accepted.map((a) => {
66
+ const c = a.candidate;
67
+ const patternId = byKey.get(c.featureKey) ?? null;
68
+ // A candidate covered by a pattern names the pattern instead of repeating itself,
69
+ // which is the whole reason patterns.js runs: four steps become one.
70
+ if (c.dimension) addParam(c.paramName ?? c.key.split(":")[0], c.dimension, patternId ?? c.key);
71
+ return {
72
+ op: c.op === "cut" ? "cut" : c.hintOp ?? "union",
73
+ explains: c.explains ?? [],
74
+ pattern: patternId,
75
+ score: Math.round(a.gain * 1000) / 1000,
76
+ args: c.hintArgs ?? {},
77
+ };
78
+ });
79
+
80
+ // Steps a pattern already covers collapse into the first of their group: emitting all
81
+ // four members plus the pattern would tell the agent to cut the same holes twice.
82
+ const emitted = new Set();
83
+ const collapsed = steps.filter((s) => {
84
+ if (!s.pattern) return true;
85
+ if (emitted.has(s.pattern)) return false;
86
+ emitted.add(s.pattern);
87
+ return true;
88
+ });
89
+
90
+ return { disclaimer: DISCLAIMER, params, steps: collapsed };
91
+ }
@@ -0,0 +1,19 @@
1
+ // Report array caps. A plain-data module with NO IMPORTS AT ALL, deliberately.
2
+ //
3
+ // partforge-cloud's sandbox boundary treats everything crossing it as
4
+ // attacker-controlled and whitelists fields, types, AND sizes (protocol.js's
5
+ // sanitizeResult). Phase B needs these exact numbers on the far side of that boundary,
6
+ // and it must be able to read them without dragging the oracle's import graph into a
7
+ // browser bundle. Same idiom as cloud's own src/chat/profileLimits.js, and for the same
8
+ // reason.
9
+ //
10
+ // These are CEILINGS, not targets. A report that hits one is not broken; it says so
11
+ // through its `truncated` block and carries on.
12
+ export const DESCRIBE_LIMITS = {
13
+ MAX_SURFACES: 200,
14
+ MAX_EDGES: 400,
15
+ MAX_FEATURES: 120,
16
+ MAX_PATTERNS: 40,
17
+ MAX_RESIDUAL_REGIONS: 20,
18
+ MAX_SUGGESTION_STEPS: 60,
19
+ };