partforge 0.6.1 → 0.8.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 +61 -15
- package/bin/cli.js +82 -58
- package/docs/AUTHORING-PARTS.md +98 -26
- package/package.json +1 -1
- package/src/app-faceted-vase.js +10 -0
- package/src/faceted-vase-worker.js +3 -0
- package/src/framework/app.css +26 -0
- package/src/framework/assembly.js +6 -9
- package/src/framework/download.js +23 -0
- package/src/framework/geometry/feature-attribution.js +102 -0
- package/src/framework/geometry/helix-tube.js +10 -20
- package/src/framework/geometry/kernel-front.js +37 -0
- package/src/framework/geometry/kernel.js +56 -10
- package/src/framework/geometry/loft.js +79 -0
- package/src/framework/geometry/manifold-backend.js +67 -25
- package/src/framework/geometry/mesh-build.js +53 -0
- package/src/framework/geometry/occt-backend.js +117 -106
- package/src/framework/geometry/occt-repair.js +83 -0
- package/src/framework/geometry/polygon.js +89 -0
- package/src/framework/geometry/probe.js +37 -30
- package/src/framework/geometry/profile.js +96 -0
- package/src/framework/geometry/solid-sugar.js +32 -5
- package/src/framework/geometry/sweep.js +151 -0
- package/src/framework/geometry-service.js +4 -6
- package/src/framework/jobs.js +40 -18
- package/src/framework/mesh-cache.js +41 -0
- package/src/framework/mount.js +103 -240
- package/src/framework/param-deps.js +9 -18
- package/src/framework/pick-request/server.js +10 -0
- package/src/framework/regen-loop.js +45 -0
- package/src/framework/selection/format.js +2 -6
- package/src/framework/selection/hover.js +128 -0
- package/src/framework/selection/index.js +3 -0
- package/src/framework/selection/pick-toggle.js +34 -0
- package/src/framework/selection/pick.js +7 -30
- package/src/framework/selection/raycast.js +43 -0
- package/src/framework/selection/resolve.js +3 -8
- package/src/framework/status-ui.js +18 -0
- package/src/framework/view-state.js +11 -1
- package/src/framework/view-tabs.js +33 -0
- package/src/framework/viewer-controls.js +48 -0
- package/src/framework/viewer.js +9 -9
- package/src/framework/worker.js +12 -20
- package/src/parts/faceted-vase.js +75 -0
- package/src/parts/filleted-box.js +1 -1
- package/src/parts/planter.js +4 -3
- package/src/testing/build.js +3 -6
- package/src/testing/manifold.js +11 -0
- package/src/testing.js +1 -0
- package/src/framework/geometry/fuzzy-cut.js +0 -32
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
// src/framework/geometry/solid-sugar.js
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// readable transform/placement
|
|
5
|
-
// rotate()/translate() primitives
|
|
6
|
-
//
|
|
2
|
+
// The backend-shared Solid front, defined ONCE over both geometry backends. Every
|
|
3
|
+
// Solid a backend's wrap() returns is passed through addSugar(), which:
|
|
4
|
+
// - attaches the readable transform/placement vocabulary (rotateX/along/at/…),
|
|
5
|
+
// composed purely from the solid's own rotate()/translate() primitives, so the
|
|
6
|
+
// sugar is geometry-identical on Manifold and OCCT alike;
|
|
7
|
+
// - validates arguments the backends would otherwise each check (scale factor);
|
|
8
|
+
// - derives boundingBox center/size from the backend's raw {min,max};
|
|
9
|
+
// - stubs any OCCT-only op the backend lacks with a KernelCapabilityError, so
|
|
10
|
+
// the needs-occt reroute works without hand-written per-backend stubs.
|
|
11
|
+
import { KernelCapabilityError } from "./errors.js";
|
|
12
|
+
import { OCCT_ONLY_OPS } from "./kernel.js";
|
|
13
|
+
|
|
7
14
|
const ORIGIN = [0, 0, 0];
|
|
8
15
|
const AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
|
|
9
16
|
|
|
@@ -31,5 +38,25 @@ const SUGAR = {
|
|
|
31
38
|
};
|
|
32
39
|
|
|
33
40
|
export function addSugar(s) {
|
|
41
|
+
const rawScale = s.scale;
|
|
42
|
+
s.scale = (factor, center = ORIGIN) => {
|
|
43
|
+
if (!(factor > 0)) throw new Error("scale: factor must be > 0");
|
|
44
|
+
return rawScale(factor, center);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const rawBoundingBox = s.boundingBox;
|
|
48
|
+
s.boundingBox = () => {
|
|
49
|
+
const { min, max } = rawBoundingBox();
|
|
50
|
+
return {
|
|
51
|
+
min, max,
|
|
52
|
+
center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
|
|
53
|
+
size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
for (const op of OCCT_ONLY_OPS) {
|
|
58
|
+
s[op] ??= () => { throw new KernelCapabilityError(`${op} requires the OCCT backend`); };
|
|
59
|
+
}
|
|
60
|
+
|
|
34
61
|
return Object.assign(s, SUGAR);
|
|
35
62
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Backend-shared sweep support. resolveSweepStations() walks a 3-D polyline path with a
|
|
2
|
+
// rotation-minimizing frame (parallel transport, specialised to piecewise-linear paths:
|
|
3
|
+
// the frame only ever rotates ACROSS a vertex, by the minimal rotation carrying the
|
|
4
|
+
// incoming tangent onto the outgoing one) and places the 2-D profile at a fixed, shared
|
|
5
|
+
// set of 3-D cross-section stations. BOTH backends build from that SAME station list —
|
|
6
|
+
// Manifold hand-meshes it (sweepMesh below, the loft/helix-tube recipe via mesh-build.js),
|
|
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.
|
|
9
|
+
//
|
|
10
|
+
// Corners: cornerRadius==0 → a SHARP MITER (one station per vertex, in the bisecting
|
|
11
|
+
// plane, stretched by 1/cos(turn/2) so straight walls meet flush). cornerRadius>0 →
|
|
12
|
+
// a tangent circular ARC FAN (setback clamped like filletPolygon). Fold conditions
|
|
13
|
+
// (profile too wide for a bend; 180° reversal) throw up front — the volume+bbox oracle
|
|
14
|
+
// would ship a fold silently otherwise.
|
|
15
|
+
import { sideQuads, fanCap, manifoldFromMesh, reverseWinding } from "./mesh-build.js";
|
|
16
|
+
|
|
17
|
+
const EPS = 1e-9;
|
|
18
|
+
const Z = [0, 0, 1], X = [1, 0, 0];
|
|
19
|
+
// Corner-arc station density, in degrees of turn per station. A shared CONSTANT (not a
|
|
20
|
+
// backend/quality value) so both backends subdivide a cornerRadius arc identically →
|
|
21
|
+
// identical stations → parity by construction.
|
|
22
|
+
const ARC_STEP_DEG = 12;
|
|
23
|
+
|
|
24
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
25
|
+
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
26
|
+
const scl = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
|
|
27
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
28
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
29
|
+
const vlen = (a) => Math.hypot(a[0], a[1], a[2]);
|
|
30
|
+
const norm = (a) => { const m = vlen(a) || 1; return [a[0] / m, a[1] / m, a[2] / m]; };
|
|
31
|
+
// Rotate vector v about unit axis k by angle ang (Rodrigues) — same math as
|
|
32
|
+
// manifold-backend.js axisAngleMat4, applied to a vector.
|
|
33
|
+
const rodrigues = (v, k, ang) => {
|
|
34
|
+
const c = Math.cos(ang), s = Math.sin(ang), kd = dot(k, v), kv = cross(k, v);
|
|
35
|
+
return [
|
|
36
|
+
v[0] * c + kv[0] * s + k[0] * kd * (1 - c),
|
|
37
|
+
v[1] * c + kv[1] * s + k[1] * kd * (1 - c),
|
|
38
|
+
v[2] * c + kv[2] * s + k[2] * kd * (1 - c),
|
|
39
|
+
];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Place the 2-D profile into 3-D at `center` using frame axes (N=profile-x, B=profile-y).
|
|
43
|
+
const placeRing = (profile2D, center, N, B) =>
|
|
44
|
+
profile2D.map(([x, y]) => add(center, add(scl(N, x), scl(B, y))));
|
|
45
|
+
|
|
46
|
+
export function resolveSweepStations(profile2D, path3D, { closed = false, cornerRadius = 0 } = {}) {
|
|
47
|
+
if (!Array.isArray(profile2D) || profile2D.length < 3)
|
|
48
|
+
throw new Error("sweep: profile2D must be an array of ≥3 [x,y] points");
|
|
49
|
+
if (!Array.isArray(path3D) || path3D.length < 2)
|
|
50
|
+
throw new Error("sweep: path3D must be an array of ≥2 [x,y,z] points");
|
|
51
|
+
for (let i = 0; i < path3D.length; i++) {
|
|
52
|
+
const p = path3D[i];
|
|
53
|
+
if (!Array.isArray(p) || p.length < 3 || !Number.isFinite(p[0]) || !Number.isFinite(p[1]) || !Number.isFinite(p[2]))
|
|
54
|
+
throw new Error(`sweep: path3D[${i}] must be a finite [x,y,z]`);
|
|
55
|
+
}
|
|
56
|
+
const P = path3D, m = P.length;
|
|
57
|
+
const segCount = closed ? m : m - 1;
|
|
58
|
+
const dir = [], segLen = [];
|
|
59
|
+
for (let k = 0; k < segCount; k++) {
|
|
60
|
+
const d = sub(P[(k + 1) % m], P[k]), l = vlen(d);
|
|
61
|
+
if (l < EPS) throw new Error(`sweep: path segment ${k} has zero length (coincident points ${k} and ${(k + 1) % m})`);
|
|
62
|
+
dir.push(scl(d, 1 / l)); segLen.push(l);
|
|
63
|
+
}
|
|
64
|
+
// profile half-width = the farthest a profile vertex reaches from its own origin
|
|
65
|
+
let maxReach = 0;
|
|
66
|
+
for (const [x, y] of profile2D) maxReach = Math.max(maxReach, Math.hypot(x, y));
|
|
67
|
+
|
|
68
|
+
// Seed the frame ⟂ the tangent coming INTO the first processed station (reference-vector
|
|
69
|
+
// method; the ref pick avoids N collapsing when the path starts along Z).
|
|
70
|
+
const seedT = closed ? dir[segCount - 1] : dir[0];
|
|
71
|
+
const ref = Math.abs(dot(seedT, Z)) < 0.9 ? Z : X;
|
|
72
|
+
let N = norm(sub(ref, scl(seedT, dot(ref, seedT))));
|
|
73
|
+
let B = cross(seedT, N);
|
|
74
|
+
|
|
75
|
+
const stations = [];
|
|
76
|
+
|
|
77
|
+
// Emit station(s) for an interior vertex and advance (N,B) from ⟂tIn to ⟂tOut.
|
|
78
|
+
const corner = (center, tIn, tOut, vtx, lenIn, lenOut) => {
|
|
79
|
+
const axisRaw = cross(tIn, tOut), s = vlen(axisRaw);
|
|
80
|
+
const cdot = Math.max(-1, Math.min(1, dot(tIn, tOut)));
|
|
81
|
+
if (cdot < -1 + 1e-6)
|
|
82
|
+
throw new Error(`sweep: 180° reversal at vertex ${vtx} is ambiguous — insert an intermediate point or use cornerRadius`);
|
|
83
|
+
if (s < EPS) { stations.push(placeRing(profile2D, center, N, B)); return; } // collinear: no turn, frame unchanged
|
|
84
|
+
const axis = scl(axisRaw, 1 / s);
|
|
85
|
+
const theta = Math.atan2(s, cdot); // exterior turn angle
|
|
86
|
+
if (cornerRadius > 0) {
|
|
87
|
+
if (cornerRadius < maxReach)
|
|
88
|
+
throw new Error(`sweep: cornerRadius ${cornerRadius} < profile half-width ${maxReach.toFixed(3)} at vertex ${vtx} — the inner wall would fold; increase cornerRadius`);
|
|
89
|
+
const t = cornerRadius * Math.tan(theta / 2); // tangent setback along each leg (= r/tan(interiorHalf))
|
|
90
|
+
const tmax = Math.min(lenIn, lenOut) / 2;
|
|
91
|
+
if (t > tmax)
|
|
92
|
+
throw new Error(`sweep: cornerRadius too large for the bend at vertex ${vtx} (setback ${t.toFixed(3)} > half the shorter segment ${tmax.toFixed(3)}) — reduce cornerRadius or lengthen the segment`);
|
|
93
|
+
const a = sub(center, scl(tIn, t)); // arc start on the incoming leg
|
|
94
|
+
const arcCenter = add(center, scl(norm(sub(tOut, tIn)), cornerRadius / Math.cos(theta / 2)));
|
|
95
|
+
const va = sub(a, arcCenter);
|
|
96
|
+
const steps = Math.max(2, Math.ceil(((theta * 180) / Math.PI) / ARC_STEP_DEG));
|
|
97
|
+
for (let i = 0; i <= steps; i++) { // smooth arc: rotate frame with the tangent, no miter tilt
|
|
98
|
+
const ang = (theta * i) / steps;
|
|
99
|
+
stations.push(placeRing(profile2D, add(arcCenter, rodrigues(va, axis, ang)),
|
|
100
|
+
rodrigues(N, axis, ang), rodrigues(B, axis, ang)));
|
|
101
|
+
}
|
|
102
|
+
} else { // sharp miter: one station in the bisecting plane
|
|
103
|
+
if (maxReach * Math.tan(theta / 2) > 0.5 * Math.min(lenIn, lenOut))
|
|
104
|
+
throw new Error(`sweep: profile too wide for the bend at vertex ${vtx} (turn too sharp / segment too short) — increase cornerRadius or lengthen the segment`);
|
|
105
|
+
const Nh = rodrigues(N, axis, theta / 2), Bh = rodrigues(B, axis, theta / 2);
|
|
106
|
+
const mDir = rodrigues(tIn, axis, theta / 2); // ring-plane normal (average travel dir)
|
|
107
|
+
const u = norm(cross(axis, mDir)); // in-plane bend direction (stretch axis)
|
|
108
|
+
const cosh = Math.cos(theta / 2);
|
|
109
|
+
stations.push(profile2D.map(([x, y]) => {
|
|
110
|
+
const p = add(scl(Nh, x), scl(Bh, y)); // profile point in the miter plane (spanned by u, axis)
|
|
111
|
+
return add(center, add(scl(axis, dot(p, axis)), scl(u, dot(p, u) / cosh))); // stretch the u component
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
N = rodrigues(N, axis, theta); B = rodrigues(B, axis, theta); // advance frame to ⟂ tOut
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (closed) {
|
|
118
|
+
for (let k = 0; k < m; k++)
|
|
119
|
+
corner(P[k], dir[(k - 1 + m) % m], dir[k], k, segLen[(k - 1 + m) % m], segLen[k]);
|
|
120
|
+
} else {
|
|
121
|
+
stations.push(placeRing(profile2D, P[0], N, B)); // start cap ring ⟂ dir[0]
|
|
122
|
+
for (let k = 1; k <= m - 2; k++) corner(P[k], dir[k - 1], dir[k], k, segLen[k - 1], segLen[k]);
|
|
123
|
+
stations.push(placeRing(profile2D, P[m - 1], N, B)); // end cap ring ⟂ dir[last]
|
|
124
|
+
}
|
|
125
|
+
return { stations, closed };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const centroid = (ring) => {
|
|
129
|
+
let cx = 0, cy = 0, cz = 0;
|
|
130
|
+
for (const [x, y, z] of ring) { cx += x; cy += y; cz += z; }
|
|
131
|
+
return [cx / ring.length, cy / ring.length, cz / ring.length];
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Manifold path: stack the resolved stations, stitch side quads, and (unless closed) fan a
|
|
135
|
+
// cap over each end from its 3-D centroid. Winding self-heals via loft's signed-volume
|
|
136
|
+
// check so the sweep is winding/direction-agnostic. Returns a raw Manifold (caller T()s).
|
|
137
|
+
export function sweepMesh(wasm, profile2D, path3D, opts = {}) {
|
|
138
|
+
const { stations, closed } = resolveSweepStations(profile2D, path3D, opts);
|
|
139
|
+
const N = profile2D.length;
|
|
140
|
+
const V = [];
|
|
141
|
+
for (const ring of stations) for (const [x, y, z] of ring) V.push(x, y, z);
|
|
142
|
+
const Tr = [];
|
|
143
|
+
sideQuads(Tr, stations.length, N, closed);
|
|
144
|
+
if (!closed) {
|
|
145
|
+
fanCap(V, Tr, 0, N, centroid(stations[0]), true); // start cap faces backward
|
|
146
|
+
fanCap(V, Tr, (stations.length - 1) * N, N, centroid(stations[stations.length - 1]), false); // end faces forward
|
|
147
|
+
}
|
|
148
|
+
let out = manifoldFromMesh(wasm, V, Tr);
|
|
149
|
+
if (out.volume() < 0) { out.delete?.(); reverseWinding(Tr); out = manifoldFromMesh(wasm, V, Tr); }
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
@@ -9,10 +9,8 @@ export function createGeometryService({ createWorker, onMessage }) {
|
|
|
9
9
|
const workers = { manifold: createWorker("manifold"), occt: createWorker("occt") };
|
|
10
10
|
workers.manifold.onmessage = onMessage;
|
|
11
11
|
workers.occt.onmessage = onMessage;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
exportStep: (msg) => workers.occt.postMessage(msg), // STEP is always OCCT
|
|
17
|
-
};
|
|
12
|
+
// Post a job to the chosen backend's worker. The message's own `type` says what to
|
|
13
|
+
// do (generate / export-stl / export-3mf / export-step); `backend` picks the worker
|
|
14
|
+
// — manifold for preview/STL/3MF, occt for STEP (the caller passes "occt" for that).
|
|
15
|
+
return { send: (msg, backend = "manifold") => workers[backend].postMessage(msg) };
|
|
18
16
|
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -19,25 +19,44 @@ export function exportSubParts(part, view, params) {
|
|
|
19
19
|
return viewSubParts(part, view, params).filter((name) => part.parts[name].exportable !== false);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
22
|
+
// Resolve a part's effective params + derived values for a build: the user's params
|
|
23
|
+
// layered over the part defaults, and derive() run once over the result.
|
|
24
|
+
export function resolveParams(part, params) {
|
|
25
|
+
const p = { ...part.defaults, ...params };
|
|
26
|
+
const d = part.derive ? part.derive(p) : {};
|
|
27
|
+
return { p, d };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Build one sub-part and apply its optional place() for the given purpose/view.
|
|
31
|
+
// `p`/`d` come from resolveParams(). This is the SINGLE definition of "a posed
|
|
32
|
+
// sub-part solid" — the worker, the collision check, and the test harness all call
|
|
33
|
+
// it, so display/export poses can never drift between the app and its tests.
|
|
34
|
+
export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress } = {}) {
|
|
35
|
+
const sp = part.parts[name];
|
|
36
|
+
const solid = sp.build(kernel, p, d, onProgress);
|
|
37
|
+
return sp.place ? sp.place(solid, { view, purpose, p, d }) : solid;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
|
|
41
|
+
// Backend-agnostic and part-agnostic: every part specific comes through `part`.
|
|
24
42
|
// { type:"generate", subparts, view, params } → { type:"meshes", meshes, ms }
|
|
25
43
|
// { type:"export-stl", view, params } → { type:"download-parts", ext, mime, parts }
|
|
26
44
|
// { type:"export-step", view, params } → { type:"download", data, filename, mime }
|
|
45
|
+
// Each result branch declares its own transferables (the big binary buffers,
|
|
46
|
+
// zero-copy across the worker boundary) right where the buffers are created —
|
|
47
|
+
// so a new job type can't silently regress to structured-cloning its payload.
|
|
27
48
|
// Progress is posted as { type:"progress", phase }. Export builds thread the
|
|
28
49
|
// progress callback into build() so a part's own per-feature progress surfaces;
|
|
29
50
|
// preview generates stay quiet (no callback) to avoid flicker during slider drags.
|
|
51
|
+
const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
|
|
52
|
+
|
|
30
53
|
export async function handle(kernel, part, msg, post) {
|
|
31
54
|
const onProgress = (phase) => post({ type: "progress", phase });
|
|
32
|
-
const p =
|
|
33
|
-
const d = part.derive ? part.derive(p) : {};
|
|
55
|
+
const { p, d } = resolveParams(part, msg.params);
|
|
34
56
|
const label = (name) => part.parts[name].label ?? name;
|
|
35
57
|
const exportName = (name) => part.parts[name].export?.name ?? name;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const solid = sp.build(kernel, p, d, prog);
|
|
39
|
-
return sp.place ? sp.place(solid, { view, purpose, p, d }) : solid;
|
|
40
|
-
};
|
|
58
|
+
// Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
|
|
59
|
+
const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
|
|
41
60
|
|
|
42
61
|
try {
|
|
43
62
|
if (msg.type === "generate") {
|
|
@@ -48,37 +67,40 @@ export async function handle(kernel, part, msg, post) {
|
|
|
48
67
|
for (const name of msg.subparts) {
|
|
49
68
|
if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
|
|
50
69
|
try {
|
|
51
|
-
const m =
|
|
52
|
-
meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges });
|
|
70
|
+
const m = posed(name, "display").toMesh({ quality: "preview" });
|
|
71
|
+
meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features });
|
|
53
72
|
} finally {
|
|
54
73
|
if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
|
|
55
74
|
kernel.cleanup?.(); // free this round's transients (cached/pinned solids survive)
|
|
56
75
|
}
|
|
57
76
|
}
|
|
58
|
-
|
|
77
|
+
const transfer = meshes.flatMap((m) =>
|
|
78
|
+
[m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
|
|
79
|
+
post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.() }, transfer);
|
|
59
80
|
} else if (msg.type === "export-stl") {
|
|
60
81
|
const out = [];
|
|
61
82
|
for (const name of exportSubParts(part, msg.view, p)) {
|
|
62
83
|
onProgress(`building ${label(name)}`);
|
|
63
|
-
out.push({ name: exportName(name), data: await
|
|
84
|
+
out.push({ name: exportName(name), data: await posed(name, "export", onProgress).toSTL({ quality: "print" }) });
|
|
64
85
|
}
|
|
65
|
-
post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out });
|
|
86
|
+
post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out }, out.map((p) => bufferOf(p.data)));
|
|
66
87
|
} else if (msg.type === "export-step") {
|
|
67
88
|
const solids = exportSubParts(part, msg.view, p).map((name) => {
|
|
68
89
|
onProgress(`building ${label(name)}`);
|
|
69
|
-
return { name: exportName(name), solid:
|
|
90
|
+
return { name: exportName(name), solid: posed(name, "export", onProgress) };
|
|
70
91
|
});
|
|
71
92
|
onProgress("writing STEP file");
|
|
72
93
|
const data = await kernel.toSTEP(solids);
|
|
73
|
-
post({ type: "download", data, filename: `${msg.view}.step`, mime: "application/step" });
|
|
94
|
+
post({ type: "download", data, filename: `${msg.view}.step`, mime: "application/step" }, [bufferOf(data)]);
|
|
74
95
|
} else if (msg.type === "export-3mf") {
|
|
75
96
|
const meshes = exportSubParts(part, msg.view, p).map((name) => {
|
|
76
97
|
onProgress(`building ${label(name)}`);
|
|
77
|
-
const { positions, indices } =
|
|
98
|
+
const { positions, indices } = posed(name, "export", onProgress).toIndexedMesh();
|
|
78
99
|
return { name: exportName(name), positions, indices };
|
|
79
100
|
});
|
|
80
101
|
onProgress("writing 3MF file");
|
|
81
|
-
|
|
102
|
+
const data = meshTo3MF(meshes);
|
|
103
|
+
post({ type: "download", data, filename: `${msg.view}.3mf`, mime: "model/3mf" }, [bufferOf(data)]);
|
|
82
104
|
}
|
|
83
105
|
} catch (err) {
|
|
84
106
|
if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt" });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "./param-deps.js";
|
|
2
|
+
|
|
3
|
+
// Tracks whether each sub-part's cached display mesh is still valid for the current
|
|
4
|
+
// params ("Layer 1" of the cache — skipping regeneration of sub-parts whose inputs
|
|
5
|
+
// didn't change). A sub-part's mesh is stamped with a relevance hash over just the
|
|
6
|
+
// params that sub-part reads; it's current while that hash is unchanged and the mesh
|
|
7
|
+
// is still in the viewer.
|
|
8
|
+
//
|
|
9
|
+
// The view/paramsVersion/caching state lives in mount and changes over time, so it's
|
|
10
|
+
// passed as getters. `params` is a stable object mutated in place, so it's passed by
|
|
11
|
+
// reference.
|
|
12
|
+
export function createMeshCache(part, viewer, { params, getView, getParamsVersion, isCaching }) {
|
|
13
|
+
const cacheHash = {}; // name -> relevance hash the cached mesh was built at
|
|
14
|
+
|
|
15
|
+
// Memoize the per-sub-part read-key map per (paramsVersion, view): subPartReadKeys
|
|
16
|
+
// runs probe builds, so we compute it once per change, not per sub-part.
|
|
17
|
+
let readsKey = null, readsMap = null;
|
|
18
|
+
const readsFor = () => {
|
|
19
|
+
const key = `${getParamsVersion()}|${getView()}`;
|
|
20
|
+
if (readsKey !== key) { readsKey = key; readsMap = subPartReadKeys(part, getView(), params); }
|
|
21
|
+
return readsMap;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// The relevance hash for one sub-part at the current params (RELEVANT_ALL → hash
|
|
25
|
+
// over ALL params, so any edit invalidates it — the safe fallback).
|
|
26
|
+
const hashFor = (name) => {
|
|
27
|
+
if (!isCaching()) return `v${getParamsVersion()}`; // caching off: any edit invalidates every sub-part
|
|
28
|
+
const reads = readsFor();
|
|
29
|
+
const keys = reads === RELEVANT_ALL ? Object.keys(params) : [...(reads.get(name) ?? Object.keys(params))];
|
|
30
|
+
return relevanceHash(keys, params);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
// A cached sub-part is current only if its relevance hash is unchanged.
|
|
35
|
+
isCurrent: (name) => viewer.hasSubMesh(name) && cacheHash[name] === hashFor(name),
|
|
36
|
+
// Stamp a freshly built mesh with the hash it was built at.
|
|
37
|
+
record: (name) => { cacheHash[name] = hashFor(name); },
|
|
38
|
+
// Drop a sub-part's stamp so it rebuilds next generate.
|
|
39
|
+
forget: (name) => { delete cacheHash[name]; },
|
|
40
|
+
};
|
|
41
|
+
}
|