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.
@@ -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 meshTriangles).
7
- import { buildBVH, meshTriangles } from "./bvh.js";
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
- export function minWall(mesh, { maxThickness } = {}) {
10
- const pos = mesh.positions;
11
- const tris = meshTriangles(mesh);
12
- if (tris.length === 0) return null;
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
- // bbox diagonal as the default cap (a ray exiting into open air gets no hit anyway).
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 min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
17
- for (let i = 0; i < pos.length; i += 3) for (let a = 0; a < 3; a++) { if (pos[i + a] < min[a]) min[a] = pos[i + a]; if (pos[i + a] > max[a]) max[a] = pos[i + a]; }
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
- const bvh = buildBVH(mesh);
22
- let best = Infinity, loc = null;
23
- for (let t = 0; t < tris.length; t++) {
24
- const [v0, v1, v2] = tris[t];
25
- const e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
26
- const e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
27
- let nx = e1[1] * e2[2] - e1[2] * e2[1], ny = e1[2] * e2[0] - e1[0] * e2[2], nz = e1[0] * e2[1] - e1[1] * e2[0];
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 = [(v0[0] + v1[0] + v2[0]) / 3, (v0[1] + v1[1] + v2[1]) / 3, (v0[2] + v1[2] + v2[2]) / 3];
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
- return best === Infinity ? null : { value: best, location: loc };
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
  }
@@ -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
- return { ...base, actual, status: "warn", pass: null, message: "min wall unavailable",
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
- export function verify(kernel, part, { process, view, measureFn = defaultMeasure } = {}) {
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 }));