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