partforge 0.77.0 → 0.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +125 -1
- package/docs/AUTHORING-PARTS.md +165 -1
- package/docs/ERROR-PATTERNS.md +30 -0
- package/package.json +8 -1
- package/src/framework/jobs.js +66 -9
- package/src/framework/oracle/describe/accept.js +188 -0
- package/src/framework/oracle/describe/features/dressups.js +173 -0
- package/src/framework/oracle/describe/features/holes.js +129 -0
- package/src/framework/oracle/describe/features/prismatic.js +454 -0
- package/src/framework/oracle/describe/features/sweeps.js +233 -0
- package/src/framework/oracle/describe/fit.js +535 -0
- package/src/framework/oracle/describe/hints.js +91 -0
- package/src/framework/oracle/describe/limits.js +19 -0
- package/src/framework/oracle/describe/patterns.js +494 -0
- package/src/framework/oracle/describe/ransac.js +391 -0
- package/src/framework/oracle/describe/report.js +217 -0
- package/src/framework/oracle/describe/segment.js +498 -0
- package/src/framework/oracle/describe/snap.js +83 -0
- package/src/framework/oracle/describe/surface-graph.js +396 -0
- package/src/framework/oracle/describe/topology.js +121 -0
- package/src/framework/oracle/describe.js +538 -0
- package/src/oracle.js +27 -0
- package/src/testing.js +4 -12
- package/types/oracle.d.ts +27 -0
- package/types/testing.d.ts +178 -0
|
@@ -0,0 +1,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
|
+
};
|