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.
@@ -0,0 +1,188 @@
1
+ // The confirm half of propose-then-confirm (spec §2.8). Segmentation and the feature
2
+ // rules produce CANDIDATES; this decides which are real, in what order, and how sure
3
+ // we are — by building each one and measuring it against the source mesh.
4
+ //
5
+ // Three properties are load-bearing.
6
+ //
7
+ // ONE CACHE BRACKET. geometry/solid-cache.js scopes retention to the current build's
8
+ // graph: each begin()/end() pair rebuilds the retained set and DISPOSES anything not
9
+ // re-used that round. A search loop that opened a bracket per candidate would evict its
10
+ // own shared subtrees on every iteration — quadratic rebuilds and WASM churn on a part
11
+ // that should be nearly free. So the whole loop runs inside exactly one bracket, and
12
+ // every candidate's geometry stays warm and shared for its duration.
13
+ //
14
+ // HARD BUDGET. Booleans are the cost centre and the candidate list is attacker-shaped
15
+ // (it grows with mesh complexity, not with anything we control). The budget counts
16
+ // CANDIDATE ATTEMPTS, not raw boolean calls — see the loop's own comment below for
17
+ // exactly what one attempt costs in real booleans, which varies by case — and running
18
+ // out DEGRADES INTO RESIDUAL rather than throwing: an over-budget describe returns a
19
+ // partial, honestly-scored report, which is exactly what a caller can act on.
20
+ //
21
+ // CONFIDENCE IS THE GAIN. A feature's confidence is the marginal xor reduction that
22
+ // admitted it, not a separate estimate invented afterwards. That is what makes the
23
+ // number falsifiable — it is a measurement of how much of the part that feature
24
+ // explains.
25
+ //
26
+ // The ONLY kernel-touching file in describe/.
27
+
28
+ // Named for what it actually counts (see the loop's own comment): CANDIDATE ATTEMPTS,
29
+ // not boolean operations. One attempt costs 0-2 real booleans depending on the
30
+ // candidate's op and whether a base body exists yet, so this is a bound on search
31
+ // WORK, not a boolean-op budget a caller could size against a WASM-call cost model.
32
+ export const DEFAULT_ATTEMPT_BUDGET = 48;
33
+ // A candidate must explain at least this fraction of the source volume to be worth a
34
+ // line in the report. Below it, the "feature" is tessellation noise.
35
+ const MIN_GAIN_FRACTION = 1e-4;
36
+
37
+ // Symmetric-difference volume — the same measure measure.js uses for the `reference`
38
+ // deviation fact, so a describe score and a verify ref-gate are directly comparable.
39
+ // One boolean and two volume reads; no meshing, no rasterisation. `cut`/`union`/
40
+ // `intersect` are binary methods ON A SOLID (`a.intersect(b)`), not kernel-level free
41
+ // functions — kernel.js's own JSDoc has the full Solid method table; there is no
42
+ // `kernel.intersect(a, b)`. Neither operand needs `.clone()` first: unlike the OCCT
43
+ // backend (whose replicad shapes ARE consumed by a transform — see AGENTS.md),
44
+ // Manifold's boolean methods return a new solid and leave both operands live and
45
+ // reusable, exactly as measure.js's own `solid.intersect(ref).volume()` and
46
+ // assembly.js's pairwise overlap check already rely on.
47
+ function xorVolume(a, b) {
48
+ const inter = a.intersect(b).volume();
49
+ return a.volume() + b.volume() - 2 * inter;
50
+ }
51
+
52
+ export function acceptCandidates(kernel, source, candidates, opts = {}) {
53
+ const budget = opts.budget ?? DEFAULT_ATTEMPT_BUDGET;
54
+ const sourceVolume = source.volume();
55
+ const accepted = [];
56
+ // Counts CANDIDATE ATTEMPTS (one per pass through the `for` loop below), not real
57
+ // boolean calls — see that loop's own comment for the exact per-attempt cost, which
58
+ // is 0, 1, or 2 real booleans depending on the candidate's op and whether `current`
59
+ // is null. Reported back as `budgetSpent` (name kept as-is — see that field's own
60
+ // comment on why) rather than renamed to `attemptsSpent`.
61
+ let attempts = 0;
62
+ // Every candidate object that reached the loop body at least once — keyed by
63
+ // reference, not by `cand.key`/`cand.featureKey`, since this file never assumes a
64
+ // candidate carries either (test/describe-accept.test.js's own fixtures only give
65
+ // theirs a bare `key`, and other callers may give none at all). This is what lets a
66
+ // caller (describe.js) tell "budget ran out before this candidate ever got a turn"
67
+ // apart from "this candidate got a turn — every round it was in — and never won
68
+ // one" for whatever's left in `pending` at the end (fix round 2, IMPORTANT 2): a
69
+ // rejected feature and a budget-starved one both report `volumeShare: null` and
70
+ // are otherwise indistinguishable, which matters to a rebuilder deciding whether to
71
+ // retry with a bigger `--budget` or accept that a feature genuinely doesn't fit.
72
+ // NOTE on the one case this deliberately does NOT collapse (round 3 CRITICAL fix): a
73
+ // `cut` candidate that gets a turn while `current === null` (`"nothing to cut from
74
+ // yet"`, below) never has a gain computed for it, so it is NOT added here — only a
75
+ // candidate that actually ran a boolean (a real gain measurement) or whose `.build()`
76
+ // genuinely threw counts as attempted. Earlier this Set included the no-base-yet case
77
+ // too, which made a starved `--budget` report `"rejected"` for a feature the search
78
+ // simply never reached with a base to cut from — provably wrong, since raising the
79
+ // budget alone (no code change) turned that same feature into a real, positive share.
80
+ const attempted = new Set();
81
+
82
+ // The single bracket. `describe:accept` is deliberately its own partition name, not a
83
+ // display sub-part's: the cross-partition hash index still lets it ADOPT geometry the
84
+ // viewer already built, while its own eviction at end() cannot throw away what the
85
+ // viewer is showing. Same reasoning as oracle/build.js's `oracle:view:` naming.
86
+ kernel.beginSubPart?.("describe:accept");
87
+ try {
88
+ let current = null; // the reconstruction so far
89
+ let currentXor = sourceVolume; // an empty reconstruction differs by the whole part
90
+ const pending = [...candidates];
91
+
92
+ while (pending.length && attempts < budget) {
93
+ let best = null;
94
+ for (const cand of pending) {
95
+ if (attempts >= budget) break;
96
+ // Real boolean cost of THIS attempt, not the `attempts` counter below (that
97
+ // counts the attempt itself, always by 1, regardless of how many WASM
98
+ // booleans it took) — spelled out here because it is not uniform and a
99
+ // reader sizing the budget against boolean-call cost needs the real number:
100
+ // • op "cut", current === null → 0 booleans (trial is set to null with
101
+ // no kernel call at all — "nothing to cut from yet" — and skipped below)
102
+ // • op "union", current === null → 1 boolean (no union call needed either,
103
+ // trial IS piece; the only boolean is xorVolume's own intersect below)
104
+ // • either op, current !== null → 2 booleans (the cut/union that builds
105
+ // `trial`, plus xorVolume's intersect)
106
+ // So budget=N bounds attempts, and — once any candidate has been accepted,
107
+ // which is the common case for a multi-feature part — real boolean work at
108
+ // roughly 2N, not N. Verified directly: a 4-candidate, 2-op-type search
109
+ // (1 accepted union then 1 accepted cut) reports `budgetSpent: 9` against
110
+ // 12 real boolean calls counted by wrapping the kernel.
111
+ let trial;
112
+ let noBaseYet = false; // "nothing to cut from yet" — no gain measured
113
+ try {
114
+ const piece = cand.build();
115
+ if (current === null && cand.op === "cut") {
116
+ trial = null;
117
+ noBaseYet = true;
118
+ } else {
119
+ trial = current === null ? piece
120
+ : cand.op === "cut" ? current.cut(piece)
121
+ : current.union(piece);
122
+ }
123
+ } catch {
124
+ // A candidate whose geometry will not build is not an error — it is simply
125
+ // not a description of this mesh. Drop it and keep going.
126
+ trial = null;
127
+ }
128
+ attempts++;
129
+ // Only count this as a real attempt (accept.js's own contract with describe.js
130
+ // — see the Set's declaration comment) when a gain was actually measured or the
131
+ // candidate's own geometry genuinely failed to build. `noBaseYet` is neither: no
132
+ // boolean ever ran and no verdict was reached, so leaving it out of `attempted`
133
+ // is what lets describe.js report `"budget"` instead of `"rejected"` for a cut
134
+ // candidate that only ever got a turn before any base body existed — round 3's
135
+ // CRITICAL finding: with the old blanket `attempted.add(cand)` above, THIS is
136
+ // exactly the case that reported `"rejected"` (`--budget 2`, the washer fixture)
137
+ // for a feature that becomes a real 19% share at `--budget 3` — the search never
138
+ // rejected it, it just never got there.
139
+ if (!noBaseYet) attempted.add(cand);
140
+ if (!trial) continue;
141
+ const xor = xorVolume(trial, source);
142
+ const gain = currentXor - xor;
143
+ if (gain > sourceVolume * MIN_GAIN_FRACTION && (!best || gain > best.gain)) {
144
+ best = { cand, trial, xor, gain };
145
+ }
146
+ }
147
+ if (!best) break; // nothing left improves the reconstruction
148
+
149
+ current = best.trial;
150
+ currentXor = best.xor;
151
+ accepted.push({
152
+ candidate: best.cand,
153
+ gain: best.gain / sourceVolume, // normalised: comparable across parts
154
+ cumulativeXor: currentXor,
155
+ order: accepted.length,
156
+ });
157
+ pending.splice(pending.indexOf(best.cand), 1);
158
+ }
159
+
160
+ const xorFraction = sourceVolume > 0 ? currentXor / sourceVolume : 1;
161
+ return {
162
+ accepted,
163
+ residual: { xorVolume: currentXor, xorFraction },
164
+ score: {
165
+ explainedVolumeFraction: Math.max(0, 1 - xorFraction),
166
+ xorFraction,
167
+ xorVolume: currentXor,
168
+ },
169
+ // Candidate attempts, not real boolean calls — see `attempts`'s own comment
170
+ // above and the per-attempt cost breakdown in the loop. Kept as `budgetSpent`
171
+ // (not renamed to `attemptsSpent`) because it is a documented cross-task
172
+ // interface field T12's orchestrator consumes by this exact name (SDD
173
+ // progress ledger, T10→T12 interface row); the field's MEANING is what moved,
174
+ // not its shape, so a rename here would be a breaking, undocumented surprise
175
+ // for that consumer rather than a fix.
176
+ budgetSpent: attempts,
177
+ budgetExceeded: attempts >= budget && pending.length > 0,
178
+ // Candidate OBJECTS (not keys) that reached at least one build+evaluate attempt
179
+ // — see this Set's own declaration comment above for why by-reference and what
180
+ // it does and doesn't distinguish. Every accepted candidate is trivially a
181
+ // member too (it can't have been accepted without at least one attempt); the
182
+ // caller only needs to consult this for candidates NOT in `accepted`.
183
+ attempted,
184
+ };
185
+ } finally {
186
+ kernel.endSubPart?.();
187
+ }
188
+ }
@@ -0,0 +1,173 @@
1
+ // Fillet and chamfer rules.
2
+ //
3
+ // Both are TRANSITION surfaces: narrow strips whose job is to soften the meeting of
4
+ // two larger neighbours. That is what distinguishes them from a small functional
5
+ // face, and it is why the rules test the strip's relationship to its neighbours
6
+ // rather than its size alone. A 2mm-wide plane between two walls is a chamfer; a
7
+ // 2mm-wide plane bounded by four other 2mm planes is just a small face.
8
+ //
9
+ // Fillet: cylinder or torus, tangent to both of two PRIMARY neighbours (the arc
10
+ // convexity gives inside vs outside rounding).
11
+ // Chamfer: plane OR cone (a countersink is a revolved chamfer), meeting each
12
+ // primary neighbour at a consistent angle that is neither ~0 nor ~90
13
+ // degrees.
14
+ //
15
+ // PRIMARY neighbours are the two LONGEST-shared-boundary arcs, not "however many
16
+ // arcs this surface has total" (round 1 review: the original premise required
17
+ // `arcs.length === 2`, which no finite straight chamfer/fillet can ever satisfy —
18
+ // a chamfer cut along a box edge of finite length necessarily terminates against
19
+ // two more end faces besides the two walls it actually blends, giving FOUR arcs,
20
+ // not two; verified directly against a chamfered box, whose bevel plane has arcs
21
+ // of length ~30mm to the two walls it blends and ~4.2mm to the two end caps it
22
+ // merely runs into). Selecting by length picks out the two it actually blends and
23
+ // treats the incidental end walls as exactly that: incidental, not disqualifying.
24
+ //
25
+ // TANGENCY, for a cylinder candidate, means its two PRIMARY arcs are STRAIGHT
26
+ // (`kind === "line"`): a constant-radius fillet run along a straight edge is
27
+ // tangent to its neighbours along a line parallel to its own axis. A CIRCULAR
28
+ // primary arc between a cylinder and a plane means the opposite: the cylinder's
29
+ // axis runs perpendicular to that plane, punching straight through it — a bore's
30
+ // or a boss's mouth, not a tangent blend (verified directly against
31
+ // annulusPlate(10,4,3,48): its bore is a plain cylinder with exactly two circular
32
+ // arcs to the two larger cap planes, and without this check it satisfies every
33
+ // other test here and gets reported as a fillet). A doubly-curved torus fillet's
34
+ // own tangent arcs ARE genuinely circular (it blends a curved edge), so this
35
+ // guard is cylinder-only.
36
+ //
37
+ // WIDTH is compared against the primary neighbours' own EXTENT as a length, not
38
+ // an area (round 1 review: the original area-ratio test — this surface's area
39
+ // against each neighbour's — silently drops an ordinary torus fillet as it grows,
40
+ // because the neighbouring end cap shrinks by 2x the fillet radius as the fillet
41
+ // widens, which erodes the AREA ratio far faster than the actual geometry
42
+ // justifies; measured directly: r/R = 0.125 and 0.25 fillets, both completely
43
+ // normal roundovers, missed entirely under the old area test). A dress-up is
44
+ // narrow in ONE dimension, so the comparison is a length against a length: this
45
+ // surface's own width (a fillet's radius; a chamfer's or countersink's area
46
+ // divided by its longest primary arc) against the LARGER of the two primary
47
+ // arcs' own measured radius (circular arcs — the natural "how big is the thing
48
+ // this blends" reference; e.g. a fillet's tangent seam to its shaft lands almost
49
+ // exactly on the shaft's own radius) or length (straight arcs, which have no
50
+ // radius). Reusing the larger of the two, rather than requiring both
51
+ // individually, matters because a fillet's two neighbours differ in this exact
52
+ // reference by very close to the fillet's own radius (a torus fillet's tangent
53
+ // circle on the flat-cap side is smaller than on the shaft side by ~r) — testing
54
+ // against the smaller one would make the threshold tighter than the geometry
55
+ // warrants.
56
+ //
57
+ // Pure leaf. See spec §2.5.
58
+ import { arcsOf } from "../surface-graph.js";
59
+
60
+ // A dress-up must be materially narrower than what it joins, or it is a face in
61
+ // its own right. Ratio, not an absolute size, so it scales with the part.
62
+ // Verified against a torus fillet sweep at r/R = 0.0625, 0.125 and 0.25 (all
63
+ // pass with this threshold; the largest, r=2 against a shaft radius R=8, checks
64
+ // 2 <= 8*0.34 = 2.72) while the washer bore (rejected by the tangency guard
65
+ // above, not by this ratio) stays rejected regardless.
66
+ const MAX_WIDTH_RATIO = 0.34;
67
+ // Chamfer angle band: outside this it is a tangent continuation or a square corner.
68
+ const MIN_CHAMFER_RAD = 0.15, MAX_CHAMFER_RAD = Math.PI / 2 - 0.15;
69
+ const round3 = (v) => Math.round(v * 1000) / 1000;
70
+
71
+ const byId = (graph) => new Map(graph.surfaces.map((s) => [s.id, s]));
72
+ const other = (arc, id) => (arc.between[0] === id ? arc.between[1] : arc.between[0]);
73
+ const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
74
+ // A primary arc's own reference length: its measured circle radius when curved,
75
+ // or its own run length when straight (a straight arc has no radius to offer).
76
+ const arcExtent = (arc) => arc.radius ?? arc.length;
77
+
78
+ // A neighbour's own fitted geometry, not its segmentation surface id (round 2
79
+ // review: `s0`/`s1` are assigned in triangle-DISCOVERY order — the same defect
80
+ // prismatic.js's key had — so they renumber under a triangle-order permutation
81
+ // of the same geometry, which must never change what a fillet/chamfer's own
82
+ // key is). Covers every surface type a dress-up's primary neighbour can be;
83
+ // `round3` absorbs the float-associativity noise a permuted summation/fit
84
+ // order introduces (Task 4's own ruling, R29) — real geometry never differs at
85
+ // 3-decimal precision, only float dust does.
86
+ const vec3 = (a) => a.map(round3).join(",");
87
+ function surfaceSignature(s) {
88
+ if (s.type === "plane") return `plane:${round3(s.fit.offset)}:${vec3(s.fit.normal)}`;
89
+ if (s.type === "cylinder") return `cylinder:${round3(s.fit.radius)}:${vec3(s.fit.axis.direction)}:${vec3(s.fit.axis.origin)}`;
90
+ if (s.type === "cone") return `cone:${round3(s.fit.halfAngle)}:${vec3(s.fit.apex)}:${vec3(s.fit.direction)}`;
91
+ if (s.type === "torus") return `torus:${round3(s.fit.majorRadius)}:${round3(s.fit.minorRadius)}:${vec3(s.fit.center)}`;
92
+ return `${s.type}:${round3(s.area)}`; // any future surface type
93
+ }
94
+ // "|", not "-": a signature can itself contain "-" (a negative coordinate), and
95
+ // this only ever needs to be a stable SORT key, never parsed back apart.
96
+ const sortSignatures = (nbrs) => nbrs.map(surfaceSignature).sort().join("|");
97
+
98
+ export function detectDressups(graph) {
99
+ const surfaces = byId(graph);
100
+ const out = [];
101
+
102
+ for (const s of graph.surfaces) {
103
+ const arcs = arcsOf(graph, s.id);
104
+ if (arcs.length < 2) continue;
105
+
106
+ // The two PRIMARY neighbours: the longest-shared-boundary arcs. Any other
107
+ // arcs (a finite chamfer/fillet's incidental end walls) are ignored below.
108
+ const primary = [...arcs].sort((a, b) => b.length - a.length).slice(0, 2);
109
+ const nbrs = primary.map((a) => surfaces.get(other(a, s.id)));
110
+ if (nbrs.some((n) => !n)) continue;
111
+
112
+ if (s.type === "cylinder" && !primary.every((a) => a.kind === "line")) continue;
113
+
114
+ const extent = Math.max(...primary.map(arcExtent));
115
+ const fitsAsStrip = (width) => width <= extent * MAX_WIDTH_RATIO;
116
+
117
+ if (s.type === "cylinder" || s.type === "torus") {
118
+ const radius = s.type === "cylinder" ? s.fit.radius : s.fit.minorRadius;
119
+ if (!fitsAsStrip(radius)) continue;
120
+ out.push({
121
+ id: null,
122
+ key: `fillet:${round3(radius)}:${sortSignatures(nbrs)}`,
123
+ type: "fillet", radius,
124
+ between: nbrs.map((n) => n.id),
125
+ convexity: primary[0].convexity,
126
+ surfaces: [s.id],
127
+ evidence: { arcs: arcs.length, widthRatio: round3(radius / extent), fitRms: s.fit.rms },
128
+ });
129
+ continue;
130
+ }
131
+
132
+ if (s.type === "plane" && nbrs.every((n) => n.type === "plane")) {
133
+ const angles = nbrs.map((n) => Math.acos(Math.max(-1, Math.min(1, Math.abs(dot(s.fit.normal, n.fit.normal))))));
134
+ if (!angles.every((a) => a > MIN_CHAMFER_RAD && a < MAX_CHAMFER_RAD)) continue;
135
+ // Strip width from the area and the longer of the two arcs — a chamfer is a
136
+ // ribbon, so area/length is its width.
137
+ const width = s.area / Math.max(primary[0].length, primary[1].length, 1e-9);
138
+ if (!fitsAsStrip(width)) continue;
139
+ out.push({
140
+ id: null,
141
+ key: `chamfer:${round3(width)}:${sortSignatures(nbrs)}`,
142
+ type: "chamfer", width, angle: (angles[0] + angles[1]) / 2,
143
+ between: nbrs.map((n) => n.id),
144
+ convexity: primary[0].convexity,
145
+ surfaces: [s.id],
146
+ evidence: { arcs: arcs.length, angles: angles.map(round3), fitRms: s.fit.rms },
147
+ });
148
+ continue;
149
+ }
150
+
151
+ // A countersink is a REVOLVED chamfer: a cone blending a bore into a face
152
+ // (or another cone) instead of a plane blending two walls. Its own conical
153
+ // half-angle already IS its blend angle (fit.js's fitCone recovers it
154
+ // directly), so unlike the plane branch above there's no pair of face
155
+ // normals to average — the cone's `halfAngle` cashes out to the same
156
+ // "angle from the material" the plane branch measures.
157
+ if (s.type === "cone") {
158
+ const width = s.area / Math.max(primary[0].length, primary[1].length, 1e-9);
159
+ if (!fitsAsStrip(width)) continue;
160
+ out.push({
161
+ id: null,
162
+ key: `chamfer:${round3(width)}:${sortSignatures(nbrs)}`,
163
+ type: "chamfer", width, angle: s.fit.halfAngle,
164
+ between: nbrs.map((n) => n.id),
165
+ convexity: primary[0].convexity,
166
+ surfaces: [s.id],
167
+ evidence: { arcs: arcs.length, halfAngle: round3(s.fit.halfAngle), fitRms: s.fit.rms },
168
+ });
169
+ }
170
+ }
171
+
172
+ return out.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
173
+ }
@@ -0,0 +1,129 @@
1
+ // Hole rules over the attributed adjacency graph.
2
+ //
3
+ // The primary test is the cylinder's own CURVATURE, not the convexity of its arcs
4
+ // (controller ruling R10). A bore is a concave cylinder — outward normals pointing back
5
+ // toward its axis. A shaft or a boss is a convex one. Arc convexity cannot make this call:
6
+ // a through-hole's rim and a plate's outer edge are both plain 90-degree convex corners,
7
+ // because in both cases the material leaves a 90-degree wedge at the seam.
8
+ //
9
+ // Arc convexity still does the SECOND half of the job, once curvature has established we
10
+ // are looking at a bore: a convex circular arc to a plane is a MOUTH (the bore breaking
11
+ // out through a face), and a concave arc to a plane or cone is a FLOOR (the bore
12
+ // bottoming out). Two mouths on anti-parallel planes is a through hole; one mouth plus a
13
+ // floor is a blind hole. Everything else — radius, extent, axis — fit.js already measured.
14
+ //
15
+ // `id` is deliberately null: feature numbering is the orchestrator's job (Task 12),
16
+ // because ids must be assigned once across ALL feature families in a stable order.
17
+ // A `key` derived from rounded geometry gives that ordering something deterministic
18
+ // to sort on, so the same mesh always numbers its features the same way.
19
+ //
20
+ // Pure leaf. See spec §2.5.
21
+ import { arcsOf } from "../surface-graph.js";
22
+
23
+ // Two planes count as "parallel" (a through hole's two mouths) within this band.
24
+ const PARALLEL_DOT = 0.98;
25
+ // `arcsOf` is imported for the mouth/floor split; curvature comes off the surface itself.
26
+ const round3 = (v) => Math.round(v * 1000) / 1000;
27
+
28
+ const byId = (graph) => new Map(graph.surfaces.map((s) => [s.id, s]));
29
+ const other = (arc, id) => (arc.between[0] === id ? arc.between[1] : arc.between[0]);
30
+
31
+ export function detectHoles(graph) {
32
+ const surfaces = byId(graph);
33
+ const out = [];
34
+
35
+ for (const s of graph.surfaces) {
36
+ if (s.type !== "cylinder") continue;
37
+ if (s.curvature !== "concave") continue; // a boss, a shaft, an outer wall
38
+
39
+ const arcs = arcsOf(graph, s.id);
40
+ // A mouth is where the bore breaks out through a face: a convex seam to a plane.
41
+ // But the seam can also lead to a CONE first — a countersink or counterbore,
42
+ // the machining used to chamfer a hole's entrance — in which case the cone is
43
+ // incidental (it's on the way out, not the exit itself) and the true mouth is
44
+ // whatever PLANE the cone in turn opens onto one step further out. Round 1
45
+ // review: a plain through-bore with a 45-degree countersunk entrance reported
46
+ // ZERO holes before this, because the direct neighbour at that end is a cone,
47
+ // not a plane, and the old plane-only filter dropped that mouth entirely —
48
+ // leaving one planar mouth and no concave floor, so neither branch below fired.
49
+ const mouths = [];
50
+ for (const a of arcs) {
51
+ if (a.convexity !== "convex") continue;
52
+ const nbr = surfaces.get(other(a, s.id));
53
+ if (!nbr) continue;
54
+ if (nbr.type === "plane") { mouths.push({ arc: a, face: nbr, via: null }); continue; }
55
+ if (nbr.type === "cone") {
56
+ const beyond = arcsOf(graph, nbr.id)
57
+ .map((a2) => surfaces.get(other(a2, nbr.id)))
58
+ .find((far) => far && far.id !== s.id && far.type === "plane");
59
+ if (beyond) mouths.push({ arc: a, face: beyond, via: nbr });
60
+ }
61
+ }
62
+ if (mouths.length === 0) continue;
63
+
64
+ const dir = s.fit.axis.direction;
65
+ const depth = Math.abs(s.fit.extent[1] - s.fit.extent[0]);
66
+ const diameter = s.fit.radius * 2;
67
+ const origin = s.fit.axis.origin;
68
+
69
+ // Through: two planar mouths on parallel planes, both perpendicular to the bore
70
+ // axis. Blind: one mouth, with the far end closed by a surface the bore also
71
+ // touches (planar floor or conical drill point).
72
+ //
73
+ // NOTE: this checks |dot| against PARALLEL_DOT, not dot <= -PARALLEL_DOT (which
74
+ // is what "anti-parallel outward normals" would suggest and what a first draft
75
+ // of this rule used). fitPlane() (fit.js) is pure PCA over a point cloud with no
76
+ // reference to the mesh's own winding/face normals, so a plane surface's
77
+ // `fit.normal` sign is an arbitrary artifact of the eigensolver, not tied to the
78
+ // true outward direction — confirmed directly against annulusPlate(10,4,3,48),
79
+ // whose two caps (translated copies of the same ring shape, hence identical PCA
80
+ // covariance) both come back with fit.normal = (0,0,1), not the physically
81
+ // anti-parallel (0,0,-1)/(0,0,1) the caps actually have. A dot <= -PARALLEL_DOT
82
+ // test against that ambiguous sign found zero through holes on this fixture.
83
+ // The physical invariant that survives the sign ambiguity is just that the two
84
+ // mouth planes are PARALLEL to each other (their normals share a line, whichever
85
+ // way each happens to point) — a bore's own axis is perpendicular to a mouth
86
+ // plane at each end it breaks through, so two such planes at opposite ends of a
87
+ // straight bore cannot help but be parallel to one another.
88
+ let type = null, entryFace = null, exitFace = null, floorFace = null;
89
+ if (mouths.length >= 2) {
90
+ const [m0, m1] = mouths;
91
+ const d = m0.face.fit.normal[0]*m1.face.fit.normal[0]
92
+ + m0.face.fit.normal[1]*m1.face.fit.normal[1]
93
+ + m0.face.fit.normal[2]*m1.face.fit.normal[2];
94
+ if (Math.abs(d) >= PARALLEL_DOT) { type = "throughHole"; entryFace = m0.face.id; exitFace = m1.face.id; }
95
+ }
96
+ if (!type) {
97
+ // A floor is the opposite seam: CONCAVE, because the bore bottoming out leaves 270
98
+ // degrees of material there. Planar for a flat-bottomed bore, conical for a drill point.
99
+ const floor = arcs
100
+ .filter((a) => a.convexity === "concave")
101
+ .map((a) => surfaces.get(other(a, s.id)))
102
+ .find((n) => n && n.id !== mouths[0].face.id && (n.type === "plane" || n.type === "cone"));
103
+ if (floor) { type = "blindHole"; entryFace = mouths[0].face.id; floorFace = floor.id; }
104
+ }
105
+ if (!type) continue;
106
+
107
+ // Any cone(s) walked through to resolve a mouth are part of this hole too —
108
+ // countersink machining, not a separate feature — so they're folded into
109
+ // `surfaces` (and named in `evidence`) rather than silently dropped.
110
+ const viaCones = [...new Set(mouths.filter((m) => m.via).map((m) => m.via.id))];
111
+
112
+ out.push({
113
+ id: null,
114
+ key: `hole:${round3(diameter)}:${round3(origin[0])},${round3(origin[1])},${round3(origin[2])}`,
115
+ type, diameter, depth,
116
+ axis: { origin, direction: dir },
117
+ entryFace, exitFace, floorFace,
118
+ surfaces: [s.id, ...viaCones],
119
+ // What the rule actually saw. The report carries this so a wrong call can be
120
+ // argued with rather than merely disbelieved.
121
+ evidence: {
122
+ curvature: s.curvature, planarMouths: mouths.length, arcs: arcs.length, fitRms: s.fit.rms,
123
+ countersunk: viaCones.length > 0, viaCones,
124
+ },
125
+ });
126
+ }
127
+
128
+ return out.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
129
+ }