partforge 0.39.0 → 0.41.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/README.md +19 -0
- package/bin/cli.js +3 -0
- package/docs/AUTHORING-PARTS.md +17 -1
- package/package.json +1 -1
- package/src/framework/cutaway-gizmo.js +16 -2
- package/src/framework/cutaway-math.js +50 -1
- package/src/framework/cutaway-outline.js +233 -0
- package/src/framework/cutaway-render.js +20 -0
- package/src/framework/cutaway.js +47 -1
- package/src/framework/jobs.js +14 -2
- package/src/framework/mount.js +21 -1
- package/src/framework/verify-metrics.js +15 -2
- package/src/framework/viewer.js +84 -5
- package/src/testing/bvh.js +296 -106
- package/src/testing/gaps.js +6 -3
- package/src/testing/measure.js +33 -3
- package/src/testing/min-wall.js +80 -20
- package/src/testing/verify.js +58 -2
package/src/testing/min-wall.js
CHANGED
|
@@ -3,36 +3,96 @@
|
|
|
3
3
|
// voxel/SDF approach on both accuracy and speed). For each surface triangle, cast a ray
|
|
4
4
|
// inward (reverse of its outward normal) from the centroid; the nearest hit is the local
|
|
5
5
|
// material thickness. The minimum across samples is the reported min wall.
|
|
6
|
-
// Works with both Manifold non-indexed meshes and OCCT indexed meshes (via
|
|
7
|
-
|
|
6
|
+
// Works with both Manifold non-indexed meshes and OCCT indexed meshes (via the BVH's
|
|
7
|
+
// flat vertex store — never materialize a triangle-per-object list here, that is the
|
|
8
|
+
// allocation this pass exists to avoid).
|
|
9
|
+
//
|
|
10
|
+
// SAMPLING CONTRACT. One ray per triangle is unbounded work, and a dense mesh makes it
|
|
11
|
+
// the dominant cost of the inspect job: ~1.9 s and hundreds of megabytes of transient
|
|
12
|
+
// garbage at 400k triangles on a laptop, several times that on a phone. Past
|
|
13
|
+
// MAX_SAMPLES triangles the pass casts from a spread subset instead, and SAYS SO —
|
|
14
|
+
// the result always carries { sampled, sampledTriangles, totalTriangles }, so a
|
|
15
|
+
// report consumer can tell a guaranteed minimum from a lower-confidence one. A
|
|
16
|
+
// sampled reading is an upper bound on the true minimum: it can miss a thin spot,
|
|
17
|
+
// never invent one. `sampledTriangles` is the SAMPLE BUDGET — how many triangles
|
|
18
|
+
// the walk selected — not a count of rays actually cast: a degenerate (zero-area)
|
|
19
|
+
// triangle has no normal to cast along and is skipped without a ray.
|
|
20
|
+
//
|
|
21
|
+
// Only an EMPTY mesh reads as no result at all (`null`). A mesh whose sampled rays
|
|
22
|
+
// all miss returns the usual object with `value: null`, because the sampling
|
|
23
|
+
// accounting is exactly what a reader needs in that case — "we looked at 50k of
|
|
24
|
+
// 400k triangles and found no wall" is a very different statement from "nobody
|
|
25
|
+
// measured", and the two used to be indistinguishable downstream.
|
|
26
|
+
import { buildBVH, readTriangleInto } from "./bvh.js";
|
|
8
27
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
28
|
+
// Triangle budget above which minWall samples. Chosen so the parts people actually
|
|
29
|
+
// author stay exact: everything in src/parts/ is 200–10,000 triangles, and a
|
|
30
|
+
// preview-quality mesh of a fairly ornate part lands in the low tens of thousands.
|
|
31
|
+
// 50,000 is comfortably above both while capping the pass at roughly a quarter
|
|
32
|
+
// second — dense enough meshes (a high-facet lathe, a big imported STEP tessellation)
|
|
33
|
+
// are the only ones that engage it. Override per call with `{ maxSamples }`.
|
|
34
|
+
const MAX_SAMPLES = 50_000;
|
|
13
35
|
|
|
14
|
-
|
|
36
|
+
const gcd = (a, b) => { while (b) { const t = a % b; a = b; b = t; } return a; };
|
|
37
|
+
|
|
38
|
+
// Stride for the sampling walk: near n/φ and coprime to n, so stepping by it visits
|
|
39
|
+
// a permutation of the triangle list — the first `budget` steps are distinct and, by
|
|
40
|
+
// the three-distance theorem, near-uniformly spread over the WHOLE mesh (measured
|
|
41
|
+
// max gap on a 480-triangle mesh sampled 100 times: 8). A contiguous slice would
|
|
42
|
+
// read one region of the surface, and a plain n/budget stride can beat against a
|
|
43
|
+
// mesh's own periodicity (a lathed part's segment count) and sample one side of it.
|
|
44
|
+
// No RNG anywhere, so the same mesh always reads the same wall.
|
|
45
|
+
function sampleStride(n) {
|
|
46
|
+
let s = Math.max(1, Math.round(n * 0.6180339887498949)) % n || 1;
|
|
47
|
+
while (gcd(s, n) !== 1) s = s + 1 < n ? s + 1 : 1;
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// `bvh` is an already-built index for THIS mesh — one mesh, one index, so there is
|
|
52
|
+
// nothing to key here: measure() resolves it out of the Map it shares with meshGaps
|
|
53
|
+
// (see cachedBVH for why that Map is the caller's) and passes the value. Omit it and
|
|
54
|
+
// one is built. It does not interact with sampling — sampling picks WHICH rays to
|
|
55
|
+
// cast, not how the index is built, so a shared BVH is equally valid sampled or exact.
|
|
56
|
+
export function minWall(mesh, { maxThickness, maxSamples = MAX_SAMPLES, bvh = buildBVH(mesh) } = {}) {
|
|
57
|
+
const n = bvh.triangleCount;
|
|
58
|
+
if (n === 0) return null;
|
|
59
|
+
const V = bvh.vertices;
|
|
60
|
+
|
|
61
|
+
// bbox diagonal as the default cap (a ray exiting into open air gets no hit
|
|
62
|
+
// anyway). The BVH's root node bounds ARE that box, already computed — rescanning
|
|
63
|
+
// mesh.positions would be an O(n) pass on the hot path for a number we have. (On
|
|
64
|
+
// an indexed mesh they are also marginally tighter, since unreferenced vertices
|
|
65
|
+
// are not in the tree; that only shrinks a ray cap, never a reading.)
|
|
15
66
|
if (maxThickness == null) {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
maxThickness = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) + 1;
|
|
67
|
+
const rb = bvh.rootBounds;
|
|
68
|
+
maxThickness = Math.hypot(rb[3] - rb[0], rb[4] - rb[1], rb[5] - rb[2]) + 1;
|
|
19
69
|
}
|
|
20
70
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
71
|
+
// `maxSamples: 0` (or any non-positive) is the explicit "no cap, cast everything"
|
|
72
|
+
// escape hatch — the exact reading, however long it takes.
|
|
73
|
+
const budget = maxSamples > 0 && n > maxSamples ? Math.floor(maxSamples) : n;
|
|
74
|
+
const sampled = budget < n;
|
|
75
|
+
const stride = sampled ? sampleStride(n) : 1; // stride 1 = every triangle, in mesh order
|
|
76
|
+
|
|
77
|
+
let best = Infinity, loc = null, t = 0;
|
|
78
|
+
const tri = new Float64Array(9); // reused per triangle; no per-ray garbage
|
|
79
|
+
for (let s = 0; s < budget; s++, t = t + stride < n ? t + stride : t + stride - n) {
|
|
80
|
+
readTriangleInto(V, t, tri);
|
|
81
|
+
const v0x = tri[0], v0y = tri[1], v0z = tri[2];
|
|
82
|
+
const e1x = tri[3] - v0x, e1y = tri[4] - v0y, e1z = tri[5] - v0z;
|
|
83
|
+
const e2x = tri[6] - v0x, e2y = tri[7] - v0y, e2z = tri[8] - v0z;
|
|
84
|
+
let nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
|
|
28
85
|
const len = Math.hypot(nx, ny, nz);
|
|
29
|
-
if (len < 1e-9) continue; // degenerate triangle
|
|
86
|
+
if (len < 1e-9) continue; // degenerate triangle: no normal, no ray
|
|
30
87
|
nx /= len; ny /= len; nz /= len; // outward normal (manifold winding)
|
|
31
|
-
const c = [(
|
|
88
|
+
const c = [(v0x + tri[3] + tri[6]) / 3, (v0y + tri[4] + tri[7]) / 3, (v0z + tri[5] + tri[8]) / 3];
|
|
32
89
|
const dir = [-nx, -ny, -nz]; // inward
|
|
33
90
|
const origin = [c[0] + dir[0] * 1e-4, c[1] + dir[1] * 1e-4, c[2] + dir[2] * 1e-4];
|
|
34
91
|
const hit = bvh.raycast(origin, dir, { tMax: maxThickness, skipTri: t });
|
|
35
92
|
if (hit && hit.t < best) { best = hit.t; loc = c; }
|
|
36
93
|
}
|
|
37
|
-
|
|
94
|
+
// No hit anywhere still reports HOW it looked (see the header): a `value: null`
|
|
95
|
+
// with the sampling accounting intact, never a bare null that reads downstream as
|
|
96
|
+
// "min wall was never measured".
|
|
97
|
+
return { value: best === Infinity ? null : best, location: loc, sampled, sampledTriangles: budget, totalTriangles: n };
|
|
38
98
|
}
|
package/src/testing/verify.js
CHANGED
|
@@ -118,14 +118,23 @@ function check(scope, subpart, metric, spec, registry, factsObj) {
|
|
|
118
118
|
if (actual === null || actual === undefined) {
|
|
119
119
|
if (reg.manifoldOnly) return { ...base, actual, status: "skip", pass: null, message: "n/a (OCCT backend)" };
|
|
120
120
|
if (metric === "minWall") {
|
|
121
|
-
|
|
121
|
+
const out = { ...base, actual, status: "warn", pass: null, message: "min wall unavailable",
|
|
122
122
|
hint: partHint ?? "no min-wall reading for this mesh — treat thin features as unverified" };
|
|
123
|
+
// A missing reading still has a HOW: a sampled run whose rays all missed says
|
|
124
|
+
// so here, rather than reading like a mesh min-wall never looked at.
|
|
125
|
+
const note = reg.note?.(factsObj);
|
|
126
|
+
if (note) out.note = note;
|
|
127
|
+
return out;
|
|
123
128
|
}
|
|
124
129
|
return { ...base, actual, status: "skip", pass: null, message: "unavailable" };
|
|
125
130
|
}
|
|
126
131
|
const { pass, message } = evaluateAssertion(parseAssertion(expr), actual);
|
|
127
132
|
const status = pass ? "pass" : reg.kind === "warn" ? "warn" : "fail";
|
|
128
133
|
const out = { ...base, actual, status, pass, message };
|
|
134
|
+
// A measurement caveat rides along whatever the verdict — a min-wall reading
|
|
135
|
+
// taken from a sample still passed, but the reader should know it was a sample.
|
|
136
|
+
const note = reg.note?.(factsObj);
|
|
137
|
+
if (note) out.note = note;
|
|
129
138
|
if (!pass) {
|
|
130
139
|
out.hint = partHint ?? reg.hint;
|
|
131
140
|
if (reg.pattern) out.pattern = reg.pattern;
|
|
@@ -158,7 +167,10 @@ export function evaluateCase(facts, { profile, expect, subPartNames }) {
|
|
|
158
167
|
return checks;
|
|
159
168
|
}
|
|
160
169
|
|
|
161
|
-
|
|
170
|
+
// `seed` lets a caller that has ALREADY measured this part hand the result in so
|
|
171
|
+
// verify does not recompute it — see the seeding block below for the shape and
|
|
172
|
+
// the one correctness rule that governs it.
|
|
173
|
+
export function verify(kernel, part, { process, view, measureFn = defaultMeasure, seed } = {}) {
|
|
162
174
|
view = view ?? Object.keys(part.views)[0];
|
|
163
175
|
const profileSpec = process ?? part.verify?.process;
|
|
164
176
|
const profile = profileSpec ? resolveProfile(profileSpec) : null;
|
|
@@ -185,6 +197,50 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
185
197
|
: [...readKeys.entries()].map(([name, keys]) => `${name}:${relevanceHash([...keys], params)}`).join("|");
|
|
186
198
|
|
|
187
199
|
const memo = new Map();
|
|
200
|
+
|
|
201
|
+
// SEEDING. expandCases always yields a "defaults" case, and the inspect job
|
|
202
|
+
// (framework/jobs.js) measures those exact params immediately before calling
|
|
203
|
+
// verify — so without this the oracle rebuilds the same geometry, casts the
|
|
204
|
+
// same min-wall rays and re-indexes the same meshes a second time. On a
|
|
205
|
+
// single-case part that is half the job.
|
|
206
|
+
// seed = { params, result } — the params the result was measured with, and
|
|
207
|
+
// the measure() output itself. Nothing else: every fact the rule below needs
|
|
208
|
+
// is read off the artifact, so a caller cannot assert it wrongly.
|
|
209
|
+
//
|
|
210
|
+
// THE MIN-WALL SUPERSET RULE, which is the trap here. measureCase asks for
|
|
211
|
+
// `{ minWall: needMinWall }`, and needMinWall is false whenever no profile and
|
|
212
|
+
// no expectation mentions min wall. A result measured WITH min wall is a strict
|
|
213
|
+
// superset of one measured without: the extra fields are only ever read by the
|
|
214
|
+
// minWall metric, which by definition this run never checks. Reuse in that
|
|
215
|
+
// direction is free. The reverse is NOT safe — a seed taken without min wall
|
|
216
|
+
// carries `minWall: null` on every sub-part, which the registry reports as
|
|
217
|
+
// "min wall unavailable", silently downgrading a real gate to a warning. So the
|
|
218
|
+
// seed is consulted only when `seed.result.measuredMinWall || !needMinWall` —
|
|
219
|
+
// and `measuredMinWall` is stamped by measure() itself, not claimed by whoever
|
|
220
|
+
// holds the result. Otherwise the seed is ignored and the case measured properly.
|
|
221
|
+
//
|
|
222
|
+
// ALIASING. A consulted seed is memoized BY REFERENCE, so the caller's result
|
|
223
|
+
// and every case that hits it are the same object — the inspect job's
|
|
224
|
+
// `report.measure` and `report.verify.cases[0]`'s facts included. Nothing here
|
|
225
|
+
// mutates facts (evaluateCase only reads), and that is what makes the sharing
|
|
226
|
+
// safe; a future check that wants to annotate a fact must copy first.
|
|
227
|
+
//
|
|
228
|
+
// Keyed through the SAME signature() the memo uses, never a JSON compare of the
|
|
229
|
+
// raw params — a separate compare would miss cases that share a signature (a
|
|
230
|
+
// preset touching only params the build never reads) and, worse, could hit on
|
|
231
|
+
// params that merely look equal. The seed's params are layered over
|
|
232
|
+
// part.defaults first because a caller's `{}` and the defaults case's
|
|
233
|
+
// `{...part.defaults}` build identical geometry but hash differently
|
|
234
|
+
// (JSON.stringify({}) is not JSON.stringify(defaults), and relevanceHash reads
|
|
235
|
+
// params[k] straight through). The view is checked too: measure()'s output is
|
|
236
|
+
// per-view, and a seed from another view would be a silent wrong answer.
|
|
237
|
+
//
|
|
238
|
+
// Non-default measure options (a custom `gapThreshold`) are the caller's
|
|
239
|
+
// responsibility: seed only a measurement taken the way verify would take it.
|
|
240
|
+
if (seed?.result && (seed.result.measuredMinWall || !needMinWall) && seed.result.view === view) {
|
|
241
|
+
memo.set(signature({ ...part.defaults, ...(seed.params ?? {}) }), seed.result);
|
|
242
|
+
}
|
|
243
|
+
|
|
188
244
|
const measureCase = (params) => {
|
|
189
245
|
const key = signature(params);
|
|
190
246
|
if (!memo.has(key)) memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall }));
|