partforge 0.165.0 → 0.165.1

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.
@@ -106,13 +106,17 @@ relying on consumption should gate it with a `verify` volume assertion.
106
106
  **B-rep class.** Core plus native `fillet`/`chamfer`/`shell` and `toSTEP`. The in-repo
107
107
  OCCT/replicad backend is the reference.
108
108
 
109
- **Optional ops.** `KERNEL_OPTIONAL_OPS` (`beginSubPart`/`endSubPart`/`sweepCache`/
109
+ **Optional ops.** `KERNEL_OPTIONAL_OPS` (`beginSubPart`/`endSubPart`/`sweepCache`/`dropCache`/
110
110
  `cacheStats`/`resetCacheStats`/`cleanup`) and `SOLID_OPTIONAL_OPS` (`genus`/`isEmpty`) may
111
111
  be omitted entirely; callers in the framework guard with `?.`/`typeof`. A host that omits
112
112
  them loses sub-part caching and mesh-topology gates (`holes`, emptiness), nothing else.
113
113
  `sweepCache()` is the cache's rebind hygiene hook: called once when a worker is rebound to
114
114
  a part (never inside a `beginSubPart`/`endSubPart` bracket), it drops cache partitions that
115
- have gone unbuilt for three consecutive rebinds.
115
+ have gone unbuilt for three consecutive rebinds. `dropCache()` drops every partition at
116
+ once, under the same never-mid-bracket rule: the worker calls it on each booted kernel
117
+ before an export job, because a display build's retained solids and the export's own
118
+ build share one WASM heap, and on a very large part the cache alone can leave too little
119
+ heap for the export. The next display build after an export is cold.
116
120
 
117
121
  `beginSubPart`/`endSubPart` brackets MAY nest: only the outermost pair opens and
118
122
  commits a round, and an inner pair is a balanced no-op. Nesting is real rather than
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.165.0",
3
+ "version": "0.165.1",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,16 +47,26 @@ export function bedWidthFor(part) {
47
47
  }
48
48
  }
49
49
 
50
- // `pieces`: [{ name, solid, ...rest }] in export order. Map-form pieces are laid out
51
- // together; legacy ones keep their export pose, and so do sheet pieces — a sheet's pose IS
52
- // its placement, so sheet exports come out assembled. Returns a new array, same order.
50
+ // Map-form pieces are laid out together; legacy ones keep their export pose, and so do
51
+ // sheet pieces — a sheet's pose IS its placement, so sheet exports come out assembled.
52
+ export function laidOutOnBed(part, name) {
53
+ const sp = part.parts[name];
54
+ return formOf(sp) === "map" && !isSheetPart(sp);
55
+ }
56
+
57
+ // `boxes`: [{ name, box }] for the laid-out pieces, in export order → Map(name → offset).
58
+ // Bounds alone are enough, which is what lets the 3MF export free each piece as soon as
59
+ // it is meshed and shift the mesh afterwards (feedback #212).
60
+ export function exportLayoutOffsets(part, boxes) {
61
+ if (boxes.length === 0) return new Map();
62
+ return bedLayout(boxes.map(({ name, box }) => ({ key: name, ...box })), { width: bedWidthFor(part) });
63
+ }
64
+
65
+ // `pieces`: [{ name, solid, ...rest }] in export order. Returns a new array, same order,
66
+ // laid-out pieces translated (see laidOutOnBed).
53
67
  export function layoutExportPieces(part, pieces) {
54
- const laidOut = (sp) => formOf(sp) === "map" && !isSheetPart(sp);
55
- const mapPieces = pieces.filter(({ name }) => laidOut(part.parts[name]));
68
+ const mapPieces = pieces.filter(({ name }) => laidOutOnBed(part, name));
56
69
  if (mapPieces.length === 0) return pieces;
57
- const offsets = bedLayout(
58
- mapPieces.map(({ name, solid }) => ({ key: name, ...solid.boundingBox() })),
59
- { width: bedWidthFor(part) },
60
- );
70
+ const offsets = exportLayoutOffsets(part, mapPieces.map(({ name, solid }) => ({ name, box: solid.boundingBox() })));
61
71
  return pieces.map((pc) => (offsets.has(pc.name) ? { ...pc, solid: pc.solid.translate(offsets.get(pc.name)) } : pc));
62
72
  }
