partforge 0.28.0 → 0.31.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/docs/AUTHORING-PARTS.md +7 -0
- package/docs/ERROR-PATTERNS.md +1 -1
- package/package.json +1 -1
- package/src/framework/download.js +14 -6
- package/src/framework/geometry/manifold-backend.js +10 -16
- package/src/framework/geometry/mesh-stl.js +27 -0
- package/src/framework/geometry/occt-backend.js +292 -92
- package/src/framework/geometry/pose.js +47 -0
- package/src/framework/mount.js +3 -3
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -337,6 +337,13 @@ their hash folds every shape-affecting argument (each `loft` ring's points/`z`/`
|
|
|
337
337
|
profile's segment specs from `roundedProfile`, and the tessellation from `twist`), so
|
|
338
338
|
changing any of them is a fresh cache node while an identical rebuild is a hit.
|
|
339
339
|
|
|
340
|
+
This holds on **both backends** — and on OCCT, `translate`/`rotate` are additionally
|
|
341
|
+
*pose-lazy*: the backend re-poses the cached solid's cached tessellation instead of
|
|
342
|
+
re-running any B-rep work. A parameter that only feeds a final placement rotation (a
|
|
343
|
+
lid's open angle, an exploded-view offset) therefore re-drags in ~0 ms even on the
|
|
344
|
+
slow exact kernel — keep such transforms as the last ops in `build` (or in `place`)
|
|
345
|
+
rather than baking them into the geometry earlier.
|
|
346
|
+
|
|
340
347
|
---
|
|
341
348
|
|
|
342
349
|
## Parameters: the control-panel schema
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -47,7 +47,7 @@ test parses every one; keep prose like this as plain paragraphs):
|
|
|
47
47
|
- **Cause:** replicad transforms and booleans (`translate`/`rotate`/`mirror`/`cut`/…) consume their operand — the input solid is deleted and a new one returned.
|
|
48
48
|
- **Fix:** Never reuse a solid after transforming it; take a `.clone()` first when you need the original again. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Geometry: the kernel / `Solid` API" (the `s.clone()` row).
|
|
49
49
|
|
|
50
|
-
The framework itself rebuilds each sub-part fresh per job and applies `place` once, which avoids the problem — follow the same pattern in your own code.
|
|
50
|
+
The framework itself rebuilds each sub-part fresh per job and applies `place` once, which avoids the problem — follow the same pattern in your own code. (Since the OCCT solid cache landed, the in-repo backend clones internally before every consuming replicad call, so wrapped `Solid`s effectively have value semantics and this crash should no longer reproduce through the kernel API — but the portable rule stands: per KERNEL-CONTRACT.md a backend MAY consume, so a part must still not rely on reuse.)
|
|
51
51
|
|
|
52
52
|
## probe-routed-to-occt
|
|
53
53
|
|
package/package.json
CHANGED
|
@@ -2,9 +2,16 @@ import { zipSync } from "fflate";
|
|
|
2
2
|
|
|
3
3
|
// Browser file-download helpers. Pure DOM/Blob utilities with no app state — the
|
|
4
4
|
// worker produces the bytes; these just hand them to the browser as a download.
|
|
5
|
+
//
|
|
6
|
+
// `sink` is an optional escape hatch for embedders that cannot download from
|
|
7
|
+
// their own document — e.g. partforge running inside a null-origin sandboxed
|
|
8
|
+
// iframe, where a blob: URL is blob:null and browsers such as WebKit refuse to
|
|
9
|
+
// load it. When `sink` is supplied it receives the FINAL bytes and no DOM work
|
|
10
|
+
// happens here; the embedder saves them from a context that can.
|
|
5
11
|
|
|
6
|
-
// Trigger a download of one binary blob under `filename
|
|
7
|
-
export function triggerDownload(data, filename, mime) {
|
|
12
|
+
// Trigger a download of one binary blob under `filename` (or hand it to `sink`).
|
|
13
|
+
export function triggerDownload(data, filename, mime, sink) {
|
|
14
|
+
if (typeof sink === "function") { sink({ data, filename, mime }); return; }
|
|
8
15
|
const url = URL.createObjectURL(new Blob([data], { type: mime }));
|
|
9
16
|
const a = document.createElement("a");
|
|
10
17
|
a.href = url;
|
|
@@ -14,10 +21,11 @@ export function triggerDownload(data, filename, mime) {
|
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
// Download a set of built parts: a single part downloads directly; multiple parts
|
|
17
|
-
// are bundled into one flat, store-only (level 0) zip named `zipName`.
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
// are bundled into one flat, store-only (level 0) zip named `zipName`. `sink`, if
|
|
25
|
+
// given, is forwarded to triggerDownload so it receives the final bytes.
|
|
26
|
+
export function downloadParts({ parts, ext, mime }, zipName, sink) {
|
|
27
|
+
if (parts.length === 1) return triggerDownload(parts[0].data, `${parts[0].name}.${ext}`, mime, sink);
|
|
20
28
|
const entries = {};
|
|
21
29
|
for (const p of parts) entries[`${p.name}.${ext}`] = new Uint8Array(p.data);
|
|
22
|
-
triggerDownload(zipSync(entries, { level: 0 }), zipName, "application/zip");
|
|
30
|
+
triggerDownload(zipSync(entries, { level: 0 }), zipName, "application/zip", sink);
|
|
23
31
|
}
|
|
@@ -8,6 +8,7 @@ import { addSugar } from "./solid-sugar.js";
|
|
|
8
8
|
import { addShape2dSugar } from "./shape2d-sugar.js";
|
|
9
9
|
import { assembleRegions } from "./shape2d-regions.js";
|
|
10
10
|
import { finishKernel } from "./kernel-front.js";
|
|
11
|
+
import { meshToStl } from "./mesh-stl.js";
|
|
11
12
|
|
|
12
13
|
const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
|
|
13
14
|
// 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
|
|
@@ -363,21 +364,14 @@ function creasedNormals(g, sharpCos, featureLabels) {
|
|
|
363
364
|
}
|
|
364
365
|
|
|
365
366
|
function stlFromMesh(g) {
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
let
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const ux = b[0]-a[0], uy = b[1]-a[1], uz = b[2]-a[2];
|
|
375
|
-
const vx = c[0]-a[0], vy = c[1]-a[1], vz = c[2]-a[2];
|
|
376
|
-
const nx = uy*vz - uz*vy, ny = uz*vx - ux*vz, nz = ux*vy - uy*vx;
|
|
377
|
-
const L = Math.hypot(nx, ny, nz) || 1;
|
|
378
|
-
dv.setFloat32(o, nx/L, true); dv.setFloat32(o+4, ny/L, true); dv.setFloat32(o+8, nz/L, true); o += 12;
|
|
379
|
-
for (const p of [a, b, c]) for (const x of p) { dv.setFloat32(o, x, true); o += 4; }
|
|
380
|
-
dv.setUint16(o, 0, true); o += 2;
|
|
367
|
+
const vp = g.vertProperties, np = g.numProp;
|
|
368
|
+
const nVert = (vp.length / np) | 0;
|
|
369
|
+
let positions;
|
|
370
|
+
if (np === 3) {
|
|
371
|
+
positions = vp; // already x,y,z per vertex
|
|
372
|
+
} else {
|
|
373
|
+
positions = new Float32Array(nVert * 3);
|
|
374
|
+
for (let i = 0; i < nVert; i++) { positions[i*3] = vp[i*np]; positions[i*3+1] = vp[i*np+1]; positions[i*3+2] = vp[i*np+2]; }
|
|
381
375
|
}
|
|
382
|
-
return
|
|
376
|
+
return meshToStl(positions, g.triVerts);
|
|
383
377
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Pure-JS binary STL writer, shared by both geometry backends. Takes a flat
|
|
2
|
+
// vertex-position array (x,y,z per vertex) and triangle indices, and returns a
|
|
3
|
+
// binary STL ArrayBuffer. STL is a triangle-mesh format, so this is the one and
|
|
4
|
+
// only STL path — OCCT and Manifold both feed it a mesh. It deliberately does
|
|
5
|
+
// NOT touch Blobs: the sandbox worker on Safari cannot read a Blob, so every
|
|
6
|
+
// export must hand back a raw ArrayBuffer.
|
|
7
|
+
export function meshToStl(positions, indices) {
|
|
8
|
+
const n = (indices.length / 3) | 0;
|
|
9
|
+
const ab = new ArrayBuffer(84 + n * 50);
|
|
10
|
+
const dv = new DataView(ab);
|
|
11
|
+
dv.setUint32(80, n, true); // triangle count (80-byte header left zero)
|
|
12
|
+
let o = 84;
|
|
13
|
+
const P = (i) => [positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]];
|
|
14
|
+
for (let i = 0; i < n; i++) {
|
|
15
|
+
const a = P(indices[i * 3]), b = P(indices[i * 3 + 1]), c = P(indices[i * 3 + 2]);
|
|
16
|
+
// Per-facet flat normal from the winding. Slicers recompute this, but some
|
|
17
|
+
// viewers (macOS Preview/Quick Look) render unlit if it's left zero.
|
|
18
|
+
const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
|
|
19
|
+
const vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];
|
|
20
|
+
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
21
|
+
const L = Math.hypot(nx, ny, nz) || 1;
|
|
22
|
+
dv.setFloat32(o, nx / L, true); dv.setFloat32(o + 4, ny / L, true); dv.setFloat32(o + 8, nz / L, true); o += 12;
|
|
23
|
+
for (const p of [a, b, c]) for (const x of p) { dv.setFloat32(o, x, true); o += 4; }
|
|
24
|
+
dv.setUint16(o, 0, true); o += 2;
|
|
25
|
+
}
|
|
26
|
+
return ab;
|
|
27
|
+
}
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
// OCCT backend via replicad. Same GeometryKernel shape as the Manifold backend,
|
|
2
2
|
// and the only backend with toSTEP(). This is where today's drum.js kernel calls
|
|
3
3
|
// (makeCylinder, makeHelix+genericSweep, draw/extrude, cut/fuse) now live.
|
|
4
|
+
//
|
|
5
|
+
// Caching: the SAME createSolidCache as the Manifold backend, bracketed per
|
|
6
|
+
// sub-part by jobs.js. Two backend-specific twists:
|
|
7
|
+
// - replicad ops CONSUME their operands (transform/boolean inputs are deleted),
|
|
8
|
+
// so every op that feeds a possibly-cached shape into replicad clones it
|
|
9
|
+
// first. Disposal is free: replicad wrappers release their WASM through a
|
|
10
|
+
// FinalizationRegistry when the JS object is collected, so evicted cache
|
|
11
|
+
// entries need no dispose bookkeeping (unlike Manifold's tracked/cleanup).
|
|
12
|
+
// - translate/rotate are POSE-LAZY: they accumulate rigid steps on the wrap
|
|
13
|
+
// (pose.js) instead of running OCCT transforms, and toMesh caches the BASE
|
|
14
|
+
// solid's tessellation and re-poses the cached vertices in JS. A pose-only
|
|
15
|
+
// param change (a lid's open angle) therefore re-runs no OCCT op at all.
|
|
16
|
+
// Ops that need the real B-rep (booleans, fillet/chamfer/shell, exports,
|
|
17
|
+
// volume, boundingBox) materialize the pending pose through replicad first.
|
|
4
18
|
import { toEdgeFinder } from "./edge-selector.js";
|
|
5
19
|
import { toFaceFinder } from "./face-selector.js";
|
|
6
20
|
import { addSugar } from "./solid-sugar.js";
|
|
@@ -12,47 +26,66 @@ import { classifyFaceGroups } from "./feature-attribution.js";
|
|
|
12
26
|
import { resolveRings } from "./loft.js";
|
|
13
27
|
import { resolveSweepStations } from "./sweep.js";
|
|
14
28
|
import { normalizeProfile } from "./profile.js";
|
|
29
|
+
import { h } from "./solid-hash.js";
|
|
30
|
+
import { createSolidCache } from "./solid-cache.js";
|
|
31
|
+
import { composePose, transformPositions } from "./pose.js";
|
|
32
|
+
import { meshToStl } from "./mesh-stl.js";
|
|
15
33
|
const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
|
|
16
34
|
|
|
17
35
|
export function createOcctKernel(replicad) {
|
|
18
36
|
const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
|
|
19
|
-
makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
|
|
37
|
+
makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane, getOC } = replicad;
|
|
20
38
|
|
|
21
39
|
// Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
|
|
22
40
|
// see occt-repair.js for the policies and why they differ per op.
|
|
23
41
|
const { validChamfer, safeOp } = createOcctRepair(measureVolume);
|
|
24
42
|
|
|
43
|
+
const cache = createSolidCache();
|
|
44
|
+
// Boundary ops route through cache.lookup. pin is unused here (no cleanup() —
|
|
45
|
+
// GC frees WASM via replicad's FinalizationRegistry) and dispose is a no-op:
|
|
46
|
+
// dropping the evicted reference is all the freeing OCCT needs.
|
|
47
|
+
const cached = (hash, make) => cache.lookup(hash, () => {
|
|
48
|
+
const value = make();
|
|
49
|
+
return { value, pin: value, dispose: () => {} };
|
|
50
|
+
});
|
|
51
|
+
// Function-form selectors (the raw replicad finder escape hatch) can capture
|
|
52
|
+
// params in a closure the source text doesn't show, so they can't be trusted to
|
|
53
|
+
// a content hash: give each call a fresh key, making the op and everything
|
|
54
|
+
// downstream of it a cache miss. Declarative selectors hash normally.
|
|
55
|
+
let unhashable = 0;
|
|
56
|
+
const selKey = (sel) => (typeof sel === "function" ? `fn#${unhashable++}` : sel);
|
|
57
|
+
|
|
25
58
|
// Feature labels: each entry snapshots the labeled solid's geometry at the moment
|
|
26
|
-
// the label applies;
|
|
27
|
-
// sides' lists. At toMesh() time result faces are
|
|
59
|
+
// the label applies; materialization moves the snapshots along with the shape,
|
|
60
|
+
// booleans merge the two sides' lists. At toMesh() time result faces are
|
|
61
|
+
// classified against the snapshots — in BASE space for a posed solid, which is
|
|
62
|
+
// sound because classification is pose-invariant (both meshes share the frame).
|
|
28
63
|
const cloneLabels = (ls) => ls.map((l) => ({ label: l.label, snapshot: l.snapshot.clone() }));
|
|
29
64
|
const mapLabels = (ls, f) => ls.map((l) => ({ label: l.label, snapshot: f(l.snapshot.clone()) }));
|
|
30
65
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
toMesh: ({ quality = "preview" } = {}) => {
|
|
66
|
+
// Apply pending pose steps to a (freshly cloned) replicad shape.
|
|
67
|
+
const applySteps = (sh, steps) => steps.reduce(
|
|
68
|
+
(cur, st) => (st.t === "translate" ? cur.translate(st.v) : cur.rotate(st.deg, st.center, st.axis)), sh);
|
|
69
|
+
|
|
70
|
+
// `hash` is the content hash of the POSED solid; `baseHash` keys the pose-free
|
|
71
|
+
// base (they're equal when `pose` is empty). Every op below that consumes a
|
|
72
|
+
// shape clones it first — cached values must survive reuse across builds.
|
|
73
|
+
const wrap = (shape, labels = [], hash, pose = [], baseHash = hash) => {
|
|
74
|
+
// Materialize the pending pose through replicad, yielding a pose-free wrap.
|
|
75
|
+
// Cached under the posed hash so repeated ops on the same posed solid reuse it.
|
|
76
|
+
const mat = () => (pose.length === 0 ? self : cached(hash, () => wrap(
|
|
77
|
+
applySteps(shape.clone(), pose),
|
|
78
|
+
labels.map((l) => ({ label: l.label, snapshot: applySteps(l.snapshot.clone(), pose) })),
|
|
79
|
+
hash,
|
|
80
|
+
)));
|
|
81
|
+
|
|
82
|
+
// The base solid's tessellation (+ label classification), cached per quality.
|
|
83
|
+
// Pose-independent by construction: meshed from the base shape and base-space
|
|
84
|
+
// snapshots, so every pose of the same base reuses one entry.
|
|
85
|
+
const baseMesh = (quality) => cached(h("mesh", baseHash, quality), () => {
|
|
52
86
|
const m = shape.mesh(MESH[quality]);
|
|
53
87
|
const out = {
|
|
54
88
|
positions: Float32Array.from(m.vertices),
|
|
55
|
-
normals: new Float32Array(0), // let the main thread crease (matches prior look)
|
|
56
89
|
indices: Uint32Array.from(m.triangles),
|
|
57
90
|
triangles: m.triangles.length / 3,
|
|
58
91
|
};
|
|
@@ -64,29 +97,137 @@ export function createOcctKernel(replicad) {
|
|
|
64
97
|
Object.assign(out, classifyFaceGroups(m, soups));
|
|
65
98
|
}
|
|
66
99
|
return out;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
100
|
+
});
|
|
101
|
+
// Fresh posed copies of the cached arrays — jobs.js transfers the buffers to
|
|
102
|
+
// the main thread (detaching them), so the cache must never hand out its own.
|
|
103
|
+
const posedPositions = (base) => {
|
|
104
|
+
const positions = Float32Array.from(base.positions);
|
|
105
|
+
if (pose.length) transformPositions(positions, composePose(pose));
|
|
106
|
+
return positions;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const self = addSugar({
|
|
110
|
+
_s: shape,
|
|
111
|
+
_labels: labels,
|
|
112
|
+
_hash: hash,
|
|
113
|
+
_pose: pose,
|
|
114
|
+
_mat: () => mat(),
|
|
115
|
+
label: (name) => {
|
|
116
|
+
const a = mat();
|
|
117
|
+
return wrap(a._s, [...cloneLabels(a._labels), { label: name, snapshot: a._s.clone() }], h("label", hash, name));
|
|
118
|
+
},
|
|
119
|
+
cut: (t) => {
|
|
120
|
+
const key = h("cut", hash, t._hash);
|
|
121
|
+
return cached(key, () => {
|
|
122
|
+
const a = mat(), b = t._mat();
|
|
123
|
+
return wrap(a._s.clone().cut(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
cutAll: (tools) => {
|
|
127
|
+
const key = h("cutAll", hash, tools.map((t) => t._hash));
|
|
128
|
+
return cached(key, () => {
|
|
129
|
+
const a = mat(), bs = tools.map((t) => t._mat());
|
|
130
|
+
return wrap(
|
|
131
|
+
a._s.clone().cut(makeCompound(bs.map((b) => b._s.clone()))),
|
|
132
|
+
[...cloneLabels(a._labels), ...bs.flatMap((b) => cloneLabels(b._labels))],
|
|
133
|
+
key,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
intersect: (t) => {
|
|
138
|
+
const key = h("intersect", hash, t._hash);
|
|
139
|
+
return cached(key, () => {
|
|
140
|
+
const a = mat(), b = t._mat();
|
|
141
|
+
return wrap(a._s.clone().intersect(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
union: (t) => {
|
|
145
|
+
const key = h("union", [hash, t._hash]);
|
|
146
|
+
return cached(key, () => {
|
|
147
|
+
const a = mat(), b = t._mat();
|
|
148
|
+
return wrap(a._s.clone().fuse(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
clone: () => wrap(shape.clone(), cloneLabels(labels), hash, pose, baseHash),
|
|
152
|
+
boundingBox: () => {
|
|
153
|
+
const [min, max] = mat()._s.boundingBox.bounds; // addSugar derives center/size
|
|
154
|
+
return { min: [...min], max: [...max] };
|
|
155
|
+
},
|
|
156
|
+
// Pose-lazy: no OCCT work, just a recorded step + the composed hash.
|
|
157
|
+
translate: (v) => wrap(shape, labels, h("translate", hash, v), [...pose, { t: "translate", v }], baseHash),
|
|
158
|
+
rotate: (deg, center, axis) => wrap(shape, labels, h("rotate", hash, deg, center, axis), [...pose, { t: "rotate", deg, center, axis }], baseHash),
|
|
159
|
+
mirror: (plane) => {
|
|
160
|
+
const key = h("mirror", hash, plane);
|
|
161
|
+
return cached(key, () => {
|
|
162
|
+
const a = mat();
|
|
163
|
+
return wrap(a._s.clone().mirror(plane), mapLabels(a._labels, (s) => s.mirror(plane)), key);
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
scale: (factor, center) => { // factor validated (and center defaulted) by addSugar
|
|
167
|
+
const key = h("scale", hash, factor, center);
|
|
168
|
+
return cached(key, () => {
|
|
169
|
+
const a = mat();
|
|
170
|
+
return wrap(a._s.clone().scale(factor, center), mapLabels(a._labels, (s) => s.scale(factor, center)), key);
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
toMesh: ({ quality = "preview" } = {}) => {
|
|
174
|
+
const base = baseMesh(quality);
|
|
175
|
+
const out = {
|
|
176
|
+
positions: posedPositions(base),
|
|
177
|
+
normals: new Float32Array(0), // let the main thread crease (matches prior look)
|
|
178
|
+
indices: Uint32Array.from(base.indices),
|
|
179
|
+
triangles: base.triangles,
|
|
180
|
+
};
|
|
181
|
+
if (base.featureIds) { out.featureIds = Uint16Array.from(base.featureIds); out.features = base.features; }
|
|
182
|
+
return out;
|
|
183
|
+
},
|
|
184
|
+
toSTL: ({ quality = "print" } = {}) => {
|
|
185
|
+
const base = baseMesh(quality);
|
|
186
|
+
return Promise.resolve(meshToStl(posedPositions(base), Uint32Array.from(base.indices)));
|
|
187
|
+
},
|
|
188
|
+
fillet: (radius, selector) => {
|
|
189
|
+
const key = h("fillet", hash, radius, selKey(selector));
|
|
190
|
+
return cached(key, () => {
|
|
191
|
+
const a = mat();
|
|
192
|
+
return wrap(safeOp(a._s.clone(), (sh) => sh.fillet(radius, toEdgeFinder(selector)), `fillet(${radius})`), cloneLabels(a._labels), key);
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
chamfer: (distance, selector) => {
|
|
196
|
+
const key = h("chamfer", hash, distance, selKey(selector));
|
|
197
|
+
// validChamfer probes on internal clones and never consumes its input.
|
|
198
|
+
return cached(key, () => {
|
|
199
|
+
const a = mat();
|
|
200
|
+
return wrap(validChamfer(a._s, toEdgeFinder(selector), distance), cloneLabels(a._labels), key);
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
shell: (thickness, openFaces) => {
|
|
204
|
+
if (openFaces == null) throw new Error("shell: openFaces is required (a fully closed hollow is not supported)");
|
|
205
|
+
const key = h("shell", hash, thickness, selKey(openFaces));
|
|
206
|
+
// replicad shells inward with a positive thickness in this version, keeping outer dimensions.
|
|
207
|
+
return cached(key, () => {
|
|
208
|
+
const a = mat();
|
|
209
|
+
return wrap(safeOp(a._s.clone(), (sh) => sh.shell(thickness, toFaceFinder(openFaces)), `shell(${thickness})`), cloneLabels(a._labels), key);
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
volume: () => measureVolume(mat()._s),
|
|
213
|
+
toIndexedMesh: () => {
|
|
214
|
+
const base = baseMesh("preview");
|
|
215
|
+
return { positions: posedPositions(base), indices: Uint32Array.from(base.indices) };
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
return self;
|
|
219
|
+
};
|
|
82
220
|
|
|
83
221
|
// cylinder OR frustum (loft of two circles) when rb !== rt
|
|
84
|
-
const cylinder = (rb, rt,
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
222
|
+
const cylinder = (rb, rt, hgt, { center = false } = {}) => {
|
|
223
|
+
const key = h("cylinder", rb, rt, hgt, center);
|
|
224
|
+
return cached(key, () => {
|
|
225
|
+
const z0 = center ? -hgt / 2 : 0;
|
|
226
|
+
if (Math.abs(rb - rt) < 1e-9) return wrap(makeCylinder(rb, hgt, [0, 0, z0]), [], key);
|
|
227
|
+
const w1 = assembleWire([makeCircle(rb, [0, 0, z0])]);
|
|
228
|
+
const w2 = assembleWire([makeCircle(rt, [0, 0, z0 + hgt])]);
|
|
229
|
+
return wrap(loft([w1, w2]), [], key);
|
|
230
|
+
});
|
|
90
231
|
};
|
|
91
232
|
|
|
92
233
|
// Draw a closed Drawing from a Contour: a legacy 2-D point list (all straight edges,
|
|
@@ -157,15 +298,22 @@ export function createOcctKernel(replicad) {
|
|
|
157
298
|
return (ringArea(r) >= 0) === wantOuter ? r : r.slice().reverse();
|
|
158
299
|
});
|
|
159
300
|
};
|
|
160
|
-
|
|
301
|
+
// Shape2D carries a content hash (so a Solid op over a profile can key on it)
|
|
302
|
+
// but stays UNCACHED — Drawings already clone before every consuming replicad
|
|
303
|
+
// call, and 2-D booleans are cheap next to the B-rep work they feed.
|
|
304
|
+
const wrapShape2d = (drawing, hash) => {
|
|
161
305
|
const toRegions = () => assembleRegions(drawingRegionRings(drawing));
|
|
162
306
|
return addShape2dSugar({
|
|
163
307
|
_drawing: drawing,
|
|
164
308
|
_shape2d: true,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
309
|
+
_hash: hash,
|
|
310
|
+
union: (o) => { const t = liftDrawing(o); return wrapShape2d(drawing.clone().fuse(t._drawing.clone()), h("union2d", hash, t._hash)); },
|
|
311
|
+
cut: (o) => { const t = liftDrawing(o); return wrapShape2d(drawing.clone().cut(t._drawing.clone()), h("cut2d", hash, t._hash)); },
|
|
312
|
+
cutAll: (os) => {
|
|
313
|
+
const ts = os.map(liftDrawing);
|
|
314
|
+
return wrapShape2d(ts.reduce((acc, t) => acc.cut(t._drawing.clone()), drawing.clone()), h("cutAll2d", hash, ts.map((t) => t._hash)));
|
|
315
|
+
},
|
|
316
|
+
intersect: (o) => { const t = liftDrawing(o); return wrapShape2d(drawing.clone().intersect(t._drawing.clone()), h("intersect2d", hash, t._hash)); },
|
|
169
317
|
// corners map onto replicad's Offset2DConfig.lineJoinType; "chamfer" → "bevel", a
|
|
170
318
|
// true 45° corner cut (a straight chord). Manifold now matches this via a
|
|
171
319
|
// single-chord Round join (see manifold-backend offset) — the two agree to float
|
|
@@ -183,52 +331,64 @@ export function createOcctKernel(replicad) {
|
|
|
183
331
|
// guards this — a replicad upgrade that renames it must keep that test green.
|
|
184
332
|
if (!result || !result.innerShape)
|
|
185
333
|
throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
|
|
186
|
-
return wrapShape2d(result);
|
|
334
|
+
return wrapShape2d(result, h("offset2d", hash, delta, corners));
|
|
187
335
|
},
|
|
188
336
|
area: () => regionsArea(toRegions()), // no native Drawing area → derive from materialized regions
|
|
189
337
|
boundingBox: () => { const b = drawing.boundingBox; return { min: [b.bounds[0][0], b.bounds[0][1]], max: [b.bounds[1][0], b.bounds[1][1]] }; },
|
|
190
338
|
toRegions,
|
|
191
|
-
clone: () => wrapShape2d(drawing.clone()),
|
|
339
|
+
clone: () => wrapShape2d(drawing.clone(), hash),
|
|
192
340
|
}, { shape2d, extrude: kernel.extrude, revolve: kernel.revolve });
|
|
193
341
|
};
|
|
194
|
-
const shape2d = (profile) => (profile && profile._shape2d ? profile : wrapShape2d(drawingFromProfile(profile)));
|
|
342
|
+
const shape2d = (profile) => (profile && profile._shape2d ? profile : wrapShape2d(drawingFromProfile(profile), h("shape2d", profile)));
|
|
195
343
|
|
|
196
344
|
// extrude a 2-D polygon from z=0 (arguments validated by the kernel front)
|
|
197
|
-
const prism = (pts,
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
345
|
+
const prism = (pts, hgt, { twist = 0, scaleTop = 1 } = {}) => {
|
|
346
|
+
const key = h("prism", pts, hgt, twist, scaleTop);
|
|
347
|
+
return cached(key, () => {
|
|
348
|
+
const sketch = contourDrawing(pts).sketchOnPlane("XY");
|
|
349
|
+
if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(hgt), [], key);
|
|
350
|
+
const cfg = {};
|
|
351
|
+
if (twist !== 0) cfg.twistAngle = twist;
|
|
352
|
+
if (scaleTop !== 1) cfg.extrusionProfile = { profile: "linear", endFactor: scaleTop };
|
|
353
|
+
return wrap(sketch.extrude(hgt, cfg), [], key);
|
|
354
|
+
});
|
|
204
355
|
};
|
|
205
356
|
|
|
206
357
|
// revolve a lathe profile [[r,z],…] around the Z axis (degrees defaults to 360)
|
|
207
358
|
const revolve = (pts, { degrees = 360 } = {}) => {
|
|
208
|
-
const
|
|
209
|
-
return
|
|
359
|
+
const key = h("revolve", pts && pts._shape2d ? pts._hash : pts, degrees);
|
|
360
|
+
return cached(key, () => {
|
|
361
|
+
const region = pts && pts._shape2d ? pts._drawing.clone() : contourDrawing(pts);
|
|
362
|
+
return wrap(region.sketchOnPlane("XZ").revolve([0, 0, 1], { angle: degrees }), [], key);
|
|
363
|
+
});
|
|
210
364
|
};
|
|
211
365
|
|
|
212
366
|
// extrude a polygon-with-holes region from z=0: cut each hole Drawing out of the outer
|
|
213
367
|
// Drawing (winding-agnostic 2-D boolean), sketch it, then extrude (twist/taper via cfg).
|
|
214
368
|
// A Shape2D `profile` (already a Drawing, possibly multi-region) extrudes directly off
|
|
215
369
|
// its own `_drawing` (cloned — replicad booleans/extrude consume their operand).
|
|
216
|
-
const extrude = (profile,
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
370
|
+
const extrude = (profile, hgt, { twist = 0, scaleTop = 1 } = {}) => {
|
|
371
|
+
const key = h("extrude", profile && profile._shape2d ? profile._hash : profile, hgt, twist, scaleTop);
|
|
372
|
+
return cached(key, () => {
|
|
373
|
+
const region = profile && profile._shape2d ? profile._drawing.clone() : drawingFromProfile(profile);
|
|
374
|
+
const sketch = region.sketchOnPlane("XY");
|
|
375
|
+
if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(hgt), [], key);
|
|
376
|
+
const cfg = {};
|
|
377
|
+
if (twist !== 0) cfg.twistAngle = twist;
|
|
378
|
+
if (scaleTop !== 1) cfg.extrusionProfile = { profile: "linear", endFactor: scaleTop };
|
|
379
|
+
return wrap(sketch.extrude(hgt, cfg), [], key);
|
|
380
|
+
});
|
|
224
381
|
};
|
|
225
382
|
|
|
226
383
|
// ring loft: each ring becomes a closed polygon wire placed at its z (native loft closes
|
|
227
384
|
// the ends for closed wires). closed:true loops are Manifold-only (replicad loft is open).
|
|
228
385
|
const loftOp = (rings, { ruled = true, closed = false } = {}) => {
|
|
229
386
|
if (closed) throw new Error("loft: closed:true loops are only supported on the Manifold backend");
|
|
230
|
-
const
|
|
231
|
-
return
|
|
387
|
+
const key = h("loft", rings, ruled);
|
|
388
|
+
return cached(key, () => {
|
|
389
|
+
const wires = resolveRings(rings).map(({ pts2d, z }) => contourDrawing(pts2d).sketchOnPlane("XY", z).wire);
|
|
390
|
+
return wrap(loft(wires, { ruled }), [], key);
|
|
391
|
+
});
|
|
232
392
|
};
|
|
233
393
|
|
|
234
394
|
// Sweep a 2-D profile along a 3-D polyline path. DEFAULT (§3A recipe): loft the SAME
|
|
@@ -237,7 +397,7 @@ export function createOcctKernel(replicad) {
|
|
|
237
397
|
// mechanism, not a tolerance). smooth:true switches to the OCCT-native genericSweep along
|
|
238
398
|
// a spline spine for an exact swept B-rep (STEP-exact / preview-faceted, parity waived —
|
|
239
399
|
// the same contract loft ships for ruled:false). closed:true loops are Manifold-only.
|
|
240
|
-
const sweepSmooth = (profile2D, path3D, cornerRadius) => {
|
|
400
|
+
const sweepSmooth = (profile2D, path3D, cornerRadius, key) => {
|
|
241
401
|
const edges = [];
|
|
242
402
|
for (let i = 0; i < path3D.length - 1; i++) edges.push(makeLine(path3D[i], path3D[i + 1]));
|
|
243
403
|
const spine = assembleWire(edges);
|
|
@@ -246,35 +406,75 @@ export function createOcctKernel(replicad) {
|
|
|
246
406
|
return wrap(genericSweep(profileWire, spine, {
|
|
247
407
|
transitionMode: cornerRadius > 0 ? "round" : "right", // sharp miter analogue vs rounded joint
|
|
248
408
|
forceProfileSpineOthogonality: true,
|
|
249
|
-
}));
|
|
409
|
+
}), [], key);
|
|
250
410
|
};
|
|
251
411
|
const sweep = (profile2D, path3D, { closed = false, cornerRadius = 0, ruled = true, smooth = false } = {}) => {
|
|
252
412
|
if (closed) throw new Error("sweep: closed:true loops are only supported on the Manifold backend");
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
413
|
+
const key = h("sweep", profile2D, path3D, closed, cornerRadius, ruled, smooth);
|
|
414
|
+
return cached(key, () => {
|
|
415
|
+
if (smooth) return sweepSmooth(profile2D, path3D, cornerRadius, key);
|
|
416
|
+
const { stations } = resolveSweepStations(profile2D, path3D, { closed, cornerRadius });
|
|
417
|
+
const wires = stations.map((ring) => assembleWire(ring.map((p, i) => makeLine(p, ring[(i + 1) % ring.length]))));
|
|
418
|
+
return wrap(loft(wires, { ruled }), [], key);
|
|
419
|
+
});
|
|
257
420
|
};
|
|
258
421
|
|
|
259
422
|
// circle profile swept along a helix (frenet)
|
|
260
|
-
const helixSweptTube = (
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
423
|
+
const helixSweptTube = (o) => {
|
|
424
|
+
const key = h("helixSweptTube", o);
|
|
425
|
+
return cached(key, () => {
|
|
426
|
+
const { pathR, profileR, pitch, turns, z0, lefthand } = o;
|
|
427
|
+
const spine = makeHelix(pitch, pitch * turns, pathR, [0, 0, z0], [0, 0, 1], lefthand);
|
|
428
|
+
const dir = lefthand ? -1 : 1;
|
|
429
|
+
const tangent = [0, dir * pathR, pitch / (2 * Math.PI)];
|
|
430
|
+
const profile = assembleWire([makeCircle(profileR, [pathR, 0, z0], tangent)]);
|
|
431
|
+
return wrap(genericSweep(profile, spine, { frenet: true }), [], key);
|
|
432
|
+
});
|
|
266
433
|
};
|
|
267
434
|
|
|
268
435
|
const kernel = finishKernel({
|
|
269
436
|
cylinder, // boredCylinder: the kernel front's default composition is exactly right here
|
|
270
|
-
box: (min, max) =>
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
solids.
|
|
275
|
-
|
|
437
|
+
box: (min, max) => cached(h("box", min, max), () => wrap(makeBox(min, max), [], h("box", min, max))),
|
|
438
|
+
prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube,
|
|
439
|
+
sphere: (r) => cached(h("sphere", r), () => wrap(makeSphere(r), [], h("sphere", r))),
|
|
440
|
+
union: (solids) => {
|
|
441
|
+
const key = h("union", solids.map((s) => s._hash));
|
|
442
|
+
return cached(key, () => {
|
|
443
|
+
const ms = solids.map((s) => s._mat());
|
|
444
|
+
return wrap(
|
|
445
|
+
ms.map((m) => m._s.clone()).reduce((a, b) => a.fuse(b)),
|
|
446
|
+
ms.flatMap((m) => cloneLabels(m._labels)),
|
|
447
|
+
key,
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
},
|
|
276
451
|
shape2d,
|
|
277
|
-
toSTEP: (named) =>
|
|
452
|
+
toSTEP: (named) => {
|
|
453
|
+
// Blob-free STEP: replicad's exportSTEP writes the STEP text to OCCT's
|
|
454
|
+
// virtual FS, reads it, and wraps it in a Blob. Safari's sandbox worker
|
|
455
|
+
// cannot read a Blob, so we intercept the FS read to capture the raw
|
|
456
|
+
// Uint8Array before it is wrapped, and return an ArrayBuffer instead. The
|
|
457
|
+
// interception is synchronous (exportSTEP is sync) and restored in finally.
|
|
458
|
+
const oc = getOC();
|
|
459
|
+
const realRead = oc.FS.readFile.bind(oc.FS);
|
|
460
|
+
let captured = null;
|
|
461
|
+
oc.FS.readFile = (path, ...rest) => {
|
|
462
|
+
const bytes = realRead(path, ...rest);
|
|
463
|
+
if (typeof path === "string" && path.toLowerCase().endsWith(".step")) captured = bytes;
|
|
464
|
+
return bytes;
|
|
465
|
+
};
|
|
466
|
+
try {
|
|
467
|
+
exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._mat()._s })));
|
|
468
|
+
} finally {
|
|
469
|
+
oc.FS.readFile = realRead;
|
|
470
|
+
}
|
|
471
|
+
if (!captured) throw new Error("STEP export produced no bytes");
|
|
472
|
+
return Promise.resolve(captured.buffer.slice(captured.byteOffset, captured.byteOffset + captured.byteLength));
|
|
473
|
+
},
|
|
474
|
+
beginSubPart: (name) => cache.begin(name),
|
|
475
|
+
endSubPart: () => cache.end(),
|
|
476
|
+
cacheStats: () => cache.stats(),
|
|
477
|
+
resetCacheStats: () => cache.resetStats(),
|
|
278
478
|
});
|
|
279
479
|
return kernel;
|
|
280
480
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Rigid-pose math for the OCCT backend's lazy transforms. A pose is a list of
|
|
2
|
+
// {t:"translate", v} / {t:"rotate", deg, center, axis} steps not yet applied to
|
|
3
|
+
// the underlying B-rep shape; composePose folds them (in application order) into
|
|
4
|
+
// one column-major mat4, and transformPositions re-poses a cached tessellation's
|
|
5
|
+
// vertices with it. Pure JS — unit-testable without booting a kernel.
|
|
6
|
+
|
|
7
|
+
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
8
|
+
|
|
9
|
+
// column-major 4x4 product: (A·B)[c][r] = Σk A[k][r]·B[c][k]
|
|
10
|
+
function mul(A, B) {
|
|
11
|
+
const o = new Array(16);
|
|
12
|
+
for (let c = 0; c < 4; c++)
|
|
13
|
+
for (let r = 0; r < 4; r++)
|
|
14
|
+
o[c * 4 + r] = A[r] * B[c * 4] + A[4 + r] * B[c * 4 + 1] + A[8 + r] * B[c * 4 + 2] + A[12 + r] * B[c * 4 + 3];
|
|
15
|
+
return o;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const translation = (v) => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, v[0], v[1], v[2], 1];
|
|
19
|
+
|
|
20
|
+
// axis-angle rotation about an axis THROUGH `center`: T(center) · R(axis, deg) · T(−center)
|
|
21
|
+
function rotationAbout(deg, center, axis) {
|
|
22
|
+
const len = Math.hypot(axis[0], axis[1], axis[2]) || 1;
|
|
23
|
+
const x = axis[0] / len, y = axis[1] / len, z = axis[2] / len;
|
|
24
|
+
const t = (deg * Math.PI) / 180, c = Math.cos(t), s = Math.sin(t), C = 1 - c;
|
|
25
|
+
const R = [
|
|
26
|
+
c + x * x * C, y * x * C + z * s, z * x * C - y * s, 0,
|
|
27
|
+
x * y * C - z * s, c + y * y * C, z * y * C + x * s, 0,
|
|
28
|
+
x * z * C + y * s, y * z * C - x * s, c + z * z * C, 0,
|
|
29
|
+
0, 0, 0, 1,
|
|
30
|
+
];
|
|
31
|
+
return mul(translation(center), mul(R, translation([-center[0], -center[1], -center[2]])));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const stepMatrix = (s) => (s.t === "translate" ? translation(s.v) : rotationAbout(s.deg, s.center, s.axis));
|
|
35
|
+
|
|
36
|
+
// Fold steps so the EARLIEST step applies first: p' = Mn · … · M1 · p.
|
|
37
|
+
export const composePose = (steps) => steps.reduce((m, s) => mul(stepMatrix(s), m), IDENTITY);
|
|
38
|
+
|
|
39
|
+
// Apply a mat4 to an interleaved xyz Float32Array in place.
|
|
40
|
+
export function transformPositions(positions, m) {
|
|
41
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
42
|
+
const x = positions[i], y = positions[i + 1], z = positions[i + 2];
|
|
43
|
+
positions[i] = m[0] * x + m[4] * y + m[8] * z + m[12];
|
|
44
|
+
positions[i + 1] = m[1] * x + m[5] * y + m[9] * z + m[13];
|
|
45
|
+
positions[i + 2] = m[2] * x + m[6] * y + m[10] * z + m[14];
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/framework/mount.js
CHANGED
|
@@ -62,7 +62,7 @@ function createCleanupStack() {
|
|
|
62
62
|
// Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
|
|
63
63
|
// exactly once here — submodules take element refs and never query the document.
|
|
64
64
|
// `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
|
|
65
|
-
export function mount(part, { createWorker, elements = {}, onBuild, onPick,
|
|
65
|
+
export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload,
|
|
66
66
|
container: legacyContainer, controls: legacyControls } = {}) {
|
|
67
67
|
// --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
|
|
68
68
|
const byId = (id) => document.getElementById(id);
|
|
@@ -287,12 +287,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
|
|
|
287
287
|
}
|
|
288
288
|
case "download-parts":
|
|
289
289
|
ui.hideBusy();
|
|
290
|
-
downloadParts(data, zipName);
|
|
290
|
+
downloadParts(data, zipName, onDownload);
|
|
291
291
|
ui.setStatus(`${data.parts.length} part(s) downloaded`);
|
|
292
292
|
break;
|
|
293
293
|
case "download":
|
|
294
294
|
ui.hideBusy();
|
|
295
|
-
triggerDownload(data.data, data.filename, data.mime);
|
|
295
|
+
triggerDownload(data.data, data.filename, data.mime, onDownload);
|
|
296
296
|
ui.setStatus(`${data.filename} downloaded`);
|
|
297
297
|
break;
|
|
298
298
|
case "needs-occt":
|