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.
- package/bin/cli.js +28 -0
- package/docs/AUTHORING-PARTS.md +116 -20
- package/docs/ERROR-PATTERNS.md +16 -1
- package/docs/KERNEL-CONTRACT.md +24 -8
- package/package.json +1 -1
- package/src/app-lofted-bottle.js +22 -0
- package/src/framework/geometry/kernel.js +1 -1
- package/src/framework/geometry/loft-rings.js +377 -0
- package/src/framework/geometry/loft.js +138 -61
- package/src/framework/geometry/manifold-backend.js +48 -6
- package/src/framework/geometry/mesh-build.js +16 -0
- package/src/framework/geometry/occt-backend.js +10 -5
- package/src/framework/geometry/probe.js +21 -8
- package/src/framework/geometry/profile.js +26 -18
- package/src/framework/geometry/shading-policy.js +14 -8
- package/src/framework/geometry/sweep.js +1 -1
- package/src/framework/lint/rules-build.js +12 -2
- package/src/framework/lint/rules-shape.js +19 -0
- package/src/framework/oracle/measure.js +87 -0
- package/src/framework/oracle/verify.js +4 -1
- package/src/lofted-bottle-worker.js +3 -0
- package/src/parts/import-demo.js +22 -1
- package/src/parts/lofted-bottle.js +61 -0
|
@@ -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
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
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
|
|
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(
|
|
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 {
|
|
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:
|
|
427
|
-
//
|
|
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
|
|
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()
|
|
159
|
-
*
|
|
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
|
-
|
|
166
|
-
if (typeof sp?.build !== "function") continue; // no-buildable-parts already reports this
|
|
168
|
+
const run = (fn, onThrow) => {
|
|
167
169
|
try {
|
|
168
|
-
|
|
170
|
+
fn(probe.kernel, p, d);
|
|
169
171
|
} catch (e) {
|
|
170
|
-
if (e instanceof ProbeRunawayError) { runaway = true;
|
|
171
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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([
|
|
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] = [
|
|
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
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/parts/import-demo.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Reference part for docs/AUTHORING-PARTS.md's "Importing geometry" section —
|
|
2
|
-
//
|
|
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
|
+
};
|