@@ -392,6 +392,7 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
392
392
  pp.triangles = indices.length / 3;
393
393
  }
394
394
  builtPrints.push(pp);
395
+ kernel.cleanup?.(); // pp keeps only JS-owned data; free this piece's solids before the next (feedback #212)
395
396
  }
396
397
  const prints = dedupe(builtPrints, (x) => x.hash, (a, b) => a.triangles === b.triangles && a.color === b.color
397
398
  && near(a.volume, b.volume) && a.size.every((x, i) => near(x, b.size[i])))
@@ -36,7 +36,7 @@ export const KERNEL_OPS = [
36
36
  // OCCT's replicad shapes need no dispose bookkeeping). jobs.js calls all of these via
37
37
  // `?.`, so a third-party backend may simply omit them.
38
38
  export const KERNEL_OPTIONAL_OPS = [
39
- "beginSubPart", "endSubPart", "sweepCache", "cacheStats", "resetCacheStats", "cleanup",
39
+ "beginSubPart", "endSubPart", "sweepCache", "dropCache", "cacheStats", "resetCacheStats", "cleanup",
40
40
  // Drains the feature-skip warnings recorded since the last drain — a fillet or
41
41
  // chamfer the geometry defeated and the backend skipped rather than failed the
42
42
  // build over. Both backends implement it; a host that never calls it sees the
@@ -170,6 +170,7 @@ export const ROUTED_CAD_OPS = ["shell"];
170
170
  * @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (both backends)
171
171
  * @property {() => void} [endSubPart] close the cache round (always pair with beginSubPart)
172
172
  * @property {() => void} [sweepCache] drop cache partitions idle for 3 rebinds; call once per setPart, never mid-bracket
173
+ * @property {() => void} [dropCache] drop EVERY cache partition (the worker calls it before an export); never mid-bracket
173
174
  * @property {() => {hits:number,misses:number}} [cacheStats]
174
175
  * @property {() => void} [resetCacheStats]
175
176
  * @property {() => void} [cleanup] free per-job WASM objects (Manifold backend); call after each job
@@ -1063,6 +1063,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
1063
1063
  beginSubPart: (name) => cache.begin(name),
1064
1064
  endSubPart: () => cache.end(),
1065
1065
  sweepCache: () => cache.sweep(),
1066
+ dropCache: () => cache.clear(),
1066
1067
  cacheStats: () => cache.stats(),
1067
1068
  resetCacheStats: () => cache.resetStats(),
1068
1069
  // Drain the feature-skip warnings recorded since the last drain (see
@@ -859,6 +859,7 @@ export function createOcctKernel(replicad) {
859
859
  beginSubPart: (name) => cache.begin(name),
860
860
  endSubPart: () => cache.end(),
861
861
  sweepCache: () => cache.sweep(),
862
+ dropCache: () => cache.clear(),
862
863
  cacheStats: () => cache.stats(),
863
864
  resetCacheStats: () => cache.resetStats(),
864
865
  // Drain the feature-skip warnings recorded since the last drain — the
@@ -66,6 +66,20 @@ export function createSolidCache() {
66
66
  }
67
67
  },
68
68
 
69
+ // Drop EVERY partition, whatever its age — the next build of each sub-part is
70
+ // cold. The worker calls this before an export (feedback #212): the display
71
+ // build's retained solids and the export's own print-quality build share one
72
+ // WASM heap, and on a 105-plate forge the display cache alone held ~1.75 GB,
73
+ // so the export ran out of heap after a few plates on a phone. Like sweep(),
74
+ // never mid-bracket — the worker's job queue is serial.
75
+ clear() {
76
+ for (const entries of caches.values()) {
77
+ for (const [hash, entry] of entries) release(hash, entry); // refcounted: each partition lets go once
78
+ }
79
+ caches.clear();
80
+ lastBuilt.clear();
81
+ },
82
+
69
83
  lookup(hash, make) {
70
84
  if (name == null) return make().value; // not bracketed → no caching
71
85
  if (active.has(hash)) { hits++; return active.get(hash).value; }
@@ -163,6 +163,22 @@ async function scoreMatchTargets(built, targets, onProgress) {
163
163
  }
164
164
  }
165
165
 
166
+ // The WASM traps that mean the kernel ran out of heap (or was corrupted by doing so).
167
+ const HEAP_TRAP_RE = /out of bounds memory access|memory access out of bounds|Cannot enlarge memory|out of memory/i;
168
+
169
+ // A piece of a many-piece export failed: say WHICH piece, how far the export got, and
170
+ // — when it is the heap that ran out — the one remedy the user can apply. The raw
171
+ // message stays LAST, after a colon, so a host's own trap classifier still matches it.
172
+ function exportPieceError(thrown, { kernel, piece, index, count }) {
173
+ const err = describeKernelThrow(thrown, { evidence: kernel._throwEvidence?.(thrown) });
174
+ if (count < 2 || err?.code) return err; // one piece: the error already names it; coded errors are routed, not shown
175
+ const raw = String(err?.message || err);
176
+ const advice = HEAP_TRAP_RE.test(raw)
177
+ ? " ran out of memory — export fewer parts at a time (each export needs room for the parts it builds)"
178
+ : " failed";
179
+ return new Error(`exporting "${piece}" (part ${index + 1} of ${count})${advice}: ${raw}`, { cause: thrown });
180
+ }
181
+
166
182
  export async function handle(kernel, part, msg, post, opts = {}) {
167
183
  const isStale = opts.isStale ?? (() => false);
168
184
  const onProgress = (phase) => post({ type: "progress", phase, jobId: msg.jobId });
@@ -500,9 +516,18 @@ export async function handle(kernel, part, msg, post, opts = {}) {
500
516
  const names = selected();
501
517
  if (names.length === 0) throw new Error("no exportable parts selected");
502
518
  const out = [];
503
- for (const name of names) {
519
+ for (const [i, name] of names.entries()) {
504
520
  onProgress(`building ${label(name)}`);
505
- out.push({ name: exportName(name), data: await posed(name, "export", onProgress).toSTL({ quality: msg.quality ?? "print" }) });
521
+ try {
522
+ out.push({ name: exportName(name), data: await posed(name, "export", onProgress).toSTL({ quality: msg.quality ?? "print" }) });
523
+ } catch (thrown) {
524
+ throw exportPieceError(thrown, { kernel, piece: label(name), index: i, count: names.length });
525
+ }
526
+ // Each file is JS-owned once toSTL returns, so this piece's solids can go now.
527
+ // Without it a many-piece export held every piece's print-quality build until
528
+ // the job ended — the 105-plate forge of feedback #212 ran out of heap at
529
+ // about the 100th plate.
530
+ kernel.cleanup?.();
506
531
  }
507
532
  post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out, jobId: msg.jobId },
508
533
  out.map((pp) => bufferOf(pp.data)));
@@ -523,12 +548,27 @@ export async function handle(kernel, part, msg, post, opts = {}) {
523
548
  } else if (msg.type === "export-3mf") {
524
549
  const names = selected();
525
550
  if (names.length === 0) throw new Error("no exportable parts selected");
526
- const { layoutExportPieces } = await loadBedLayout();
527
- const meshes = layoutExportPieces(part, names.map((name) => {
551
+ // One piece in the kernel at a time, as export-stl: build, read the bounds the bed
552
+ // layout needs, mesh into JS-owned arrays, free — then lay out from the bounds and
553
+ // shift the meshes (translation only, so this is the layout layoutExportPieces makes).
554
+ const { laidOutOnBed, exportLayoutOffsets } = await loadBedLayout();
555
+ const built = [];
556
+ for (const [i, name] of names.entries()) {
528
557
  onProgress(`building ${label(name)}`);
529
- return { name, solid: posed(name, "export", onProgress) };
530
- })).map(({ name, solid }) => {
531
- const { positions, indices } = solid.toIndexedMesh({ quality: msg.quality ?? "print" });
558
+ try {
559
+ const solid = posed(name, "export", onProgress);
560
+ const box = laidOutOnBed(part, name) ? solid.boundingBox() : null;
561
+ const { positions, indices } = solid.toIndexedMesh({ quality: msg.quality ?? "print" });
562
+ built.push({ name, box, positions, indices });
563
+ } catch (thrown) {
564
+ throw exportPieceError(thrown, { kernel, piece: label(name), index: i, count: names.length });
565
+ }
566
+ kernel.cleanup?.();
567
+ }
568
+ const offsets = exportLayoutOffsets(part, built.filter((b) => b.box));
569
+ const meshes = built.map(({ name, positions, indices }) => {
570
+ const o = offsets.get(name);
571
+ if (o) for (let j = 0; j < positions.length; j += 3) { positions[j] += o[0]; positions[j + 1] += o[1]; positions[j + 2] += o[2]; }
532
572
  return { name: exportName(name), positions, indices, color: printColor(part.parts[name]?.display) };
533
573
  });
534
574
  onProgress("writing 3MF file");
@@ -30,6 +30,15 @@ async function manifoldKernels() {
30
30
  };
31
31
  }
32
32
 
33
+ // Jobs that build every selected sub-part again at export quality. Before one runs,
34
+ // the worker drops its kernels' display caches (dropCache): the cache's retained
35
+ // solids and the export's build share one WASM heap, and on a very large part the
36
+ // display build alone held ~1.75 GB — on a phone, enough that exporting three of a
37
+ // 105-plate forge's plates ran out of heap (feedback #212). The cost is one cold
38
+ // display build after an export; exports are rare and deliberate, a failed one
39
+ // costs the user the whole job.
40
+ const EXPORT_JOBS = new Set(["export-stl", "export-step", "export-3mf", "export-bundle"]);
41
+
33
42
  async function occtKernel() {
34
43
  const [{ default: opencascade }, wasmUrlMod, replicad, { createOcctKernel }] = await Promise.all([
35
44
  import("replicad-opencascadejs/src/replicad_single.js"),
@@ -115,6 +124,11 @@ export function runWorker(part, opts = {}) {
115
124
  // A generate superseded while it sat in the queue never builds at all.
116
125
  if (job.epoch !== null && job.epoch !== epoch) continue;
117
126
  kernel = await kernelFor(job.data);
127
+ if (EXPORT_JOBS.has(job.data?.type)) {
128
+ manifold?.preview.dropCache?.();
129
+ manifold?.print.dropCache?.();
130
+ occt?.dropCache?.();
131
+ }
118
132
  // handle() declares each message's transferables (the big binary buffers).
119
133
  const post = (m, transfer = []) => postMessage(m, transfer);
120
134
  if (job.epoch === null) { await handle(kernel, job.part, job.data, post, { importMeshes, jobs: opts.jobs }); continue; }
package/types/kernel.d.ts CHANGED
@@ -642,6 +642,8 @@ export interface GeometryKernel {
642
642
  endSubPart?(): void;
643
643
  /** Drop cache partitions idle for 3 rebinds. Never call mid-bracket. */
644
644
  sweepCache?(): void;
645
+ /** Drop every cache partition — the worker calls this before an export job. Never call mid-bracket. */
646
+ dropCache?(): void;
645
647
  cacheStats?(): { hits: number; misses: number };
646
648
  resetCacheStats?(): void;
647
649
  /** Free per-job WASM objects (Manifold backend); call after each job. */