partforge 0.28.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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; transforms move the snapshots along, booleans merge the two
27
- // sides' lists. At toMesh() time result faces are classified against the snapshots.
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
- const wrap = (shape, labels = []) => addSugar({
32
- _s: shape,
33
- _labels: labels,
34
- label: (name) => wrap(shape, [...labels, { label: name, snapshot: shape.clone() }]),
35
- cut: (t) => wrap(shape.cut(t._s), [...cloneLabels(labels), ...cloneLabels(t._labels ?? [])]),
36
- cutAll: (tools) => wrap(
37
- shape.cut(makeCompound(tools.map((t) => t._s))),
38
- [...cloneLabels(labels), ...tools.flatMap((t) => cloneLabels(t._labels ?? []))]
39
- ),
40
- intersect: (t) => wrap(shape.intersect(t._s), [...cloneLabels(labels), ...cloneLabels(t._labels ?? [])]),
41
- union: (t) => wrap(shape.fuse(t._s), [...cloneLabels(labels), ...cloneLabels(t._labels ?? [])]),
42
- clone: () => wrap(shape.clone(), cloneLabels(labels)),
43
- boundingBox: () => {
44
- const [min, max] = shape.boundingBox.bounds; // addSugar derives center/size
45
- return { min: [...min], max: [...max] };
46
- },
47
- translate: (v) => wrap(shape.translate(v), mapLabels(labels, (s) => s.translate(v))),
48
- rotate: (deg, center, axis) => wrap(shape.rotate(deg, center, axis), mapLabels(labels, (s) => s.rotate(deg, center, axis))),
49
- mirror: (plane) => wrap(shape.mirror(plane), mapLabels(labels, (s) => s.mirror(plane))),
50
- scale: (factor, center) => wrap(shape.scale(factor, center), mapLabels(labels, (s) => s.scale(factor, center))), // validated/defaulted by addSugar
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
- toSTL: ({ quality = "print" } = {}) => shape.blobSTL(MESH[quality]).arrayBuffer(),
69
- fillet: (radius, selector) => wrap(safeOp(shape, (sh) => sh.fillet(radius, toEdgeFinder(selector)), `fillet(${radius})`), cloneLabels(labels)),
70
- chamfer: (distance, selector) => wrap(validChamfer(shape, toEdgeFinder(selector), distance), cloneLabels(labels)),
71
- shell: (thickness, openFaces) => {
72
- if (openFaces == null) throw new Error("shell: openFaces is required (a fully closed hollow is not supported)");
73
- // replicad shells inward with a positive thickness in this version, keeping outer dimensions.
74
- return wrap(safeOp(shape, (sh) => sh.shell(thickness, toFaceFinder(openFaces)), `shell(${thickness})`), cloneLabels(labels));
75
- },
76
- volume: () => measureVolume(shape),
77
- toIndexedMesh: () => {
78
- const m = shape.mesh(MESH.preview);
79
- return { positions: Float32Array.from(m.vertices), indices: Uint32Array.from(m.triangles) };
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, h, { center = false } = {}) => {
85
- const z0 = center ? -h / 2 : 0;
86
- if (Math.abs(rb - rt) < 1e-9) return wrap(makeCylinder(rb, h, [0, 0, z0]));
87
- const w1 = assembleWire([makeCircle(rb, [0, 0, z0])]);
88
- const w2 = assembleWire([makeCircle(rt, [0, 0, z0 + h])]);
89
- return wrap(loft([w1, w2]));
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
- const wrapShape2d = (drawing) => {
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
- union: (o) => wrapShape2d(drawing.clone().fuse(liftDrawing(o)._drawing.clone())),
166
- cut: (o) => wrapShape2d(drawing.clone().cut(liftDrawing(o)._drawing.clone())),
167
- cutAll: (os) => wrapShape2d(os.map(liftDrawing).reduce((acc, t) => acc.cut(t._drawing.clone()), drawing.clone())),
168
- intersect: (o) => wrapShape2d(drawing.clone().intersect(liftDrawing(o)._drawing.clone())),
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, h, { twist = 0, scaleTop = 1 } = {}) => {
198
- const sketch = contourDrawing(pts).sketchOnPlane("XY");
199
- if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
200
- const cfg = {};
201
- if (twist !== 0) cfg.twistAngle = twist;
202
- if (scaleTop !== 1) cfg.extrusionProfile = { profile: "linear", endFactor: scaleTop };
203
- return wrap(sketch.extrude(h, cfg));
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 region = pts && pts._shape2d ? pts._drawing.clone() : contourDrawing(pts);
209
- return wrap(region.sketchOnPlane("XZ").revolve([0, 0, 1], { angle: degrees }));
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, h, { twist = 0, scaleTop = 1 } = {}) => {
217
- const region = profile && profile._shape2d ? profile._drawing.clone() : drawingFromProfile(profile);
218
- const sketch = region.sketchOnPlane("XY");
219
- if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
220
- const cfg = {};
221
- if (twist !== 0) cfg.twistAngle = twist;
222
- if (scaleTop !== 1) cfg.extrusionProfile = { profile: "linear", endFactor: scaleTop };
223
- return wrap(sketch.extrude(h, cfg));
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 wires = resolveRings(rings).map(({ pts2d, z }) => contourDrawing(pts2d).sketchOnPlane("XY", z).wire);
231
- return wrap(loft(wires, { ruled }));
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
- if (smooth) return sweepSmooth(profile2D, path3D, cornerRadius);
254
- const { stations } = resolveSweepStations(profile2D, path3D, { closed, cornerRadius });
255
- const wires = stations.map((ring) => assembleWire(ring.map((p, i) => makeLine(p, ring[(i + 1) % ring.length]))));
256
- return wrap(loft(wires, { ruled }));
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 = ({ pathR, profileR, pitch, turns, z0, lefthand }) => {
261
- const spine = makeHelix(pitch, pitch * turns, pathR, [0, 0, z0], [0, 0, 1], lefthand);
262
- const dir = lefthand ? -1 : 1;
263
- const tangent = [0, dir * pathR, pitch / (2 * Math.PI)];
264
- const profile = assembleWire([makeCircle(profileR, [pathR, 0, z0], tangent)]);
265
- return wrap(genericSweep(profile, spine, { frenet: true }));
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) => wrap(makeBox(min, max)), prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube,
271
- sphere: (r) => wrap(makeSphere(r)),
272
- union: (solids) => wrap(
273
- solids.map((s) => s._s).reduce((a, b) => a.fuse(b)),
274
- solids.flatMap((s) => cloneLabels(s._labels ?? []))
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) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
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
  }
@@ -42,7 +42,9 @@ export function createOcctRepair(measureVolume) {
42
42
  // WASM heap doesn't grow across regenerates.
43
43
  const validChamfer = (shape, finderFn, distance) => {
44
44
  if (!(distance > 0)) return shape.clone();
45
+ let attempts = 0;
45
46
  const tryAt = (d) => {
47
+ attempts++;
46
48
  const probe = shape.clone();
47
49
  let res;
48
50
  try { res = probe.chamfer(d, finderFn); } catch { return null; } // probe consumed by the op
@@ -50,6 +52,7 @@ export function createOcctRepair(measureVolume) {
50
52
  res.delete?.();
51
53
  return null;
52
54
  };
55
+ const t0 = performance.now();
53
56
  let best = tryAt(distance);
54
57
  if (best) return best; // requested distance is valid
55
58
  let lo = 0, hi = distance, bestD = 0;
@@ -58,7 +61,12 @@ export function createOcctRepair(measureVolume) {
58
61
  const res = tryAt(mid);
59
62
  if (res) { best?.delete?.(); best = res; bestD = mid; lo = mid; } else hi = mid;
60
63
  }
61
- if (best) { console.info(`partforge: chamfer ${distance} reduced to ${bestD.toFixed(2)} (largest valid for this geometry)`); return best; }
64
+ // The rescue re-ran the chamfer per attempt on a many-edge selection that
65
+ // multiplies an already-expensive op by ~8x, so make the cost loud enough to
66
+ // act on (lower the distance, or bevel profile rims with a loft instead).
67
+ const cost = `${attempts} attempts, ${((performance.now() - t0) / 1000).toFixed(1)}s — see ERROR-PATTERNS.md#chamfer-rescue-bisection`;
68
+ if (best) { console.warn(`partforge: chamfer ${distance} over-ran the geometry — reduced to ${bestD.toFixed(2)} (largest valid; ${cost})`); return best; }
69
+ console.warn(`partforge: chamfer ${distance} has no valid distance for this geometry — feature skipped (${cost})`);
62
70
  return shape.clone(); // nothing valid — skip the chamfer
63
71
  };
64
72
 
@@ -103,7 +103,10 @@ export function prismArgs(o) {
103
103
  }
104
104
 
105
105
  export function extrudeArgs(o) {
106
- checkKeys("extrude", o, ["profile", "h", "twist", "scaleTop"]);
106
+ // `bevel` is accepted here so the validating probe (lint) doesn't flag it, but
107
+ // it never reaches the positional backend op — kernel-front.js desugars a
108
+ // bevel call into extrude + loft + intersect before this normalizer runs.
109
+ checkKeys("extrude", o, ["profile", "h", "twist", "scaleTop", "bevel"]);
107
110
  return [req("extrude", o, "profile"), req("extrude", o, "h"), ...tail(o, ["twist", "scaleTop"])];
108
111
  }
109
112
 
@@ -0,0 +1,79 @@
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
+ // The same math backs the viewer's pose fast path: when a param change only
8
+ // moves a sub-part, `poseDelta` (via `invertRigid`) gives the matrix carrying an
9
+ // already-delivered mesh from the pose it was built at to the new one, so the
10
+ // viewer re-poses instead of rebuilding.
11
+
12
+ const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
13
+
14
+ // column-major 4x4 product: (A·B)[c][r] = Σk A[k][r]·B[c][k]
15
+ function mulMat4(A, B) {
16
+ const o = new Array(16);
17
+ for (let c = 0; c < 4; c++)
18
+ for (let r = 0; r < 4; r++)
19
+ 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];
20
+ return o;
21
+ }
22
+
23
+ const translation = (v) => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, v[0], v[1], v[2], 1];
24
+
25
+ // axis-angle rotation about an axis THROUGH `center`: T(center) · R(axis, deg) · T(−center)
26
+ function rotationAbout(deg, center, axis) {
27
+ const len = Math.hypot(axis[0], axis[1], axis[2]) || 1;
28
+ const x = axis[0] / len, y = axis[1] / len, z = axis[2] / len;
29
+ const t = (deg * Math.PI) / 180, c = Math.cos(t), s = Math.sin(t), C = 1 - c;
30
+ const R = [
31
+ c + x * x * C, y * x * C + z * s, z * x * C - y * s, 0,
32
+ x * y * C - z * s, c + y * y * C, z * y * C + x * s, 0,
33
+ x * z * C + y * s, y * z * C - x * s, c + z * z * C, 0,
34
+ 0, 0, 0, 1,
35
+ ];
36
+ return mulMat4(translation(center), mulMat4(R, translation([-center[0], -center[1], -center[2]])));
37
+ }
38
+
39
+ const stepMatrix = (s) => (s.t === "translate" ? translation(s.v) : rotationAbout(s.deg, s.center, s.axis));
40
+
41
+ // Fold steps so the EARLIEST step applies first: p' = Mn · … · M1 · p.
42
+ export const composePose = (steps) => steps.reduce((m, s) => mulMat4(stepMatrix(s), m), IDENTITY);
43
+
44
+ // Apply a mat4 to an interleaved xyz Float32Array in place.
45
+ export function transformPositions(positions, m) {
46
+ for (let i = 0; i < positions.length; i += 3) {
47
+ const x = positions[i], y = positions[i + 1], z = positions[i + 2];
48
+ positions[i] = m[0] * x + m[4] * y + m[8] * z + m[12];
49
+ positions[i + 1] = m[1] * x + m[5] * y + m[9] * z + m[13];
50
+ positions[i + 2] = m[2] * x + m[6] * y + m[10] * z + m[14];
51
+ }
52
+ }
53
+
54
+ // Invert a rigid mat4 (rotation + translation only): Rᵀ, t' = −Rᵀ·t. Valid only
55
+ // for matrices produced by composePose — transposing the 3x3 block inverts a
56
+ // rotation, so any scale or shear in it yields garbage rather than an inverse.
57
+ export function invertRigid(m) {
58
+ const r0 = m[0], r1 = m[1], r2 = m[2],
59
+ r4 = m[4], r5 = m[5], r6 = m[6],
60
+ r8 = m[8], r9 = m[9], r10 = m[10],
61
+ tx = m[12], ty = m[13], tz = m[14];
62
+ return [
63
+ r0, r4, r8, 0,
64
+ r1, r5, r9, 0,
65
+ r2, r6, r10, 0,
66
+ -(r0 * tx + r1 * ty + r2 * tz),
67
+ -(r4 * tx + r5 * ty + r6 * tz),
68
+ -(r8 * tx + r9 * ty + r10 * tz),
69
+ 1,
70
+ ];
71
+ }
72
+
73
+ // The matrix that carries a mesh delivered at `oldSteps` to the pose `newSteps`:
74
+ // compose(new) · compose(old)⁻¹. Both step lists come from the pose probe.
75
+ export const poseDelta = (newSteps, oldSteps) => {
76
+ const target = composePose(newSteps);
77
+ const inv = invertRigid(composePose(oldSteps));
78
+ return mulMat4(target, inv);
79
+ };