partforge 0.81.0 → 0.83.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.
@@ -1,5 +1,6 @@
1
1
  import { helixTube } from "./helix-tube.js";
2
2
  import { loftMesh } from "./loft.js";
3
+ import { resolveLoftRings, loftRingsKey } from "./loft-rings.js";
3
4
  import { sweepMesh } from "./sweep.js";
4
5
  import { roundedBoxRings } from "./rounded-solids.js";
5
6
  import { tessellateContour, tessellateProfile } from "./profile.js";
@@ -434,6 +435,34 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
434
435
  const g0 = m.getMesh();
435
436
  const isBlend = (oid) => !!oidPolicies.get(oid)?.boundaryLines;
436
437
  const oids0 = new Set(g0.runOriginalID);
438
+ // Sector-aware re-stamp: a provenance-sectored loft (the `sector` policy
439
+ // marker) must keep every run's identity — folding them (either the plain
440
+ // asOriginal below or the blend path's two-group stamp) would erase the
441
+ // sector creases and dividing lines the runs exist to draw. Re-stamp every
442
+ // distinct id 1:1 onto fresh reserved ids: each keeps its policy (blend
443
+ // runs riding along keep BLEND), all map to the one label string, and the
444
+ // fresh ids give the same reuse-under-another-label guarantee as
445
+ // asOriginal(). Unregistered ids (plain boolean tools) stay unregistered —
446
+ // they shade SMOOTH by default exactly as before.
447
+ if ([...oids0].some((oid) => !!oidPolicies.get(oid)?.sector)) {
448
+ const olds = [...oids0];
449
+ const base2 = Manifold.reserveIDs(olds.length);
450
+ const mapId = new Map(olds.map((o2, i2) => [o2, base2 + i2]));
451
+ g0.runOriginalID = Uint32Array.from(g0.runOriginalID, (o2) => mapId.get(o2));
452
+ const o = T(new Manifold(g0));
453
+ g0.delete?.();
454
+ const setIds = [];
455
+ for (const [oldId, newId] of mapId) {
456
+ featureLabels.set(newId, name);
457
+ const pol = oidPolicies.get(oldId);
458
+ if (pol !== undefined) oidPolicies.set(newId, pol);
459
+ setIds.push(newId);
460
+ }
461
+ return { value: wrap(o, lh), pin: o, dispose: () => {
462
+ for (const id2 of setIds) { featureLabels.delete(id2); oidPolicies.delete(id2); }
463
+ o.delete?.();
464
+ } };
465
+ }
437
466
  if ([...oids0].some(isBlend)) {
438
467
  const baseId = Manifold.reserveIDs(2), blendId = baseId + 1;
439
468
  // base-group policy: the same triangle-weighted majority vote as the plain
@@ -605,16 +634,29 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
605
634
  // Ring loft: hand-meshed via the shared ring-mesh helpers (helix-tube recipe).
606
635
  // Cached atomically; the hash folds every ring's points/z/rotate/scale and the
607
636
  // opts (including `shading`, so toggling the hint is a fresh cache node).
608
- // asOriginal() stamps a stable originalID; the shading policy (inferred from
609
- // the rings, or forced by `shading`) registers under it for the crease pass
610
- // and lives exactly as long as the cache pins the solid.
637
+ // Sectored lofts (curve/resample rings with sharp features or silhouette kinks)
638
+ // come back already stamped with reserved per-run original IDs, whose policies
639
+ // loftMesh reports through the out-param register them all and never
640
+ // asOriginal() (it would fold the runs into one surface and erase every
641
+ // provenance crease). Unsectored lofts keep the legacy single-surface path:
642
+ // asOriginal() + one inferred policy. Either way the registrations live
643
+ // exactly as long as the cache pins the solid.
611
644
  loft: (rings, opts = {}) => {
612
- const key = h("loft", rings, opts);
645
+ const key = h("loft", loftRingsKey(rings), opts);
613
646
  return cache.lookup(key, () => {
614
- const raw = T(loftMesh(wasm, rings, opts));
647
+ const rl = resolveLoftRings(rings); // resolve once: mesh + shading share it
648
+ const runPol = new Map();
649
+ const raw = T(loftMesh(wasm, rl, opts, runPol));
650
+ if (runPol.size > 0) {
651
+ for (const [oid, pol] of runPol) oidPolicies.set(oid, pol);
652
+ return { value: wrap(raw, key), pin: raw, dispose: () => {
653
+ for (const oid of runPol.keys()) oidPolicies.delete(oid);
654
+ raw.delete?.();
655
+ } };
656
+ }
615
657
  const m = T(raw.asOriginal());
616
658
  const id = m.originalID();
617
- oidPolicies.set(id, loftShadingPolicy(rings, opts));
659
+ oidPolicies.set(id, loftShadingPolicy(rl, opts));
618
660
  return { value: wrap(m, key), pin: m, dispose: () => { oidPolicies.delete(id); m.delete?.(); } };
619
661
  });
620
662
  },
@@ -51,3 +51,19 @@ export function manifoldFromMesh(wasm, V, Tr) {
51
51
  mesh.delete?.(); // input mesh is consumed by ofMesh; free it (caller tracks `out`)
52
52
  return out;
53
53
  }
54
+
55
+ // Same import, but with the triangles partitioned into runs carrying pre-reserved
56
+ // original IDs (Manifold.reserveIDs), so downstream shading (creased-normals) can
57
+ // treat each run as its own surface with its own policy — and the partition
58
+ // survives every boolean, exactly like mesh-fillet's blend bands. `runIndex` is in
59
+ // triVert units (3 × triangle count, MeshGL convention), first entry 0, last Tr.length.
60
+ export function manifoldFromMeshRuns(wasm, V, Tr, runIndex, runOriginalID) {
61
+ const mesh = new wasm.Mesh({
62
+ numProp: 3, vertProperties: Float32Array.from(V), triVerts: Uint32Array.from(Tr),
63
+ runIndex: Uint32Array.from(runIndex), runOriginalID: Uint32Array.from(runOriginalID),
64
+ });
65
+ mesh.merge();
66
+ const out = wasm.Manifold.ofMesh(mesh);
67
+ mesh.delete?.();
68
+ return out;
69
+ }
@@ -23,7 +23,7 @@ import { finishKernel } from "./kernel-front.js";
23
23
  import { createOcctRepair } from "./occt-repair.js";
24
24
  import { occtRoundAll } from "./occt-roundall.js";
25
25
  import { classifyFaceGroups } from "./feature-attribution.js";
26
- import { resolveRings } from "./loft.js";
26
+ import { resolveLoftRings, loftRingsKey } from "./loft-rings.js";
27
27
  import { resolveSweepStations } from "./sweep.js";
28
28
  import { normalizeProfile } from "./profile.js";
29
29
  import { roundedRectContour } from "./rounded-solids.js";
@@ -423,13 +423,18 @@ export function createOcctKernel(replicad) {
423
423
  });
