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,233 @@
1
+ // Revolve and uniform-wall shell rules — the two additions past the prismatic core
2
+ // (spec decisions table), both mapping directly onto partforge ops that already exist.
3
+ //
4
+ // REVOLVE: every surface's own axis is collinear with one shared axis. Turned parts,
5
+ // vases, and washers all satisfy this, and when they do, a revolve of the axial
6
+ // half-profile is a far better parameterisation than a stack of extrusions. Two
7
+ // families of surface feed the vote: cylinders/cones/tori contribute their own fitted
8
+ // axis directly, and planes perpendicular to the winning axis (the caps a lathe cuts
9
+ // square to the spindle) count as agreeing too rather than as evidence against — a
10
+ // plain cylinder's end caps are exactly this case. This is a candidate reading, not an
11
+ // exclusive one: the same cylinder that reads as a revolve here also reads as an
12
+ // extrusion in prismatic.js, and reconciling overlapping readings across families is a
13
+ // later stage's job, not this rule's.
14
+ //
15
+ // SHELL: this is the hardest detector in the vocabulary and the spec names it as the
16
+ // first thing to cut if v1 runs long — so it is written conservatively and reports
17
+ // NOTHING when it is not confident, which is always the safe direction: a missed shell
18
+ // is residual, an invented shell is a lie the agent will build against.
19
+ //
20
+ // MEASURED BY RAYCAST, not by pairing anti-parallel planes at a matching offset — a
21
+ // first version tried the pairing approach and it was wrong in BOTH directions,
22
+ // verified directly against fixtures (numbers below, at each threshold constant): it
23
+ // called `boxMesh(10,10,10)` (a solid cube) a shell, because a
24
+ // cube's three anti-parallel plane pairs happen to share one gap purely because a
25
+ // cube's three dimensions happen to be equal — the pairing test has no notion of
26
+ // "thin", only "consistent", and a solid's own consistent full-size gap satisfies it
27
+ // exactly as well as a genuine wall thickness would. And it missed every GENUINE
28
+ // uniform-wall shell, because pairing every anti-parallel plane combination on a real
29
+ // shell (inner AND outer walls both present) also produces the outer envelope's own
30
+ // gaps and the inner cavity's own gaps alongside the true wall-thickness gaps, and no
31
+ // spread threshold can separate "these three gaps are one shell" from "these three
32
+ // gaps are the cube's three dimensions" using gap values alone — both are perfectly
33
+ // self-consistent for the wrong reason.
34
+ //
35
+ // The fix: measure actual material thickness, the way `min-wall.js` already does for
36
+ // the whole mesh, at the scale of one SURFACE rather than one triangle. For each plane
37
+ // surface, cast a ray from a handful of its own face centroids inward (reverse of its
38
+ // own outward normal) into `bvh.raycast` and take the nearest hit as that surface's own
39
+ // local wall reading. A genuine shell reads the SAME small distance from nearly every
40
+ // plane; a solid does not read "inconsistent" this way (a cube reads a perfectly
41
+ // consistent full 10mm from every one of its six faces) — which is why a SECOND,
42
+ // independent gate is required: the reading must also be THIN relative to the part's
43
+ // own size (bbox diagonal), not merely consistent. Two-part gate: (1) the large
44
+ // majority of per-plane readings agree with each other (a shell's own CLOSING faces —
45
+ // a tray's rim, see `openTrayMesh` — read a wildly different, unrelated distance along
46
+ // their own normal, since their normal points along the wall's run rather than across
47
+ // its thickness, so "majority" rather than "all" is deliberate); (2) that agreed value
48
+ // is small relative to the part's own bbox diagonal. Both thresholds were picked
49
+ // against real measurements on this file's own fixtures, not guessed — see the
50
+ // constants below.
51
+ //
52
+ // Requires `topo` (the welded mesh topology `buildTopology` produces) to raycast
53
+ // against; omit it and this half of the rule is skipped rather than guessing (the
54
+ // revolve rule above needs no mesh access and still runs).
55
+ //
56
+ // `opts.bvh`, if given, is used instead of building one here — the orchestrator
57
+ // (describe.js, Task 12 / ruling R39) builds exactly ONE BVH per describe() call
58
+ // and passes it down, since buildBVH is O(n log n) over the WHOLE mesh regardless
59
+ // of how few rays a caller casts (measured 9.8ms at 10.8k triangles, 48ms at
60
+ // 43k) and this rule's own handful of per-plane rays would otherwise pay that
61
+ // cost again for nothing. Falls back to building its own so every existing
62
+ // `detectSweeps(graph, topo)` call (this file's own tests included) keeps working
63
+ // unchanged.
64
+ //
65
+ // Pure leaf. See spec §2.5.
66
+ import { intrinsicScale } from "../fit.js";
67
+ import { buildBVH } from "../../bvh.js";
68
+
69
+ // "Same axis" band for the revolve vote.
70
+ const COLLINEAR_DOT = 0.995;
71
+ // A per-plane thickness reading counts as agreeing with the shell's own median if it
72
+ // is within this fraction of it. Measured on `hollowBoxMesh(20,20,20,2)` (every one of
73
+ // 12 planes reads exactly 2mm, 0% spread) and `openTrayMesh(20,20,10,2)` (10 of 11
74
+ // planes read exactly 2mm; the rim reads ~10mm, 400% off) — a band this tight only
75
+ // ever excludes the rim's own genuine outlier, never a true wall reading.
76
+ const SHELL_READING_AGREE_FRAC = 0.1;
77
+ // How much of a candidate shell's own plane readings must agree (see above) for the
78
+ // shell to be reported at all. Measured: `hollowBoxMesh` clears 12/12 = 100%;
79
+ // `openTrayMesh` clears 10/11 = 90.9% (the rim is the one dissenting reading, exactly
80
+ // as its own geometry predicts); `boxMesh(10,10,30)` (a non-cube solid, three
81
+ // different gaps) clears only 4/6 = 66.7% (its two 30mm faces dissent from the
82
+ // four 10mm ones). Set at 85%, comfortably below the tray's 90.9% and above the
83
+ // non-cube solid's 66.7%.
84
+ const SHELL_INLIER_FRAC = 0.85;
85
+ // The agreed thickness must be small relative to the part's own size, or "consistent"
86
+ // is just describing a solid. Originally measured against a world/PCA bbox diagonal:
87
+ // `boxMesh(10,10,10)` (a solid cube) reads a perfectly consistent 10mm from all six
88
+ // faces — 10 / (10*sqrt(3)) = 57.7% of its own bbox diagonal. `hollowBoxMesh(20,20,20,2)`
89
+ // reads 2 / (20*sqrt(3)) = 5.8%; `openTrayMesh(20,20,10,2)` reads 2 / 30 = 6.7% (bbox
90
+ // diagonal sqrt(20^2+20^2+10^2) = 30). Set at 25% against those numbers, comfortably
91
+ // below the cube's 57.7% and well above either genuine shell's own reading.
92
+ //
93
+ // Retuned (fix round 5, ONE-TIME DELIBERATE, not an invariant — see `FIT_TOL_FRAC`'s
94
+ // comment in segment.js for the full reasoning, which applies identically here):
95
+ // `diag` (this file, below) is `intrinsicFrame`'s `diagonal`, now a bare radius of
96
+ // gyration with no calibration folded in, reading smaller than the bbox-diagonal
97
+ // figures above on the same mesh. `0.25 * 1.0879082239773115 = 0.271977` carries this
98
+ // constant's original tuning forward onto the new scale, measured on the same
99
+ // reference shape `FIT_TOL_FRAC` uses; it is not a claim that every part's shell
100
+ // gate is unchanged.
101
+ const SHELL_MAX_RELATIVE_THICKNESS = 0.271977;
102
+ // Sample budget per plane surface — a handful of its own faces, not every one (that is
103
+ // min-wall.js's job, at whole-mesh scale, with its own much larger budget). Once a
104
+ // surface is already known to be one plane, a few widely-spread samples are enough to
105
+ // catch it reading something other than its neighbours' consistent value.
106
+ const MAX_SAMPLES_PER_PLANE = 5;
107
+ const round3 = (v) => Math.round(v * 1000) / 1000;
108
+ const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
109
+
110
+ const medianOf = (xs) => {
111
+ const s = [...xs].sort((a, b) => a - b);
112
+ const m = s.length >> 1;
113
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
114
+ };
115
+
116
+ function faceCentroid(topo, t) {
117
+ const c = [0, 0, 0];
118
+ for (let k = 0; k < 3; k++) {
119
+ const v = topo.tris[3*t + k] * 3;
120
+ c[0] += topo.verts[v]/3; c[1] += topo.verts[v+1]/3; c[2] += topo.verts[v+2]/3;
121
+ }
122
+ return c;
123
+ }
124
+
125
+ // This plane surface's own local wall reading: cast a ray from a spread of its own
126
+ // face centroids along the reverse of its fitted normal (straight into whatever
127
+ // material or cavity lies immediately behind it — `bvh.raycast`, the same primitive
128
+ // min-wall.js casts one of per triangle) and take the MEDIAN hit distance. `skipTri`
129
+ // keeps the originating triangle from reporting a spurious zero-distance self-hit.
130
+ function planeInwardThickness(topo, bvh, surf) {
131
+ const faces = surf.faces, n = faces.length;
132
+ if (n === 0) return null;
133
+ const step = Math.max(1, Math.floor(n / MAX_SAMPLES_PER_PLANE));
134
+ const nrm = surf.fit.normal, dir = [-nrm[0], -nrm[1], -nrm[2]];
135
+ const dists = [];
136
+ for (let i = 0; i < n; i += step) {
137
+ const t = faces[i];
138
+ const hit = bvh.raycast(faceCentroid(topo, t), dir, { skipTri: t });
139
+ if (hit) dists.push(hit.t);
140
+ }
141
+ return dists.length ? medianOf(dists) : null;
142
+ }
143
+
144
+ // This surface's own axis, if it has one — the vote unit for the revolve rule.
145
+ const axisOf = (s) =>
146
+ s.type === "cylinder" ? s.fit.axis.direction :
147
+ s.type === "cone" ? s.fit.direction :
148
+ s.type === "torus" ? s.fit.axis : null;
149
+
150
+ export function detectSweeps(graph, topo, opts = {}) {
151
+ const out = [];
152
+
153
+ // --- revolve ---------------------------------------------------------------
154
+ const axial = graph.surfaces.map((s) => ({ s, a: axisOf(s) })).filter((x) => x.a);
155
+ if (axial.length >= 1) {
156
+ // Vote: the axis most surfaces agree with, weighted by area. Antipodal directions
157
+ // are the same axis, so compare on |dot|.
158
+ let best = null;
159
+ for (const cand of axial) {
160
+ const agree = axial.filter((x) => Math.abs(dot(x.a, cand.a)) > COLLINEAR_DOT);
161
+ const area = agree.reduce((t, x) => t + x.s.area, 0);
162
+ if (!best || area > best.area) best = { axis: cand.a, agree, area };
163
+ }
164
+ const axialArea = best ? best.area : 0;
165
+ const total = graph.surfaces.reduce((t, s) => t + s.area, 0);
166
+ // Planes perpendicular to the axis (caps) are consistent with a revolve too, so
167
+ // count their area as agreeing rather than as evidence against.
168
+ const capArea = graph.surfaces
169
+ .filter((s) => s.type === "plane" && best && Math.abs(dot(s.fit.normal, best.axis)) > COLLINEAR_DOT)
170
+ .reduce((t, s) => t + s.area, 0);
171
+ if (best && (axialArea + capArea) / total > 0.9) {
172
+ const origin = best.agree[0].s.type === "cylinder" ? best.agree[0].s.fit.axis.origin
173
+ : best.agree[0].s.type === "cone" ? best.agree[0].s.fit.apex
174
+ : best.agree[0].s.fit.center;
175
+ out.push({
176
+ id: null,
177
+ key: `revolve:${best.axis.map(round3).join(",")}`,
178
+ type: "revolve",
179
+ axis: { origin, direction: best.axis },
180
+ profile: { kind: "mixed" },
181
+ surfaces: best.agree.map((x) => x.s.id),
182
+ evidence: { axialAreaFraction: round3((axialArea + capArea) / total), agreeing: best.agree.length },
183
+ });
184
+ }
185
+ }
186
+
187
+ // --- shell -----------------------------------------------------------------
188
+ // See the header for why this measures actual material thickness by raycast rather
189
+ // than pairing anti-parallel planes. Skipped entirely without `topo` — no mesh, no
190
+ // rays to cast — rather than falling back to a method already found wrong twice.
191
+ if (topo) {
192
+ const planes = graph.surfaces.filter((s) => s.type === "plane");
193
+ if (planes.length >= 3) {
194
+ const bvh = opts.bvh ?? buildBVH({ positions: topo.verts, indices: topo.tris });
195
+ const readingsBySurface = planes
196
+ .map((s) => ({ s, reading: planeInwardThickness(topo, bvh, s) }))
197
+ .filter((r) => r.reading != null);
198
+ if (readingsBySurface.length >= 3) {
199
+ const readings = readingsBySurface.map((r) => r.reading);
200
+ const median = medianOf(readings);
201
+ // Agreement against the MEDIAN, not the mean — a shell's own closing faces
202
+ // (see header) can read far enough off that a mean would already be dragged
203
+ // away from the true wall value before anything gets compared to it.
204
+ const inliers = median > 0
205
+ ? readingsBySurface.filter((r) => Math.abs(r.reading - median) / median < SHELL_READING_AGREE_FRAC)
206
+ : [];
207
+ const inlierFrac = readingsBySurface.length ? inliers.length / readingsBySurface.length : 0;
208
+ // intrinsicScale(), not a plain world-axis min/max: this gate gets MORE
209
+ // permissive (a smaller relativeThickness) when a naive AABB diagonal
210
+ // inflates under rotation, which is the identical failure mode fit.js's own
211
+ // comment documents — here it would make shell detection accept a thicker
212
+ // wall as "thin" purely because the part is tilted, not because anything
213
+ // about the wall changed.
214
+ const diag = intrinsicScale(topo.verts);
215
+ if (median > 0 && inlierFrac >= SHELL_INLIER_FRAC && diag > 0 && median / diag < SHELL_MAX_RELATIVE_THICKNESS) {
216
+ const thickness = medianOf(inliers.map((r) => r.reading));
217
+ out.push({
218
+ id: null,
219
+ key: `shell:${round3(thickness)}`,
220
+ type: "shell", thickness,
221
+ surfaces: inliers.map((r) => r.s.id),
222
+ evidence: {
223
+ planesSampled: readingsBySurface.length, agreeing: inliers.length,
224
+ inlierFraction: round3(inlierFrac), relativeThickness: round3(thickness / diag),
225
+ },
226
+ });
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ return out;
233
+ }