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,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
+ }