424
424
  };
425
425
 
426
- // ring loft: each ring becomes a closed polygon wire placed at its z (native loft closes
427
- // the ends for closed wires). closed:true loops are Manifold-only (replicad loft is open).
426
+ // ring loft: mode "curve" (structurally identical curve rings) lofts the ORIGINAL
427
+ // baked contours as true arc/spline wires STEP stays curve-exact; every other mode
428
+ // lofts the same resolved point rings the Manifold backend stitches, so parity is by
429
+ // construction (ThruSections' own wire-matching never gets to pick a different seam).
430
+ // closed:true loops are Manifold-only (replicad loft is open).
428
431
  const loftOp = (rings, { ruled = true, closed = false } = {}) => {
429
432
  if (closed) throw new Error("loft: closed:true loops are only supported on the Manifold backend");
430
- const key = h("loft", rings, ruled);
433
+ const key = h("loft", loftRingsKey(rings), ruled);
431
434
  return cached(key, () => {
432
- const wires = resolveRings(rings).map(({ pts2d, z }) => contourDrawing(pts2d).sketchOnPlane("XY", z).wire);
435
+ const { mode, resolved } = resolveLoftRings(rings);
436
+ const wires = resolved.map(({ pts2d, contour, z }) =>
437
+ (mode === "curve" ? contourDrawing(contour) : contourDrawing(pts2d)).sketchOnPlane("XY", z).wire);
433
438
  return wrap(loft(wires, { ruled }), [], key);
434
439
  });
435
440
  };
@@ -155,21 +155,34 @@ export function createValidatingProbe({ maxOps = MAX_PROBE_OPS } = {}) {
155
155
  }
156
156
 
157
157
  /**
158
- * Execute every sub-part's build() against a validating probe.
159
- * Never throws: a build error becomes an entry in `throws`, a runaway sets `runaway`.
158
+ * Execute every sub-part's build() and every declared probe (`part.probes`,
159
+ * same (k, p, d) contract, see oracle/measure.js) against a validating probe.
160
+ * Never throws: a build error becomes an entry in `throws` (probe entries land
161
+ * in `probeThrows`), a runaway sets `runaway`.
160
162
  */
