partforge 0.76.0 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +150 -6
- package/docs/AUTHORING-PARTS.md +196 -0
- package/docs/ERROR-PATTERNS.md +42 -0
- package/package.json +1 -1
- package/src/framework/jobs.js +31 -0
- package/src/framework/lint/index.js +37 -3
- package/src/framework/lint/rules-schema.js +39 -11
- package/src/framework/lint/rules-source.js +108 -0
- package/src/framework/lint/source-scan.js +333 -0
- package/src/framework/oracle/describe/accept.js +188 -0
- package/src/framework/oracle/describe/features/dressups.js +173 -0
- package/src/framework/oracle/describe/features/holes.js +129 -0
- package/src/framework/oracle/describe/features/prismatic.js +454 -0
- package/src/framework/oracle/describe/features/sweeps.js +233 -0
- package/src/framework/oracle/describe/fit.js +535 -0
- package/src/framework/oracle/describe/hints.js +91 -0
- package/src/framework/oracle/describe/limits.js +19 -0
- package/src/framework/oracle/describe/patterns.js +494 -0
- package/src/framework/oracle/describe/ransac.js +391 -0
- package/src/framework/oracle/describe/report.js +217 -0
- package/src/framework/oracle/describe/segment.js +498 -0
- package/src/framework/oracle/describe/snap.js +83 -0
- package/src/framework/oracle/describe/surface-graph.js +396 -0
- package/src/framework/oracle/describe/topology.js +121 -0
- package/src/framework/oracle/describe.js +538 -0
- package/src/lint.js +5 -0
- package/src/testing.js +5 -0
- package/types/lint.d.ts +44 -2
- package/types/testing.d.ts +178 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
// Repetition and symmetry over the feature list.
|
|
2
|
+
//
|
|
3
|
+
// This is the stage that turns a feature DUMP into design INTENT, and it is worth more
|
|
4
|
+
// to the consuming agent than marginal recognition accuracy is. Four holes reported
|
|
5
|
+
// individually invite four hard-coded positions; the same four reported as a 2x2 grid
|
|
6
|
+
// on a 50x30 pitch invite two parameters. A detected mirror plane tells the agent the
|
|
7
|
+
// part wants a symmetric parameterisation. Neither is recoverable from the feature list
|
|
8
|
+
// once it has been written out flat, which is why it happens here and not in the model.
|
|
9
|
+
//
|
|
10
|
+
// Grouping is by feature SIGNATURE (type plus rounded principal dimension) before any
|
|
11
|
+
// geometry is considered: two holes of different diameters are never one pattern no
|
|
12
|
+
// matter how neatly they line up, and testing that first keeps the position search
|
|
13
|
+
// small.
|
|
14
|
+
//
|
|
15
|
+
// Pure leaf. See spec §2.6.
|
|
16
|
+
|
|
17
|
+
const TOL_FRAC = 1e-3; // spacing agreement, as a fraction of the bbox diagonal
|
|
18
|
+
const MIN_MEMBERS = 3; // below this a "pattern" is just two features
|
|
19
|
+
const MIN_SYMMETRY_MEMBERS = 4; // a mirror plane over 1-2 points restates a midpoint, not a finding
|
|
20
|
+
const SYMMETRY_EVIDENCE_MIN = 2; // confirmed mirror PAIRS a candidate needs, including the one that proposed it
|
|
21
|
+
const SYMMETRY_PREFILTER_FACTOR = 5; // how much wider the proposer-count pre-filter bucket is than the exact one
|
|
22
|
+
const round3 = (v) => Math.round(v * 1000) / 1000;
|
|
23
|
+
|
|
24
|
+
const posOf = (f) => f.axis?.origin ?? f.center ?? null;
|
|
25
|
+
const signature = (f) =>
|
|
26
|
+
`${f.type}:${round3(f.diameter ?? f.radius ?? f.width ?? f.depth ?? 0)}`;
|
|
27
|
+
|
|
28
|
+
const sub = (a, b) => [a[0]-b[0], a[1]-b[1], a[2]-b[2]];
|
|
29
|
+
const len = (a) => Math.hypot(a[0], a[1], a[2]);
|
|
30
|
+
const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
|
|
31
|
+
|
|
32
|
+
// An orthonormal frame to measure repetition in. NOT the world axes (controller ruling
|
|
33
|
+
// R27): a 2x2 hole grid drilled into a plate sitting at an arbitrary orientation — which is
|
|
34
|
+
// every real part — has no relationship to world X/Y/Z, so bucketing world coordinates
|
|
35
|
+
// finds nothing. Holes in one pattern share a drill direction, so that direction is the
|
|
36
|
+
// natural third axis and the repetition lives in the plane perpendicular to it.
|
|
37
|
+
//
|
|
38
|
+
// Falls back to world axes only when no feature carries a direction at all, which keeps
|
|
39
|
+
// the axis-aligned fixtures behaving exactly as before.
|
|
40
|
+
function patternFrame(members) {
|
|
41
|
+
const dir = members.map((f) => f.axis?.direction).find(Boolean);
|
|
42
|
+
if (!dir) return [[1,0,0],[0,1,0],[0,0,1]];
|
|
43
|
+
const w = unit(dir);
|
|
44
|
+
const seed = Math.abs(w[0]) < 0.9 ? [1,0,0] : [0,1,0];
|
|
45
|
+
const u = unit(cross(w, seed));
|
|
46
|
+
return [u, cross(w, u), w];
|
|
47
|
+
}
|
|
48
|
+
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]];
|
|
49
|
+
const unit = (a) => { const n = len(a) || 1; return [a[0]/n, a[1]/n, a[2]/n]; };
|
|
50
|
+
// A position expressed in the frame — this is what every geometric test below reads,
|
|
51
|
+
// so all of them are orientation-invariant by construction rather than by inspection.
|
|
52
|
+
const inFrame = (frame, p) => frame.map((axis) => p[0]*axis[0] + p[1]*axis[1] + p[2]*axis[2]);
|
|
53
|
+
|
|
54
|
+
export function detectPatterns(features, bounds) {
|
|
55
|
+
const diag = len(sub(bounds.max, bounds.min));
|
|
56
|
+
const tol = diag * TOL_FRAC;
|
|
57
|
+
const patterns = [];
|
|
58
|
+
const groups = new Map();
|
|
59
|
+
for (const f of features) {
|
|
60
|
+
if (!posOf(f)) continue;
|
|
61
|
+
const k = signature(f);
|
|
62
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
63
|
+
groups.get(k).push(f);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const members of groups.values()) {
|
|
67
|
+
if (members.length < MIN_MEMBERS) continue;
|
|
68
|
+
const frame = patternFrame(members);
|
|
69
|
+
const pts = members.map((f) => inFrame(frame, posOf(f)));
|
|
70
|
+
|
|
71
|
+
// Grid: the positions factor into two independent spacings. Detected before linear
|
|
72
|
+
// so a 2x2 layout is not reported as two unrelated 2-member lines.
|
|
73
|
+
// Directions reported back out are rotated OUT of the frame, so a consumer never has
|
|
74
|
+
// to know the frame existed.
|
|
75
|
+
const outOfFrame = (v) => v && [0,1,2].reduce((acc, i) =>
|
|
76
|
+
[acc[0] + v[i]*frame[i][0], acc[1] + v[i]*frame[i][1], acc[2] + v[i]*frame[i][2]], [0,0,0]);
|
|
77
|
+
|
|
78
|
+
const grid = asGrid(members, pts, tol);
|
|
79
|
+
if (grid) { patterns.push({ id: `p${patterns.length}`, ...grid, axis: outOfFrame(grid.axis) }); continue; }
|
|
80
|
+
|
|
81
|
+
const linear = asLinear(members, pts, tol);
|
|
82
|
+
if (linear) { patterns.push({ id: `p${patterns.length}`, ...linear, axis: outOfFrame(linear.axis) }); continue; }
|
|
83
|
+
|
|
84
|
+
const circular = asCircular(members, pts, tol);
|
|
85
|
+
if (circular) patterns.push({ id: `p${patterns.length}`, ...circular, axis: outOfFrame(circular.axis) });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { patterns, symmetry: detectSymmetry(features, bounds, tol) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Two distinct coordinate values on each of two PERPENDICULAR axes, every
|
|
92
|
+
// combination present.
|
|
93
|
+
//
|
|
94
|
+
// The axes are NOT the frame's own u/v (unlike asLinear/asCircular below, which are
|
|
95
|
+
// isometry-invariant and so don't care which in-plane basis the frame happened to
|
|
96
|
+
// pick). `patternFrame` chooses u/v from an arbitrary seed vector perpendicular to
|
|
97
|
+
// the shared drill direction -- it fixes the plane the pattern lives in, not the
|
|
98
|
+
// rotation WITHIN that plane. A grid's own row/column directions can sit at any
|
|
99
|
+
// angle inside that plane relative to u/v, so bucketing coordinates against u/v
|
|
100
|
+
// directly only works by accident (exactly the axis-aligned case, which is why the
|
|
101
|
+
// bug the R27 regression test exists for passed every earlier axis-aligned fixture
|
|
102
|
+
// silently). Round 8 review: the brief's own reference `asGrid` bucketed along u/v
|
|
103
|
+
// and failed the rotated 2x2 fixture -- verified failing before this rewrite,
|
|
104
|
+
// `grid.pitch` came back undefined because the search fell through to `asCircular`
|
|
105
|
+
// (a rectangle's 4 corners are also equidistant from its centre, so that branch
|
|
106
|
+
// fires "successfully" on the wrong pattern type instead of erroring loudly).
|
|
107
|
+
//
|
|
108
|
+
// Fix: search for the grid's actual axes among the directions the DATA itself
|
|
109
|
+
// exhibits. Every pairwise offset between two members is a candidate row/column
|
|
110
|
+
// direction; there are only O(n^2) of them for a feature-count-sized n (not
|
|
111
|
+
// triangle-count), so trying each is cheap. The first candidate whose bucketing
|
|
112
|
+
// (it, and its single in-plane perpendicular) accounts for every point with two
|
|
113
|
+
// evenly spaced axes is the grid.
|
|
114
|
+
function asGrid(members, pts, tol) {
|
|
115
|
+
const w = uniqueSorted(pts.map((p) => p[2]), tol);
|
|
116
|
+
if (w.length > 1) return null; // members aren't coplanar perpendicular to the shared drill axis
|
|
117
|
+
|
|
118
|
+
const planar = pts.map((p) => [p[0], p[1]]);
|
|
119
|
+
const n = planar.length;
|
|
120
|
+
if (n < 4) return null; // a 2-axis grid needs at least 2x2
|
|
121
|
+
|
|
122
|
+
const candidates = [];
|
|
123
|
+
for (let i = 0; i < n; i++) {
|
|
124
|
+
for (let j = i + 1; j < n; j++) {
|
|
125
|
+
const dx = planar[j][0] - planar[i][0], dy = planar[j][1] - planar[i][1];
|
|
126
|
+
const m = Math.hypot(dx, dy);
|
|
127
|
+
if (m < tol) continue;
|
|
128
|
+
candidates.push([dx / m, dy / m]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const e1 of candidates) {
|
|
133
|
+
const e2 = [-e1[1], e1[0]]; // the plane's only perpendicular, up to sign
|
|
134
|
+
const a0 = uniqueSorted(planar.map((p) => p[0]*e1[0] + p[1]*e1[1]), tol);
|
|
135
|
+
const a1 = uniqueSorted(planar.map((p) => p[0]*e2[0] + p[1]*e2[1]), tol);
|
|
136
|
+
if (a0.length < 2 || a1.length < 2 || a0.length * a1.length !== n) continue;
|
|
137
|
+
const p0 = spacing(a0, tol), p1 = spacing(a1, tol);
|
|
138
|
+
if (p0 === null || p1 === null) continue;
|
|
139
|
+
// Canonical order (larger pitch first) so the reported shape doesn't depend on
|
|
140
|
+
// which pairwise offset happened to seed the search.
|
|
141
|
+
const [pitch, counts] = p0 >= p1 ? [[p0, p1], [a0.length, a1.length]] : [[p1, p0], [a1.length, a0.length]];
|
|
142
|
+
return {
|
|
143
|
+
type: "grid", members: members.map((m) => m.key),
|
|
144
|
+
counts, pitch, plane: null, axis: null, confidence: 1,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Collinear and evenly spaced.
|
|
151
|
+
function asLinear(members, pts, tol) {
|
|
152
|
+
if (pts.length < MIN_MEMBERS) return null;
|
|
153
|
+
const dir = sub(pts[1], pts[0]);
|
|
154
|
+
const dl = len(dir);
|
|
155
|
+
if (dl < tol) return null;
|
|
156
|
+
const u = [dir[0]/dl, dir[1]/dl, dir[2]/dl];
|
|
157
|
+
const ts = [];
|
|
158
|
+
for (const p of pts) {
|
|
159
|
+
const d = sub(p, pts[0]);
|
|
160
|
+
const t = d[0]*u[0] + d[1]*u[1] + d[2]*u[2];
|
|
161
|
+
if (len(sub(d, [t*u[0], t*u[1], t*u[2]])) > tol) return null; // off the line
|
|
162
|
+
ts.push(t);
|
|
163
|
+
}
|
|
164
|
+
ts.sort((a, b) => a - b);
|
|
165
|
+
const step = spacing(ts, tol);
|
|
166
|
+
if (step === null) return null;
|
|
167
|
+
return {
|
|
168
|
+
type: "linear", members: members.map((m) => m.key),
|
|
169
|
+
counts: [pts.length], pitch: [step], axis: u, plane: null, confidence: 1,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Equidistant from a common centre, evenly spaced in angle.
|
|
174
|
+
//
|
|
175
|
+
// Equal radius from the CENTROID is not sufficient on its own -- for n >= 4 it is
|
|
176
|
+
// satisfiable by clusters (e.g. three antipodal pairs of holes, each pair 8 degrees
|
|
177
|
+
// apart, the pairs 120 degrees apart: every point is still equidistant from the
|
|
178
|
+
// centroid by the 3-fold symmetry, but the layout is nothing like an evenly spaced
|
|
179
|
+
// bolt circle). Confirmed against exactly that construction before adding the
|
|
180
|
+
// angular check below: the brief's reference `asCircular`, which only tests radius,
|
|
181
|
+
// reported it as a clean 6-hole/60-degree pattern. For n == 3 this can't happen --
|
|
182
|
+
// three equal-magnitude vectors summing to zero (which is what "equidistant from
|
|
183
|
+
// their own centroid" forces) are necessarily 120 degrees apart -- so the gap is
|
|
184
|
+
// invisible on every fixture this small, exactly the kind of thing that passes a
|
|
185
|
+
// minimal suite silently.
|
|
186
|
+
function asCircular(members, pts, tol) {
|
|
187
|
+
if (pts.length < MIN_MEMBERS) return null;
|
|
188
|
+
const w = uniqueSorted(pts.map((p) => p[2]), tol);
|
|
189
|
+
if (w.length > 1) return null; // not coplanar perpendicular to the shared axis -- not a bolt circle
|
|
190
|
+
|
|
191
|
+
const c = pts.reduce((a, p) => [a[0]+p[0]/pts.length, a[1]+p[1]/pts.length, a[2]+p[2]/pts.length], [0,0,0]);
|
|
192
|
+
const radii = pts.map((p) => len(sub(p, c)));
|
|
193
|
+
const rm = radii.reduce((a, b) => a + b, 0) / radii.length;
|
|
194
|
+
if (rm < tol || Math.max(...radii.map((r) => Math.abs(r - rm))) > tol) return null;
|
|
195
|
+
|
|
196
|
+
const angles = pts.map((p) => Math.atan2(p[1] - c[1], p[0] - c[0])).sort((a, b) => a - b);
|
|
197
|
+
const gaps = angles.map((a, i) => (i === angles.length - 1 ? angles[0] + 2*Math.PI : angles[i+1]) - a);
|
|
198
|
+
const meanGap = (2 * Math.PI) / pts.length;
|
|
199
|
+
const angleTol = tol / rm; // linear tolerance, converted to angular via the mean radius
|
|
200
|
+
if (!gaps.every((g) => Math.abs(g - meanGap) <= angleTol)) return null;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
type: "circular", members: members.map((m) => m.key),
|
|
204
|
+
counts: [pts.length], pitch: [round3(360 / pts.length)],
|
|
205
|
+
// Frame-LOCAL, like asGrid and asLinear, so the caller's single `outOfFrame` step
|
|
206
|
+
// handles all three uniformly. A circular pattern's axis is the frame's own third
|
|
207
|
+
// axis by construction — the drill direction its members share is exactly what
|
|
208
|
+
// `patternFrame` built the frame around — so in frame coordinates it is [0,0,1].
|
|
209
|
+
// Returning a world-space direction here instead (say, from a member's own axis)
|
|
210
|
+
// would be silently inconsistent with its siblings and would double-transform the
|
|
211
|
+
// moment anyone routed it through the same step.
|
|
212
|
+
axis: [0, 0, 1], plane: null, confidence: 1,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Distinct values, merged within tol.
|
|
217
|
+
function uniqueSorted(values, tol) {
|
|
218
|
+
const s = [...values].sort((a, b) => a - b), out = [];
|
|
219
|
+
for (const v of s) if (!out.length || Math.abs(v - out[out.length - 1]) > tol) out.push(v);
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The common step of a sorted sequence, or null when the steps disagree.
|
|
224
|
+
function spacing(sorted, tol) {
|
|
225
|
+
if (sorted.length < 2) return null;
|
|
226
|
+
const steps = sorted.slice(1).map((v, i) => v - sorted[i]);
|
|
227
|
+
const mean = steps.reduce((a, b) => a + b, 0) / steps.length;
|
|
228
|
+
return steps.every((s) => Math.abs(s - mean) <= tol) ? mean : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Mirror symmetry, tested against candidate planes derived from the DATA rather
|
|
232
|
+
// than from `patternFrame`'s axes -- closing the same ruling-R27 gap `asGrid` had.
|
|
233
|
+
// `patternFrame`'s w axis is data-derived (the shared drill direction), but its u/v
|
|
234
|
+
// are an arbitrary seed-vector choice with no relationship to where a real mirror
|
|
235
|
+
// plane sits; testing only those two as candidate normals meant a part whose true
|
|
236
|
+
// mirror plane wasn't aligned to that arbitrary in-plane pick reported no symmetry
|
|
237
|
+
// at all, the same silent orientation-dependence asGrid had before its rewrite.
|
|
238
|
+
//
|
|
239
|
+
// Candidates: for every PAIR of same-signature features, the perpendicular
|
|
240
|
+
// bisector plane of the segment joining them -- normal along the join direction,
|
|
241
|
+
// offset at the midpoint -- is a candidate mirror plane. This set is complete: any
|
|
242
|
+
// true mirror plane of the layout must be the bisector of at least one such pair
|
|
243
|
+
// (a feature and its own reflected partner), whatever the part's orientation.
|
|
244
|
+
//
|
|
245
|
+
// Fix round 2 (self-satisfying floor): the bisector of pair (i, j) ALWAYS reflects
|
|
246
|
+
// i onto j and j onto i, by construction, for ANY pair, symmetric layout or not --
|
|
247
|
+
// that is not evidence, it is a restatement of how the candidate was built. Scoring
|
|
248
|
+
// naively (matched/n, no gate) therefore has a guaranteed floor of 2/n before any
|
|
249
|
+
// real evidence is considered: coverage=1 at n=2, 0.67 at n=3, both already above
|
|
250
|
+
// the 0.6 threshold. Confirmed: the ungated version reports a perfect coverage=1
|
|
251
|
+
// mirror plane on the brief's own "two unrelated holes" fixture, and flagged
|
|
252
|
+
// spurious symmetry on close to 100% of random small layouts.
|
|
253
|
+
//
|
|
254
|
+
// The fix is NOT "exclude the pair that proposed the candidate" -- after
|
|
255
|
+
// de-duplication (below) a single canonical plane is typically proposed by SEVERAL
|
|
256
|
+
// pairs at once in a genuinely symmetric layout (both real pairs on the brief
|
|
257
|
+
// rectangle's x=30 plane propose the identical plane), so there is no single
|
|
258
|
+
// well-defined "the" proposing pair to exclude post-dedup; picking one arbitrarily
|
|
259
|
+
// (e.g. "whichever pair the generation loop reached first") would also make the
|
|
260
|
+
// result depend on input ORDER, which is a correctness bug on its own. Instead,
|
|
261
|
+
// every candidate is required to be corroborated by evidence beyond a single
|
|
262
|
+
// pair's self-consistency: at least `SYMMETRY_EVIDENCE_MIN` confirmed mirror PAIRS
|
|
263
|
+
// (the one that proposed the candidate always counts as one; a second, independent
|
|
264
|
+
// pair is what makes it evidence rather than a tautology). This is a property of
|
|
265
|
+
// the fully-scored candidate, not of "who proposed it first", so it is
|
|
266
|
+
// order-independent by construction. `MIN_SYMMETRY_MEMBERS` additionally refuses
|
|
267
|
+
// to even consider symmetry below 4 positioned features, per the same reasoning:
|
|
268
|
+
// a plane over 1-2 points restates a midpoint rather than finding anything.
|
|
269
|
+
//
|
|
270
|
+
// On-plane points (a feature landing within tolerance of the candidate plane
|
|
271
|
+
// itself, e.g. a centred keyway) are NOT counted toward this gate, only toward
|
|
272
|
+
// `coverage` once a candidate has already cleared it via pairs: an on-plane test is
|
|
273
|
+
// a 1-DOF proximity-to-a-PLANE check, not the 3-DOF proximity-to-a-specific-POINT
|
|
274
|
+
// check a pair match is, so it clears by pure chance far more often on unrelated
|
|
275
|
+
// data. Measured directly: gating on pairs-plus-on-plane produced 5 false
|
|
276
|
+
// positives across 1500 random trials at n=2/3/4/5/7 (all at n=4, where a single
|
|
277
|
+
// stray on-plane coincidence combined with the always-true generating pair to
|
|
278
|
+
// clear the threshold); gating on pairs alone dropped that to 0/1500.
|
|
279
|
+
//
|
|
280
|
+
// Performance (fix round 2, two passes): the first pass -- de-duplicating
|
|
281
|
+
// candidates via a rounded bucket instead of an O(candidates^2) pairwise scan, and
|
|
282
|
+
// scoring each via a spatial hash instead of an O(n) linear scan per reflected
|
|
283
|
+
// point -- still left an O(n^2) candidate count each scored at O(n), i.e. O(n^3)
|
|
284
|
+
// overall. Measured: 100-120 holes still took 0.86-3.7s (the wide range is because
|
|
285
|
+
// the ORIGINAL O(candidates^2) dedup and O(n) linear-scan lookups were themselves
|
|
286
|
+
// large constant factors -- see the cost table in the task report for the
|
|
287
|
+
// intermediate numbers). At n=120, ~7100 candidates x ~120 points/candidate is
|
|
288
|
+
// ~850k reflect-and-look-up calls; even at O(1)-amortized each, that is the
|
|
289
|
+
// dominant cost, and no amount of speeding up that lookup changes the O(n^3) shape.
|
|
290
|
+
//
|
|
291
|
+
// The actual fix is to avoid running that O(n) scoring pass at all for the
|
|
292
|
+
// overwhelming majority of candidates. Key observation: a "confirmed pair" (k, l)
|
|
293
|
+
// for a candidate plane P -- a pair that, on scoring, turns out to reflect onto
|
|
294
|
+
// itself through P -- is BY DEFINITION a pair whose OWN perpendicular bisector
|
|
295
|
+
// equals P. But every same-signature pair's bisector is exactly what the
|
|
296
|
+
// generation loop already computes for EVERY (i, j), so the number of DISTINCT
|
|
297
|
+
// pairs that propose the same bucket during generation is a free-to-compute proxy
|
|
298
|
+
// for the same "confirmed pairs" count the expensive scoring pass exists to find.
|
|
299
|
+
// So: tally proposer counts per bucket during the O(n^2) generation pass (already
|
|
300
|
+
// cheap, ~5-15ms even at n=120), and only run the O(n) exact scoring pass -- which
|
|
301
|
+
// still makes the final accept/reject call, so this tally never itself decides
|
|
302
|
+
// correctness -- on candidates whose bucket already has >= SYMMETRY_EVIDENCE_MIN
|
|
303
|
+
// proposers. For a layout with no real symmetry this prunes ~7100 candidates down
|
|
304
|
+
// to a few hundred; for a genuinely symmetric layout the true planes were always
|
|
305
|
+
// going to survive regardless, since their real proposer count clears the bar.
|
|
306
|
+
//
|
|
307
|
+
// The proposer-count bucket is deliberately WIDER (`SYMMETRY_PREFILTER_FACTOR`x)
|
|
308
|
+
// than the exact dedup bucket used for the final output: real position noise
|
|
309
|
+
// between two truly-duplicate pairs (e.g. from mesh measurement error, not the
|
|
310
|
+
// exact-copy rotations this file's own tests use) could otherwise straddle a
|
|
311
|
+
// narrow bucket edge and undercount a genuine candidate's proposers, silently
|
|
312
|
+
// dropping a real symmetry finding before the exact pass ever runs. Widening only
|
|
313
|
+
// risks the opposite, harmless direction: unrelated pairs coincidentally sharing a
|
|
314
|
+
// wide bucket just cost one extra (still individually cheap) exact scoring pass
|
|
315
|
+
// that correctly rejects them -- it can never cause a false ACCEPT, since the
|
|
316
|
+
// final decision is always the exact `pairs`/`coverage` computation below, not the
|
|
317
|
+
// proposer count.
|
|
318
|
+
//
|
|
319
|
+
// `coverage` is the matched fraction (of ALL positioned features, not just the
|
|
320
|
+
// evidence beyond the gate), so a nearly symmetric part reports 0.94 rather than
|
|
321
|
+
// silently reporting nothing — the agent can then decide whether the part WANTS
|
|
322
|
+
// to be symmetric and the scan is just imperfect.
|
|
323
|
+
function detectSymmetry(features, bounds, tol) {
|
|
324
|
+
const positioned = features.filter((f) => posOf(f));
|
|
325
|
+
if (positioned.length < MIN_SYMMETRY_MEMBERS) return [];
|
|
326
|
+
const pts = positioned.map((f) => posOf(f));
|
|
327
|
+
const sigs = positioned.map((f) => signature(f));
|
|
328
|
+
|
|
329
|
+
const fineSeen = new Map(); // fine bucket key -> candidate (for de-duplicated output)
|
|
330
|
+
const coarseCounts = new Map(); // coarse bucket key -> proposer count (pre-filter only)
|
|
331
|
+
const candidates = [];
|
|
332
|
+
for (let i = 0; i < pts.length; i++) {
|
|
333
|
+
for (let j = i + 1; j < pts.length; j++) {
|
|
334
|
+
if (sigs[i] !== sigs[j]) continue;
|
|
335
|
+
const d = sub(pts[j], pts[i]);
|
|
336
|
+
const dl = len(d);
|
|
337
|
+
if (dl < tol) continue; // coincident, not a mirror pair
|
|
338
|
+
const rawN = [d[0]/dl, d[1]/dl, d[2]/dl];
|
|
339
|
+
const mid = [(pts[i][0]+pts[j][0])/2, (pts[i][1]+pts[j][1])/2, (pts[i][2]+pts[j][2])/2];
|
|
340
|
+
const cand = canonicalPlane(rawN, dot(rawN, mid));
|
|
341
|
+
|
|
342
|
+
const ck = planeBucketKey(cand, tol, SYMMETRY_PREFILTER_FACTOR);
|
|
343
|
+
coarseCounts.set(ck, (coarseCounts.get(ck) || 0) + 1);
|
|
344
|
+
|
|
345
|
+
const fk = planeBucketKey(cand, tol, 1);
|
|
346
|
+
if (!fineSeen.has(fk)) { const entry = { cand, ck }; fineSeen.set(fk, entry); candidates.push(entry); }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const survivors = candidates.filter((c) => (coarseCounts.get(c.ck) || 0) >= SYMMETRY_EVIDENCE_MIN);
|
|
350
|
+
|
|
351
|
+
const lookup = buildPositionLookup(pts, sigs, tol);
|
|
352
|
+
const out = [];
|
|
353
|
+
for (const { cand } of survivors) {
|
|
354
|
+
const matchOf = pts.map((p, i) => {
|
|
355
|
+
const d2 = 2 * (dot(cand.n, p) - cand.offset);
|
|
356
|
+
const want = [p[0]-d2*cand.n[0], p[1]-d2*cand.n[1], p[2]-d2*cand.n[2]];
|
|
357
|
+
return findNearby(lookup, want, sigs[i], pts, tol);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
let pairs = 0, onPlane = 0;
|
|
361
|
+
for (let i = 0; i < matchOf.length; i++) {
|
|
362
|
+
const j = matchOf[i];
|
|
363
|
+
if (j === -1) continue;
|
|
364
|
+
if (j === i) onPlane++;
|
|
365
|
+
else if (j > i && matchOf[j] === i) pairs++; // count each mutual pair once
|
|
366
|
+
}
|
|
367
|
+
// The gate is confirmed PAIRS only, not "pairs + on-plane points" (an earlier
|
|
368
|
+
// version of this fix gated on both and measurably regressed: an on-plane match
|
|
369
|
+
// is a 1-DOF test -- is this point within tol of a PLANE -- against a 3-DOF test
|
|
370
|
+
// for a pair -- is this point within tol of a specific reflected POSITION -- so
|
|
371
|
+
// it fires on unrelated random data far more often. Measured directly: gating on
|
|
372
|
+
// pairs+onPlane produced 5/1500 false positives across n=2/3/4/5/7 random
|
|
373
|
+
// trials, concentrated at n=4 where a single stray on-plane coincidence was
|
|
374
|
+
// enough to clear the threshold alongside the always-true generating pair.
|
|
375
|
+
// Gating on pairs alone (a second INDEPENDENT confirmed pair beyond the one that
|
|
376
|
+
// proposed the candidate) dropped that to 0/1500. On-plane points still count
|
|
377
|
+
// toward `coverage` once a candidate has cleared the pairs gate -- a feature
|
|
378
|
+
// genuinely on the mirror plane (a centred keyway) is real supporting evidence
|
|
379
|
+
// once the plane itself is established, just not evidence for ESTABLISHING it.
|
|
380
|
+
if (pairs < SYMMETRY_EVIDENCE_MIN) continue;
|
|
381
|
+
|
|
382
|
+
const coverage = (2*pairs + onPlane) / pts.length;
|
|
383
|
+
if (coverage <= 0.6) continue;
|
|
384
|
+
if (out.some((o) => samePlane(o, cand, tol))) continue; // fine-bucket boundary straddle
|
|
385
|
+
out.push({ n: cand.n, offset: cand.offset, coverage: round3(coverage) });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Canonical order: a function of the geometry (offset, then normal), not of
|
|
389
|
+
// which pair the generation loop happened to reach first for a given input order.
|
|
390
|
+
out.sort((a, b) => a.offset - b.offset || a.n[0]-b.n[0] || a.n[1]-b.n[1] || a.n[2]-b.n[2]);
|
|
391
|
+
return out.map(({ n, offset, coverage }) => ({ type: "mirror", plane: { normal: n, offset }, coverage }));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Fixes the normal's sign (and offset to match) so a plane's canonical form is a
|
|
395
|
+
// function of the geometry, not of which of two pairs on it -- or which direction
|
|
396
|
+
// along the segment -- happened to compute it.
|
|
397
|
+
function canonicalPlane(n, offset) {
|
|
398
|
+
let idx = 0;
|
|
399
|
+
// `+ TOL_FRAC` (round 3 IMPORTANT fix), not a bare `>`: a true mirror plane at ~45deg
|
|
400
|
+
// to the frame has two normal components equal in magnitude, so two independently
|
|
401
|
+
// -computed proposing pairs for the SAME plane can land on opposite sides of this
|
|
402
|
+
// comparison from float noise alone -- one picks idx=0, the other idx=1, and each
|
|
403
|
+
// then sign-fixes against a DIFFERENT component, canonicalizing to opposite-sign
|
|
404
|
+
// normals that never merge in samePlane/planeBucketKey (the same plane reported
|
|
405
|
+
// twice, each half failing SYMMETRY_EVIDENCE_MIN alone instead of confirming each
|
|
406
|
+
// other). Reproduced directly against a real meshed 2x2-hole plate rotated exactly
|
|
407
|
+
// 45deg about Z: 2 mirror planes at 0/15/30/60/90deg, only 1 at 45deg, with
|
|
408
|
+
// Manifold's own tessellation noise on the surviving/orphaned proposers' normal
|
|
409
|
+
// components measured up to ~2e-8 apart -- large enough that the file's other
|
|
410
|
+
// candidate tie-break (`+ 1e-9`, an earlier draft of this fix) still let the idx pick
|
|
411
|
+
// flip and did not actually merge the two proposers. `TOL_FRAC` is already this
|
|
412
|
+
// file's own established noise floor for a unit-vector component (see
|
|
413
|
+
// `planeBucketKey`'s own comment: "the same dimensionless fraction the frame's own
|
|
414
|
+
// tolerance derives from, appropriate for a unit vector"), five orders of magnitude
|
|
415
|
+
// above the noise actually measured, so reusing it here needs no new magic number
|
|
416
|
+
// and cannot mask a genuinely distinct pair of components (real designs practically
|
|
417
|
+
// never differ by less than TOL_FRAC on a plane normal component without meaning it).
|
|
418
|
+
for (let k = 1; k < 3; k++) if (Math.abs(n[k]) > Math.abs(n[idx]) + TOL_FRAC) idx = k;
|
|
419
|
+
if (n[idx] < 0) return { n: [-n[0], -n[1], -n[2]], offset: -offset };
|
|
420
|
+
return { n, offset };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Rounding bucket for a plane, at `widen`x the base granularity (1x for the exact
|
|
424
|
+
// output-dedup bucket, `SYMMETRY_PREFILTER_FACTOR`x for the coarse proposer-count
|
|
425
|
+
// pre-filter -- see the performance note above `detectSymmetry` for why the latter
|
|
426
|
+
// needs to be wider). Can, in principle, fail to merge two duplicates that
|
|
427
|
+
// straddle a bucket edge; for the fine (1x) bucket that's a performance-only risk
|
|
428
|
+
// (the final `samePlane` check on the small accepted list is what actually
|
|
429
|
+
// guarantees no duplicate plane reaches the output), and for the coarse bucket the
|
|
430
|
+
// `SYMMETRY_PREFILTER_FACTOR` margin is what keeps it a non-issue in practice.
|
|
431
|
+
// Base width for the normal is `TOL_FRAC` (the same dimensionless fraction the
|
|
432
|
+
// frame's own tolerance derives from, appropriate for a unit vector); for the
|
|
433
|
+
// offset it's `tol`.
|
|
434
|
+
function planeBucketKey(cand, tol, widen) {
|
|
435
|
+
const ng = TOL_FRAC * widen, og = tol * widen;
|
|
436
|
+
const nb = cand.n.map((v) => Math.round(v / ng));
|
|
437
|
+
const ob = Math.round(cand.offset / og);
|
|
438
|
+
return `${nb.join(",")}|${ob}`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Same plane within tolerance: normals parallel up to sign, offsets equal (sign
|
|
442
|
+
// flipped to match the normal's flip, since offset is defined relative to it).
|
|
443
|
+
function samePlane(a, b, tol) {
|
|
444
|
+
if (len(sub(a.n, b.n)) < 1e-6) return Math.abs(a.offset - b.offset) <= tol;
|
|
445
|
+
if (len(sub(a.n, [-b.n[0], -b.n[1], -b.n[2]])) < 1e-6) return Math.abs(a.offset + b.offset) <= tol;
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// A spatial hash over (signature, position), built once and reused by every
|
|
450
|
+
// candidate's scoring pass. Cell size = tol, so any real match (within tol of some
|
|
451
|
+
// feature) is guaranteed to fall in the query point's own cell or one of its 26
|
|
452
|
+
// neighbours -- turning "find the matching feature" from an O(n) linear scan into
|
|
453
|
+
// an O(1)-amortized lookup. Keys are packed into a single integer (`packCell`)
|
|
454
|
+
// rather than a template-literal string: at survivor-scan volume this is called
|
|
455
|
+
// tens of thousands of times, and a fresh string allocation plus its hash on every
|
|
456
|
+
// call was, measured directly, the dominant cost even after the pre-filter above
|
|
457
|
+
// cut the candidate count down -- switching to integer keys turned a ~0.86s pass
|
|
458
|
+
// (already down from 3.7s via the pre-filter and hash alone) into a ~0.13s one.
|
|
459
|
+
// `CELL_RANGE`/`CELL_OFFSET` give +-65536 cells of headroom per axis, about 65x a
|
|
460
|
+
// typical extent/tol ratio (tol is `bounds` diagonal x 1e-3, so a feature spread
|
|
461
|
+
// across the full bounding box is already only ~1000 cells wide); a pathological
|
|
462
|
+
// input that overflows this just collides two distant cells into one bucket,
|
|
463
|
+
// which the exact `len(...) <= tol` check inside `findNearby` still filters
|
|
464
|
+
// correctly -- degrades to a slightly bigger bucket to scan, never a wrong match.
|
|
465
|
+
function buildPositionLookup(pts, sigs, tol) {
|
|
466
|
+
const sigIds = new Map();
|
|
467
|
+
const bySig = [];
|
|
468
|
+
for (let i = 0; i < pts.length; i++) {
|
|
469
|
+
let sid = sigIds.get(sigs[i]);
|
|
470
|
+
if (sid === undefined) { sid = bySig.length; sigIds.set(sigs[i], sid); bySig.push(new Map()); }
|
|
471
|
+
const key = packCell(Math.round(pts[i][0]/tol), Math.round(pts[i][1]/tol), Math.round(pts[i][2]/tol));
|
|
472
|
+
const cellMap = bySig[sid];
|
|
473
|
+
if (!cellMap.has(key)) cellMap.set(key, []);
|
|
474
|
+
cellMap.get(key).push(i);
|
|
475
|
+
}
|
|
476
|
+
return { sigIds, bySig };
|
|
477
|
+
}
|
|
478
|
+
const CELL_OFFSET = 1 << 16;
|
|
479
|
+
const CELL_RANGE = 1 << 17;
|
|
480
|
+
const packCell = (cx, cy, cz) =>
|
|
481
|
+
((cx + CELL_OFFSET) * CELL_RANGE + (cy + CELL_OFFSET)) * CELL_RANGE + (cz + CELL_OFFSET);
|
|
482
|
+
|
|
483
|
+
function findNearby(lookup, q, sig, pts, tol) {
|
|
484
|
+
const sid = lookup.sigIds.get(sig);
|
|
485
|
+
if (sid === undefined) return -1;
|
|
486
|
+
const cellMap = lookup.bySig[sid];
|
|
487
|
+
const cx = Math.round(q[0]/tol), cy = Math.round(q[1]/tol), cz = Math.round(q[2]/tol);
|
|
488
|
+
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (let dz = -1; dz <= 1; dz++) {
|
|
489
|
+
const bucket = cellMap.get(packCell(cx+dx, cy+dy, cz+dz));
|
|
490
|
+
if (!bucket) continue;
|
|
491
|
+
for (const idx of bucket) if (len(sub(pts[idx], q)) <= tol) return idx;
|
|
492
|
+
}
|
|
493
|
+
return -1;
|
|
494
|
+
}
|