161
163
  export function runValidatingProbe(part, p, d, { maxOps = MAX_PROBE_OPS } = {}) {
162
164
  const probe = createValidatingProbe({ maxOps });
163
165
  const throws = [];
166
+ const probeThrows = [];
164
167
  let runaway = false;
165
- for (const [name, sp] of Object.entries(part?.parts ?? {})) {
166
- if (typeof sp?.build !== "function") continue; // no-buildable-parts already reports this
168
+ const run = (fn, onThrow) => {
167
169
  try {
168
- sp.build(probe.kernel, p, d);
170
+ fn(probe.kernel, p, d);
169
171
  } catch (e) {
170
- if (e instanceof ProbeRunawayError) { runaway = true; break; }
171
- throws.push({ subpart: name, message: e?.message || String(e) });
172
+ if (e instanceof ProbeRunawayError) { runaway = true; return false; }
173
+ onThrow(e?.message || String(e));
174
+ }
175
+ return true;
176
+ };
177
+ for (const [name, sp] of Object.entries(part?.parts ?? {})) {
178
+ if (typeof sp?.build !== "function") continue; // no-buildable-parts already reports this
179
+ if (!run(sp.build, (m) => throws.push({ subpart: name, message: m }))) break;
180
+ }
181
+ if (!runaway) {
182
+ for (const [name, fn] of Object.entries(part?.probes ?? {})) {
183
+ if (typeof fn !== "function") continue; // invalid-probes already reports this
184
+ if (!run(fn, (m) => probeThrows.push({ probe: name, message: m }))) break;
172
185
  }
173
186
  }
174
- return { calls: probe.calls, issues: probe.issues, used: probe.used, solidUsed: probe.solidUsed, throws, runaway };
187
+ return { calls: probe.calls, issues: probe.issues, used: probe.used, solidUsed: probe.solidUsed, throws, probeThrows, runaway };
175
188
  }
@@ -53,6 +53,26 @@ export function normalizeProfile(profile) {
53
53
  return { outer, holes };
54
54
  }
55
55
 
56
+ // Solve the circle through (p0, via, p1) and the CCW-normalized sweep from p0 to p1
57
+ // that passes through `via`. Returns null for a collinear triple (callers emit a
58
+ // straight segment). Shared by sampleArc and loft-rings' fixed-count arc sampler.
59
+ export function arcGeometry(p0, via, p1) {
60
+ const [ax, ay] = p0, [bx, by] = via, [cx0, cy0] = p1;
61
+ const d = 2 * (ax * (by - cy0) + bx * (cy0 - ay) + cx0 * (ay - by));
62
+ if (Math.abs(d) < 1e-12) return null;
63
+ const sa = ax * ax + ay * ay, sb = bx * bx + by * by, sc = cx0 * cx0 + cy0 * cy0;
64
+ const cx = (sa * (by - cy0) + sb * (cy0 - ay) + sc * (ay - by)) / d;
65
+ const cy = (sa * (cx0 - bx) + sb * (ax - cx0) + sc * (bx - ax)) / d;
66
+ const r = Math.hypot(ax - cx, ay - cy);
67
+ const a0 = Math.atan2(ay - cy, ax - cx);
68
+ const av = Math.atan2(by - cy, bx - cx);
69
+ const a1 = Math.atan2(cy0 - cy, cx0 - cx);
70
+ const twoPi = 2 * Math.PI;
71
+ const ccw = (x) => { let v = x % twoPi; if (v < 0) v += twoPi; return v; };
72
+ const dCCW = ccw(a1 - a0), vCCW = ccw(av - a0);
73
+ return { cx, cy, r, a0, dA: vCCW <= dCCW ? dCCW : dCCW - twoPi };
74
+ }
75
+
56
76
  // Sample the circular arc through (p0, via, p1) — the three-point form roundedProfile
57
77
  // emits — into a point list p1…pN (EXCLUDING the start p0, which the ring already holds;
58
78
  // the last point is exactly p1). The circle is recovered from the circumcircle of the
@@ -63,27 +83,15 @@ export function normalizeProfile(profile) {
63
83
  // triple falls back to a single straight segment to p1 — the same "plain line" the OCCT
64
84
  // side gets when roundedProfile emits no `via`.
65
85
  export function sampleArc(p0, via, p1, segs) {
66
- const [ax, ay] = p0, [bx, by] = via, [cx, cy] = p1;
67
- const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
68
- if (Math.abs(d) < 1e-12) return [[cx, cy]]; // collinear → straight line
69
- const sa = ax * ax + ay * ay, sb = bx * bx + by * by, sc = cx * cx + cy * cy;
70
- const ux = (sa * (by - cy) + sb * (cy - ay) + sc * (ay - by)) / d;
71
- const uy = (sa * (cx - bx) + sb * (ax - cx) + sc * (bx - ax)) / d;
72
- const rr = Math.hypot(ax - ux, ay - uy);
73
- const a0 = Math.atan2(ay - uy, ax - ux);
74
- const av = Math.atan2(by - uy, bx - ux);
75
- const a1 = Math.atan2(cy - uy, cx - ux);
76
- const twoPi = 2 * Math.PI;
77
- const ccw = (x) => { let v = x % twoPi; if (v < 0) v += twoPi; return v; };
78
- const dCCW = ccw(a1 - a0), vCCW = ccw(av - a0);
79
- const dA = vCCW <= dCCW ? dCCW : dCCW - twoPi; // pick the sweep containing `via`
80
- const steps = Math.max(2, Math.ceil((segs * Math.abs(dA)) / twoPi));
86
+ const g = arcGeometry(p0, via, p1);
87
+ if (!g) return [[p1[0], p1[1]]]; // collinear straight line
88
+ const steps = Math.max(2, Math.ceil((segs * Math.abs(g.dA)) / (2 * Math.PI)));
81
89
  const out = [];
82
90
  for (let s = 1; s <= steps; s++) {
83
- const ang = a0 + dA * (s / steps);
84
- out.push([ux + rr * Math.cos(ang), uy + rr * Math.sin(ang)]);
91
+ const ang = g.a0 + g.dA * (s / steps);
92
+ out.push([g.cx + g.r * Math.cos(ang), g.cy + g.r * Math.sin(ang)]);
85
93
  }
86
- out[out.length - 1] = [cx, cy]; // pin the exact endpoint
94
+ out[out.length - 1] = [p1[0], p1[1]]; // pin the exact endpoint
87
95
  return out;
88
96
  }
89
97
 
@@ -17,6 +17,13 @@ export const FACETED = Object.freeze({ creaseAngle: 10, sameSurfaceLines: false
17
17
  // stay invisible while real mitre crossings still draw.
18
18
  export const BLEND = Object.freeze({ creaseAngle: 35, sameSurfaceLines: true, boundaryLines: true });
19
19
 
20
+ // Loft sector policies: identical crease/line behavior to SMOOTH/FACETED, plus the
21
+ // `sector` marker label() uses to preserve a sectored loft's runs 1:1 when it
22
+ // re-stamps original IDs (a plain asOriginal would fold the sectors into one
23
+ // surface and erase every provenance crease and dividing line).
24
+ export const LOFT_SECTOR_SMOOTH = Object.freeze({ ...SMOOTH, sector: true });
25
+ export const LOFT_SECTOR_FACETED = Object.freeze({ ...FACETED, sector: true });
26
+
20
27
  export const COPLANAR_ANGLE = 5; // deg — cut seams bending less than this are coplanar: no line
21
28
  export const TANGENT_ANGLE = 5; // deg — B-rep edges whose faces agree within this are tangent: no line
22
29
  export const MIN_EDGE = 0.01; // mm — drop shorter segments (degenerate slivers, pole edges)
@@ -36,18 +43,17 @@ export const SMOOTH_SIDES_MIN = 32;
36
43
 
37
44
  export const cosDeg = (deg) => Math.cos((deg * Math.PI) / 180);
38
45
 
39
- // Loft shading inference. An explicit `shading` hint wins; `ruled:false` asks
40
- // OCCT for a smoothly blended surface, so the Manifold preview of the same part
41
- // must shade smooth too; otherwise low-side-count rings are intentional facets.
42
- export function loftShadingPolicy(rings, { shading, ruled } = {}) {
46
+ // Loft shading inference over RESOLVED rings (resolveLoftRings' result). An explicit
47
+ // `shading` hint wins; `ruled:false` must preview smooth (it exports smooth via OCCT);
48
+ // any curved ring segment (arc/cubic) is smooth-surface intent; otherwise low
49
+ // resolved side counts are intentional facets.
50
+ export function loftShadingPolicy(resolvedLoft, { shading, ruled } = {}) {
43
51
  if (shading === "smooth") return SMOOTH;
44
52
  if (shading === "faceted") return FACETED;
45
53
  if (shading != null) throw new Error('loft: shading must be "smooth" | "faceted"');
46
54
  if (ruled === false) return SMOOTH;
55
+ if (resolvedLoft?.hasCurve) return SMOOTH;
47
56
  let maxSides = 0;
48
- if (Array.isArray(rings)) for (const r of rings) {
49
- const n = Array.isArray(r?.polygon) ? r.polygon.length : (Number.isFinite(r?.sides) ? r.sides : 0);
50
- if (n > maxSides) maxSides = n;
51
- }
57
+ for (const r of resolvedLoft?.resolved ?? []) if (r.pts2d.length > maxSides) maxSides = r.pts2d.length;
52
58
  return maxSides >= SMOOTH_SIDES_MIN ? SMOOTH : FACETED;
53
59
  }
@@ -5,7 +5,7 @@
5
5
  // set of 3-D cross-section stations. BOTH backends build from that SAME station list —
6
6
  // Manifold hand-meshes it (sweepMesh below, the loft/helix-tube recipe via mesh-build.js),
7
7
  // OCCT lofts the same rings ruled (occt-backend.js). So the elbow shape agrees BY
8
- // CONSTRUCTION, not by tolerance — the same parity mechanism loft's resolveRings uses.
8
+ // CONSTRUCTION, not by tolerance — the same parity mechanism loft's resolveLoftRings uses.
9
9
  //
10
10
  // Corners: cornerRadius==0 → a SHARP MITER (one station per vertex, in the bisecting
11
11
  // plane, stretched by 1/cos(turn/2) so straight walls meet flush). cornerRadius>0 →
@@ -50,6 +50,16 @@ export const BUILD_RULES = [
50
50
  "Fix the error in build(). This was raised with no kernel attached, so it is a fault in the build's own logic (bad arithmetic, a missing param, a null dereference) rather than a geometry failure.",
51
51
  `parts.${subpart}.build`)),
52
52
  },
53
+ {
54
+ // Same class of fault as build-throws, located at the probe. A throwing
55
+ // probe degrades at runtime to `{ error }` in the measure report rather
56
+ // than crashing anything — this rule is what makes it loud anyway.
57
+ id: "probe-throws",
58
+ run: ({ probe }) => probe().probeThrows.map(({ probe: name, message }) =>
59
+ err("probe-throws", `probe "${name}" threw during a geometry-free run: ${message}`,
60
+ "Fix the error in the probe function. This was raised with no kernel attached, so it is a fault in the probe's own logic (bad arithmetic, a missing param, a null dereference) rather than a geometry failure — at runtime it would report `{ error }` instead of a measurement.",
61
+ `probes.${name}`)),
62
+ },
53
63
  {
54
64
  id: "manifold-backend-uses-occt-op",
55
65
  run: ({ part, probe }) => {
@@ -75,9 +85,9 @@ export const BUILD_RULES = [
75
85
  id: "nondeterministic-build",
76
86
  run: ({ probe, probeAgain }) => {
77
87
  const a = probe();
78
- if (a.runaway || a.throws.length > 0) return []; // an aborted build can't be compared
88
+ if (a.runaway || a.throws.length > 0 || a.probeThrows.length > 0) return []; // an aborted run can't be compared
79
89
  const b = probeAgain();
80
- if (b.runaway || b.throws.length > 0) return [];
90
+ if (b.runaway || b.throws.length > 0 || b.probeThrows.length > 0) return [];
81
91
  if (JSON.stringify(a.calls) === JSON.stringify(b.calls)) return [];
82
92
  return [warn("nondeterministic-build",
83
93
  "two builds with identical parameters produced different kernel calls",
@@ -40,6 +40,25 @@ export const SHAPE_RULES = [
40
40
  `parts.${name}.build`));
41
41
  },
42
42
  },
43
+ {
44
+ // `probes` is optional; when present it must be an object of (k, p, d)
45
+ // functions — same contract as build, but the result lands in the measure
46
+ // report instead of the scene (see AUTHORING-PARTS.md "Probes").
47
+ id: "invalid-probes",
48
+ run: ({ part }) => {
49
+ if (part?.probes === undefined) return [];
50
+ if (!isPlainObject(part.probes)) {
51
+ return [err("invalid-probes", "`probes` must be an object mapping names to functions",
52
+ "Declare probes as `probes: { name: (k, p, d) => Solid | plain JSON }` — each is measured into the report by `partforge measure` and the inspect job.",
53
+ "probes")];
54
+ }
55
+ return Object.entries(part.probes)
56
+ .filter(([, fn]) => typeof fn !== "function")
57
+ .map(([name]) => err("invalid-probes", `probe "${name}" is not a function`,
58
+ "Every entry in `probes` must be a `(k, p, d)` function returning a Solid (measured into facts) or plain JSON (reported verbatim).",
59
+ `probes.${name}`));
60
+ },
61
+ },
43
62
  {
44
63
  id: "missing-views",
45
64
  run: ({ part }) => (isPlainObject(part?.views) && Object.keys(part.views).length > 0 ? [] : [
@@ -1,6 +1,7 @@
1
1
  import { buildView } from "./build.js";
2
2
  import { cachedBVH } from "./bvh.js";
3
3
  import { assemblyOverlaps } from "../assembly.js";
4
+ import { resolveParams } from "../part-model.js";
4
5
  import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
5
6
  import { bounds, meshArea, meshCentroid } from "./mesh.js";
6
7
  import { minWall, DIAGNOSTIC_SAMPLES } from "./min-wall.js";
@@ -12,6 +13,80 @@ const unionBounds = (list) => list.reduce(
12
13
  { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] },
13
14
  );
14
15
 
16
+ // ── probes ──────────────────────────────────────────────────────────────────
17
+ // Part-declared measurements: `probes: { name: (k, p, d) => Solid | JSON }`,
18
+ // pure functions with build's exact contract but whose result lands in the
19
+ // REPORT instead of the scene. The instrument a rebuild-against-reference
20
+ // workflow needs — before this, getting a cross-section's numbers out of the
21
+ // pipeline meant authoring throwaway `exportable: false` sub-parts and fishing
22
+ // their facts out of the sub-part list (the "Probes" feedback report).
23
+ // A Solid anywhere in the return value (duck-typed on volume+toMesh, the two
24
+ // queries the facts need) is replaced by a fact object; scalars/arrays/objects
25
+ // pass through; a throw becomes `{ error }` — probes are instrumentation, so
26
+ // they never crash the measurement and never gate `ok`.
27
+
28
+ const isSolid = (v) => v !== null && typeof v === "object"
29
+ && typeof v.volume === "function" && typeof v.toMesh === "function";
30
+
31
+ function solidProbeFacts(solid) {
32
+ const mesh = solid.toMesh();
33
+ // Empty = the probe's boolean found nothing (a slab that misses the part).
34
+ // A first-class answer, not degenerate infinite bounds: "the reference has no
35
+ // material here" is exactly what a localizing probe is asked.
36
+ const empty = typeof solid.isEmpty === "function" ? solid.isEmpty() : mesh.triangles === 0;
37
+ if (empty) {
38
+ return { empty: true, bbox: null, bounds: null, centerOfMass: null,
39
+ volume: 0, surfaceArea: 0, triangleCount: 0, watertight: null, holes: null };
40
+ }
41
+ const b = bounds(mesh.positions);
42
+ return {
43
+ empty: false,
44
+ bbox: size(b),
45
+ bounds: { min: b.min, max: b.max },
46
+ centerOfMass: meshCentroid(mesh.positions, mesh.indices),
47
+ volume: solid.volume(),
48
+ surfaceArea: meshArea(mesh.positions, mesh.indices),
49
+ triangleCount: mesh.triangles,
50
+ // Mirrors the sub-part fact: answered by isEmpty where the backend has it
51
+ // (and this branch already means it said false), null where it can't say.
52
+ watertight: typeof solid.isEmpty === "function" ? true : null,
53
+ holes: typeof solid.genus === "function" ? solid.genus() : null,
54
+ };
55
+ }
56
+
57
+ // Bounded so a self-referential or absurdly deep return value can't hang the
58
+ // report; past the cap the value is summarized rather than walked.
59
+ const MAX_PROBE_VALUE_DEPTH = 4;
60
+ function resolveProbeValue(v, depth = 0) {
61
+ if (isSolid(v)) return solidProbeFacts(v);
62
+ if (v === null || typeof v !== "object") {
63
+ return typeof v === "function" ? { error: "probe returned a function — return a Solid or plain JSON" } : v;
64
+ }
65
+ if (depth >= MAX_PROBE_VALUE_DEPTH) return { error: `probe value deeper than ${MAX_PROBE_VALUE_DEPTH} levels` };
66
+ if (Array.isArray(v)) return v.map((x) => resolveProbeValue(x, depth + 1));
67
+ return Object.fromEntries(Object.entries(v).map(([key, x]) => [key, resolveProbeValue(x, depth + 1)]));
68
+ }
69
+
70
+ // Evaluate every declared probe with resolved (p, d). Reads all solid facts
71
+ // eagerly, so the caller may free the kernel's objects afterwards. Never
72
+ // throws: each probe's failure is its own `{ error }` entry.
73
+ function evaluateProbes(kernel, part, params) {
74
+ const { p, d } = resolveParams(part, params);
75
+ // Oracle-owned cache round, same reasoning as buildView's: probe geometry must
76
+ // not evict what the viewer is showing, and the next round evicts this one.
77
+ kernel.beginSubPart?.("oracle:probes");
78
+ try {
79
+ return Object.fromEntries(Object.entries(part.probes).map(([name, fn]) => {
80
+ try {
81
+ if (typeof fn !== "function") throw new Error("probe must be a function (k, p, d)");
82
+ return [name, resolveProbeValue(fn(kernel, p, d))];
83
+ } catch (e) {
84
+ return [name, { error: e?.message || String(e) }];
85
+ }
86
+ }));
87
+ } finally { kernel.endSubPart?.(); }
88
+ }
89
+
15
90
  // Headless geometric report for one view of a part (Manifold-only). Reads exact
16
91
  // solid facts (volume/genus/emptiness) and mesh facts (bbox/area/triangles), plus
17
92
  // the assembly overlap check plus pair gap distances (near misses are reported,
@@ -100,6 +175,15 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
100
175
  };
101
176
  });
102
177
 
178
+ // Declared probes, evaluated regardless of view (they are part-level facts —
179
+ // per-view probes were exactly the annoyance this replaces) and before
180
+ // assemblyOverlaps/cleanup below frees the kernel's objects. `opts.probes:
181
+ // false` skips them: verify's per-case re-measures pass it because no gate
182
+ // reads probe values, so re-running their booleans per case buys nothing.
183
+ const probes = opts.probes !== false && part.probes && Object.keys(part.probes).length
184
+ ? evaluateProbes(kernel, part, params)
185
+ : undefined;
186
+
103
187
  // Pair surface distances from the meshes already built — no kernel dependency,
104
188
  // so this reads on OCCT too. nearMisses = the issue-#29 signal: pairs that
105
189
  // *almost* touch; overlapping pairs are excluded by name (a fully-contained
@@ -155,6 +239,9 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
155
239
  overlaps,
156
240
  gaps,
157
241
  nearMisses,
242
+ // Present only when the part declares probes AND this run evaluated them —
243
+ // a probe error stays inside its own entry and never reaches `ok` below.
244
+ ...(probes ? { probes } : {}),
158
245
  ok: subparts.every((s) => s.watertight !== false) && overlaps.length === 0,
159
246
  };
160
247
  }
@@ -266,7 +266,10 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
266
266
  const key = signature(params);
267
267
  if (memo.has(key)) return memo.get(key);
268
268
  if (quick) return null; // a case the seed does not cover — reported, never built
269
- memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall }));
269
+ // `probes: false` no gate reads probe values, so re-running their booleans
270
+ // for every case buys nothing. (A seed measured WITH probes is a superset in
271
+ // the same way a min-wall seed is: the extra key is simply never read here.)
272
+ memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall, probes: false }));
270
273
  return memo.get(key);
271
274
  };
272
275
 
@@ -0,0 +1,3 @@
1
+ import part from "./parts/lofted-bottle.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -1,5 +1,7 @@
1
1
  // Reference part for docs/AUTHORING-PARTS.md's "Importing geometry" section —
2
- // the worked example for BOTH import uses in one part:
2
+ // and for its "Probes" section (the `probes` block below measures the import
3
+ // live into the measure report) — the worked example for BOTH import uses in
4
+ // one part:
3
5
  // • reference — `ref` is a translucent ghost of the imported scan (never
4
6
  // exported); `body` is a parametric rebuild of the same block, bound to
5
7
  // the scan via `reference: "scan"` and held to it by the three ref*
@@ -108,6 +110,25 @@ export default {
108
110
  // socket never touches `body` (see `mountOffsetX`). "reference" is the
109
111
  // ghost-overlay view, browsed by hand or with an explicit view argument.
110
112
  views: { assembly: { label: "Assembly" }, reference: { label: "Reference overlay" } },
113
+ // Probes — measurements that land in the `measure` report instead of the
114
+ // scene (docs/AUTHORING-PARTS.md "Probes"). Pure (k, p, d) functions like
115
+ // build; never rendered, never exported, reported for every view.
116
+ probes: {
117
+ // Paired 1 mm cross-sections of the rebuild and the scan at the same X
118
+ // station — the localizing instrument for the deviation gate above: when
119
+ // refXorVolume creeps up, slide the slab along X to find WHERE the two
120
+ // solids disagree instead of guessing from one whole-part number.
121
+ midSlab: (k, p) => {
122
+ const slab = () => k.box({ min: [9.5, -50, -50], max: [10.5, 50, 50] });
123
+ return {
124
+ body: k.box({ min: [0, 0, 0], max: [p.scanW, p.scanD, p.scanH] }).intersect(slab()),
125
+ scan: k.import("scan").intersect(slab()),
126
+ };
127
+ },
128
+ // A live reading straight off the import — the numbers `defaults` were
129
+ // measured from. Plain JSON passes through the report verbatim.
130
+ scanBounds: (k) => k.import("scan").boundingBox(),
131
+ },
111
132
  verify: {
112
133
  process: "fdm-pla",
113
134
  expect: {
@@ -0,0 +1,61 @@
1
+ // Example PartDefinition — the Shape2D-loft reference part. One rounded-square Shape2D
2
+ // is reused up the body with per-ring scales (structurally identical rings: OCCT lofts
3
+ // the original arc wires, so STEP keeps true circles); the shoulder morphs that square
4
+ // into a circle (structurally different rings: both backends loft the identical
5
+ // resampled sections). See docs/AUTHORING-PARTS.md for the conventions.
6
+ import { circleProfile } from "partforge/geometry";
7
+
8
+ export default {
9
+ meta: { title: "Lofted Bottle", units: "mm" },
10
+ parameters: [
11
+ {
12
+ id: "body",
13
+ title: "Bottle",
14
+ description: "A bottle lofted from a rounded-square base to a round neck (`k.loft` " +
15
+ "with `Shape2D` rings). **Corner radius** shapes the base; **Belly** bulges the body.",
16
+ presets: {
17
+ "Flask": { width: 40, bodyH: 70, cornerR: 8, belly: 1.15, neckD: 22, neckH: 25 },
18
+ "Square jar": { width: 60, bodyH: 50, cornerR: 6, belly: 1.0, neckD: 40, neckH: 12 },
19
+ "Slim vial": { width: 24, bodyH: 60, cornerR: 10, belly: 1.05, neckD: 14, neckH: 20 },
20
+ },
21
+ advanced: [
22
+ { key: "width", label: "Base width", unit: "mm", min: 20, max: 80, step: 1, description: "Across-flats width of the rounded-square base." },
23
+ { key: "bodyH", label: "Body height", unit: "mm", min: 30, max: 120, step: 1, description: "Height of the square-section body." },
24
+ { key: "cornerR", label: "Corner radius", unit: "mm", min: 1, max: 12, step: 0.5, description: "Base corner rounding — these arcs stay true circles in STEP." },
25
+ { key: "belly", label: "Belly", min: 0.9, max: 1.4, step: 0.05, description: "Mid-body scale — above 1 bulges, below 1 pinches." },
26
+ { key: "neckD", label: "Neck diameter", unit: "mm", min: 10, max: 50, step: 1, description: "Round neck diameter at the mouth." },
27
+ { key: "neckH", label: "Neck height", unit: "mm", min: 8, max: 40, step: 1, description: "Height of the square-to-circle shoulder." },
28
+ ],
29
+ },
30
+ ],
31
+ defaults: { width: 40, bodyH: 70, cornerR: 8, belly: 1.15, neckD: 22, neckH: 25 },
32
+ parts: {
33
+ bottle: {
34
+ label: "Bottle", views: ["bottle"], export: { name: "lofted-bottle" },
35
+ build: (k, p) => {
36
+ const half = p.width / 2;
37
+ const r = Math.min(p.cornerR, half - 0.5); // fillet must fit the half-width
38
+ const sq = k.shape2d([[-half, -half], [half, -half], [half, half], [-half, half]]).fillet(r);
39
+ // Body: one Shape2D reused with per-ring scale → curve mode (STEP-exact arcs).
40
+ const body = k.loft({ rings: [
41
+ { polygon: sq, z: 0 },
42
+ { polygon: sq, z: p.bodyH * 0.45, scale: p.belly },
43
+ { polygon: sq, z: p.bodyH },
44
+ ] }).label("Body");
45
+ // Shoulder: rounded square → circle → resample mode (shared rings, both backends).
46
+ const shoulder = k.loft({ rings: [
47
+ { polygon: sq, z: p.bodyH },
48
+ { polygon: circleProfile(p.neckD / 2), z: p.bodyH + p.neckH },
49
+ ] }).label("Shoulder");
50
+ return body.union(shoulder);
51
+ },
52
+ },
53
+ },
54
+ views: { bottle: { label: "Bottle" } },
55
+ verify: {
56
+ expect: {
57
+ bottle: { holes: 0, bbox: "<=[120,120,165]" },
58
+ _view: { overlaps: 0 },
59
+ },
60
+ },
61
+ };