partforge 0.165.0 → 0.166.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.
@@ -106,13 +106,24 @@ 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`/
110
- `cacheStats`/`resetCacheStats`/`cleanup`) and `SOLID_OPTIONAL_OPS` (`genus`/`isEmpty`) may
109
+ **Optional ops.** `KERNEL_OPTIONAL_OPS` (`beginSubPart`/`endSubPart`/`sweepCache`/`dropCache`/
110
+ `setCacheBudget`/`trimCache`/`cacheBytes`/`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.
120
+ `setCacheBudget(bytes)` bounds the bytes the cache's retained solids may hold (the
121
+ Manifold backend estimates each from its triangle and vertex counts); `trimCache()`
122
+ then drops whole partitions, least recently built first, until they fit. A trim frees
123
+ solids, so it runs only where no caller holds one — never inside a bracket and never
124
+ right after one whose solids are still in use; jobs.js trims after each display
125
+ sub-part is meshed and when a job returns. The budget arrives on `generate` messages
126
+ as `cacheBudget` (bytes, `null` = unbounded), from mount's `cacheBudgetMB` option.
116
127
 
117
128
  `beginSubPart`/`endSubPart` brackets MAY nest: only the outermost pair opens and
118
129
  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.166.0",
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", "setCacheBudget", "trimCache", "cacheBytes", "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,10 @@ 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
174
+ * @property {(bytes:number) => void} [setCacheBudget] bound the solid cache's retained bytes (Infinity = unbounded)
175
+ * @property {() => number} [trimCache] drop least recently built partitions until the cache fits its budget; only where no cached solid is held
176
+ * @property {() => number} [cacheBytes] the cache's estimated retained bytes
173
177
  * @property {() => {hits:number,misses:number}} [cacheStats]
174
178
  * @property {() => void} [resetCacheStats]
175
179
  * @property {() => void} [cleanup] free per-job WASM objects (Manifold backend); call after each job
@@ -61,6 +61,8 @@ function axisAngleMat4(axis, deg) {
61
61
  return [R00, R10, R20, 0, R01, R11, R21, 0, R02, R12, R22, 0, 0, 0, 0, 1];
62
62
  }
63
63
 
64
+ const BYTES_PER_TRI = 160, BYTES_PER_VERT = 24;
65
+
64
66
  export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
65
67
  const { Manifold, CrossSection } = wasm;
66
68
  const segs = SEGS[quality], tube = TUBE[quality];
@@ -128,7 +130,18 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
128
130
  };
129
131
  const judgedUnion = (ms) => judged("union", ms, unionRaw(ms));
130
132
 
131
- const cache = createSolidCache();
133
+ // A retained solid's heap, estimated from its counts (solid-cache.js MEMORY BUDGET):
134
+ // Manifold keeps per triangle three half-edges, a face normal, a triangle reference and
135
+ // its share of the collider, and per vertex a position and a normal.
136
+ // CrossSections (memoized 2-D profiles) are small: a few dozen bytes per vertex.
137
+ // Measured against the heap a 105-plate forge's cache held (feedback #212).
138
+ const cache = createSolidCache({
139
+ sizeOf: (pin) => {
140
+ if (typeof pin?.numTri === "function") return pin.numTri() * BYTES_PER_TRI + pin.numVert() * BYTES_PER_VERT;
141
+ if (typeof pin?.numVert === "function") return pin.numVert() * 32;
142
+ return 0;
143
+ },
144
+ });
132
145
  // Feature-skip warnings (the OCCT backend's safeOp policy, adopted here): a
133
146
  // fillet/chamfer whose mesh machinery is defeated by the geometry returns its
134
147
  // INPUT solid unchanged and records one message here instead of failing the
@@ -1063,6 +1076,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
1063
1076
  beginSubPart: (name) => cache.begin(name),
1064
1077
  endSubPart: () => cache.end(),
1065
1078
  sweepCache: () => cache.sweep(),
1079
+ dropCache: () => cache.clear(),
1080
+ setCacheBudget: (bytes) => cache.setBudget(bytes),
1081
+ trimCache: () => cache.trim(),
1082
+ cacheBytes: () => cache.retainedBytes(),
1066
1083
  cacheStats: () => cache.stats(),
1067
1084
  resetCacheStats: () => cache.resetStats(),
1068
1085
  // 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
@@ -7,7 +7,21 @@
7
7
  // entry is retained by BOTH partitions, so disposal is refcounted — see release().
8
8
  // WASM-agnostic — it stores opaque {value, pin, dispose} triples supplied by the
9
9
  // caller (the Manifold backend), so it is unit-testable with plain objects.
10
- export function createSolidCache() {
10
+ //
11
+ // MEMORY BUDGET (feedback #212). Retaining a whole view's build graph is what makes an
12
+ // edit rebuild in seconds, and it is also most of the heap a big view holds: opening a
13
+ // 105-plate forge peaked at 3.3 GB with the cache and 0.95 GB without, which a phone's
14
+ // browser tab cannot hold. `setBudget(bytes)` bounds it; `trim()` then disposes whole
15
+ // partitions, least recently built first, until the retained solids fit. A sub-part
16
+ // trimmed away simply rebuilds cold next time. `sizeOf(pin)` is the caller's estimate
17
+ // of one retained object's bytes (the Manifold backend reads its triangle and vertex
18
+ // counts); it is asked once per entry, at the first trim() after the entry was made.
19
+ //
20
+ // trim() is NOT done inside end(): a caller may still be using a round's solids after
21
+ // closing it (buildView hands its built view to measure), and a trim there would free
22
+ // them under it. jobs.js trims where nothing is held — after each display sub-part has
23
+ // been meshed and cleaned up, and when a job ends.
24
+ export function createSolidCache({ sizeOf = () => 0 } = {}) {
11
25
  const caches = new Map(); // name -> Map(hash -> { value, pin, dispose, refs })
12
26
  const pinned = new Set(); // every live `pin` across all sub-parts
13
27
  const index = new Map(); // hash -> entry, ACROSS partitions: identical geometry built
@@ -17,6 +31,11 @@ export function createSolidCache() {
17
31
  let name = null, active = null, prev = null;
18
32
  let depth = 0; // bracket nesting; only the OUTERMOST one is real (see begin)
19
33
  let hits = 0, misses = 0;
34
+ let budget = Infinity; // bytes the retained solids may hold (setBudget)
35
+ let bytes = 0; // the retained solids' estimated size, each entry once
36
+ let tick = 0; // begin() order, for least-recently-built eviction
37
+ const lastUsed = new Map(); // name -> tick of the partition's last begin()
38
+ const unsized = new Set(); // entries made since the last trim(), size not yet read
20
39
 
21
40
  // One partition stops retaining `entry`. Disposal waits for the LAST holder:
22
41
  // a solid shared across sub-parts is one WASM object, so disposing it when the
@@ -27,8 +46,16 @@ export function createSolidCache() {
27
46
  if (--entry.refs > 0) return;
28
47
  if (index.get(hash) === entry) index.delete(hash);
29
48
  pinned.delete(entry.pin);
49
+ unsized.delete(entry);
50
+ bytes -= entry.size ?? 0;
30
51
  entry.dispose();
31
52
  };
53
+ const dropPartition = (n) => {
54
+ for (const [hash, entry] of caches.get(n) ?? []) release(hash, entry); // refcounted: a shared solid survives
55
+ caches.delete(n);
56
+ lastBuilt.delete(n);
57
+ lastUsed.delete(n);
58
+ };
32
59
 
33
60
  return {
34
61
  // Nested brackets collapse into the outermost one. There is a single open
@@ -39,7 +66,7 @@ export function createSolidCache() {
39
66
  // contain one, and the inner pair becomes a no-op.
40
67
  begin(n) {
41
68
  if (depth++ > 0) return;
42
- name = n; lastBuilt.set(n, generation); prev = caches.get(n) ?? new Map(); active = new Map();
69
+ name = n; lastBuilt.set(n, generation); lastUsed.set(n, ++tick); prev = caches.get(n) ?? new Map(); active = new Map();
43
70
  },
44
71
 
45
72
  end() {
@@ -58,12 +85,52 @@ export function createSolidCache() {
58
85
  // the eviction line, so recently-viewed views stay warm across edits.
59
86
  sweep() {
60
87
  generation++;
61
- for (const [n, entries] of caches) {
88
+ for (const n of [...caches.keys()]) {
62
89
  if (generation - (lastBuilt.get(n) ?? 0) < 3) continue;
63
- for (const [hash, entry] of entries) release(hash, entry); // refcounted: a shared solid survives
64
- caches.delete(n);
65
- lastBuilt.delete(n);
90
+ dropPartition(n);
91
+ }
92
+ },
93
+
94
+ // Drop EVERY partition, whatever its age — the next build of each sub-part is
95
+ // cold. The worker calls this before an export (feedback #212): the display
96
+ // build's retained solids and the export's own print-quality build share one
97
+ // WASM heap, and on a 105-plate forge the display cache alone held ~1.75 GB,
98
+ // so the export ran out of heap after a few plates on a phone. Like sweep(),
99
+ // never mid-bracket — the worker's job queue is serial.
100
+ clear() {
101
+ for (const entries of caches.values()) {
102
+ for (const [hash, entry] of entries) release(hash, entry); // refcounted: each partition lets go once
103
+ }
104
+ caches.clear();
105
+ lastBuilt.clear();
106
+ lastUsed.clear();
107
+ },
108
+
109
+ // Bytes the retained solids may hold; Infinity (the default) never trims.
110
+ setBudget(b) {
111
+ budget = typeof b === "number" && b >= 0 ? b : Infinity;
112
+ },
113
+
114
+ // Dispose whole partitions, least recently built first, until the retained solids
115
+ // fit the budget. Never mid-bracket, and only where no caller holds a cached solid
116
+ // (see the header). Returns how many partitions it dropped.
117
+ trim() {
118
+ if (name != null) return 0;
119
+ for (const entry of unsized) {
120
+ let s = 0;
121
+ try { s = Math.max(0, Number(sizeOf(entry.pin)) || 0); } catch { s = 0; }
122
+ entry.size = s;
123
+ bytes += s;
124
+ }
125
+ unsized.clear();
126
+ let dropped = 0;
127
+ while (bytes > budget && lastUsed.size > 0) {
128
+ let oldest = null, at = Infinity;
129
+ for (const [n, t] of lastUsed) if (t < at) { at = t; oldest = n; }
130
+ dropPartition(oldest);
131
+ dropped++;
66
132
  }
133
+ return dropped;
67
134
  },
68
135
 
69
136
  lookup(hash, make) {
@@ -76,6 +143,7 @@ export function createSolidCache() {
76
143
  misses++;
77
144
  const entry = make();
78
145
  entry.refs = 1;
146
+ unsized.add(entry);
79
147
  active.set(hash, entry);
80
148
  index.set(hash, entry);
81
149
  pinned.add(entry.pin);
@@ -84,6 +152,7 @@ export function createSolidCache() {
84
152
 
85
153
  isPinned: (pin) => pinned.has(pin),
86
154
  stats: () => ({ hits, misses }),
155
+ retainedBytes: () => bytes,
87
156
  resetStats: () => { hits = 0; misses = 0; },
88
157
  };
89
158
  }
@@ -65,7 +65,7 @@ const loadBedLayout = () => import("./export/bed-layout.js");
65
65
 
66
66
  // Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
67
67
  // Backend-agnostic and part-agnostic: every part specific comes through `part`.
68
- // { type:"generate", subparts, view, params } → { type:"meshes", meshes, ms }
68
+ // { type:"generate", subparts, view, params, cacheBudget? } → { type:"meshes", meshes, ms }
69
69
  // { type:"export-stl", view, params } → { type:"download-parts", ext, mime, parts }
70
70
  // { type:"export-step", view, params } → { type:"download", data, filename, mime }
71
71
  // { type:"export-bundle", view, params, parts, options }
@@ -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 });
@@ -446,6 +462,9 @@ export async function handle(kernel, part, msg, post, opts = {}) {
446
462
  kernel.takeBuildWarnings?.();
447
463
  const warnings = [...jobWarnings]; // job-level notices ride along with the per-sub-part ones
448
464
  kernel.resetCacheStats?.(); // count hits/misses for just this job
465
+ // The host's bound on the solid cache (mount's cacheBudgetMB), in bytes; null
466
+ // means unbounded. Kept by the kernel, so the oracle's builds share it.
467
+ if (Object.hasOwn(msg, "cacheBudget")) kernel.setCacheBudget?.(msg.cacheBudget ?? Infinity);
449
468
  for (const [i, name] of msg.subparts.entries()) {
450
469
  if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
451
470
  try {
@@ -456,6 +475,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
456
475
  for (const message of kernel.takeBuildWarnings?.() ?? []) warnings.push({ part: name, message });
457
476
  if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
458
477
  kernel.cleanup?.(); // free this round's transients (cached/pinned solids survive)
478
+ kernel.trimCache?.(); // over the host's budget: drop the least recently built sub-parts' solids
459
479
  }
460
480
  // Cooperative cancel: yield one macrotask so queued messages (a newer
461
481
  // generate, a part rebind) can be seen, then stop at this boundary if
@@ -500,9 +520,18 @@ export async function handle(kernel, part, msg, post, opts = {}) {
500
520
  const names = selected();
501
521
  if (names.length === 0) throw new Error("no exportable parts selected");
502
522
  const out = [];
503
- for (const name of names) {
523
+ for (const [i, name] of names.entries()) {
504
524
  onProgress(`building ${label(name)}`);
505
- out.push({ name: exportName(name), data: await posed(name, "export", onProgress).toSTL({ quality: msg.quality ?? "print" }) });
525
+ try {
526
+ out.push({ name: exportName(name), data: await posed(name, "export", onProgress).toSTL({ quality: msg.quality ?? "print" }) });
527
+ } catch (thrown) {
528
+ throw exportPieceError(thrown, { kernel, piece: label(name), index: i, count: names.length });
529
+ }
530
+ // Each file is JS-owned once toSTL returns, so this piece's solids can go now.
531
+ // Without it a many-piece export held every piece's print-quality build until
532
+ // the job ended — the 105-plate forge of feedback #212 ran out of heap at
533
+ // about the 100th plate.
534
+ kernel.cleanup?.();
506
535
  }
507
536
  post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out, jobId: msg.jobId },
508
537
  out.map((pp) => bufferOf(pp.data)));
@@ -523,12 +552,27 @@ export async function handle(kernel, part, msg, post, opts = {}) {
523
552
  } else if (msg.type === "export-3mf") {
524
553
  const names = selected();
525
554
  if (names.length === 0) throw new Error("no exportable parts selected");
526
- const { layoutExportPieces } = await loadBedLayout();
527
- const meshes = layoutExportPieces(part, names.map((name) => {
555
+ // One piece in the kernel at a time, as export-stl: build, read the bounds the bed
556
+ // layout needs, mesh into JS-owned arrays, free — then lay out from the bounds and
557
+ // shift the meshes (translation only, so this is the layout layoutExportPieces makes).
558
+ const { laidOutOnBed, exportLayoutOffsets } = await loadBedLayout();
559
+ const built = [];
560
+ for (const [i, name] of names.entries()) {
528
561
  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" });
562
+ try {
563
+ const solid = posed(name, "export", onProgress);
564
+ const box = laidOutOnBed(part, name) ? solid.boundingBox() : null;
565
+ const { positions, indices } = solid.toIndexedMesh({ quality: msg.quality ?? "print" });
566
+ built.push({ name, box, positions, indices });
567
+ } catch (thrown) {
568
+ throw exportPieceError(thrown, { kernel, piece: label(name), index: i, count: names.length });
569
+ }
570
+ kernel.cleanup?.();
571
+ }
572
+ const offsets = exportLayoutOffsets(part, built.filter((b) => b.box));
573
+ const meshes = built.map(({ name, positions, indices }) => {
574
+ const o = offsets.get(name);
575
+ 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
576
  return { name: exportName(name), positions, indices, color: printColor(part.parts[name]?.display) };
533
577
  });
534
578
  onProgress("writing 3MF file");
@@ -665,5 +709,6 @@ export async function handle(kernel, part, msg, post, opts = {}) {
665
709
  else post({ type: "error", message: String(err?.message || err), jobId: msg.jobId });
666
710
  } finally {
667
711
  kernel.cleanup?.();
712
+ kernel.trimCache?.(); // a job holds no cached solid once it has returned (solid-cache.js MEMORY BUDGET)
668
713
  }
669
714
  }
@@ -286,7 +286,8 @@ function createCleanupStack() {
286
286
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
287
287
  // so Vite can bundle the worker (see geometry-service.js).
288
288
  //
289
- // Embedding contract (0.158.0: a preview GLB may carry its view's autoplay
289
+ // Embedding contract (0.166.0: `cacheBudgetMB` bounds the worker's solid cache —
290
+ // pass a small one on phones; 0.158.0: a preview GLB may carry its view's autoplay
290
291
  // animation — mount({ preview }) draws its opening pose and does not play it,
291
292
  // mountPreview plays it unless `animate: false`, and exportPreview bakes it only
292
293
  // when every track is place-only; 0.147.0: a cached `preview` GLB is drawn before
@@ -515,6 +516,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
515
516
  files, panelState,
516
517
  annotateSend = "viewbar",
517
518
  selectionPopover,
519
+ cacheBudgetMB,
518
520
  container: legacyContainer, controls: legacyControls } = {}) {
519
521
  // --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
520
522
  const byId = (id) => document.getElementById(id);
@@ -641,6 +643,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
641
643
  const qs = new URLSearchParams(location.search);
642
644
  const debug = qs.has("debug");
643
645
  let cachingOn = !(debug && qs.has("nocache"));
646
+ // The host's bound on the worker's solid cache, sent with every generate (null =
647
+ // unbounded). A phone host passes a small one: the cache of a very large view is
648
+ // most of its heap (feedback #212). Anything but a positive finite number is ignored.
649
+ const cacheBudgetBytes = Number.isFinite(cacheBudgetMB) && cacheBudgetMB > 0 ? Math.round(cacheBudgetMB * 2 ** 20) : null;
644
650
  let lastGen = { skipped: 0, rebuilt: 0, posed: 0 }; // Layer-0/1 counts for the most recent generate
645
651
  // Sub-parts the pose fast path has repaired since the last build was dispatched.
646
652
  // A SET of names, not a running total: a slider drag re-repairs the same
@@ -1069,7 +1075,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1069
1075
  for (const backend of ["manifold", "occt"]) {
1070
1076
  const subparts = missing.filter((n) => (backends[n] ?? "manifold") === backend);
1071
1077
  if (subparts.length === 0) continue;
1072
- service.send({ type: "generate", subparts, view: view(), params, cache: cachingOn }, backend);
1078
+ service.send({ type: "generate", subparts, view: view(), params, cache: cachingOn, cacheBudget: cacheBudgetBytes }, backend);
1073
1079
  jobs++;
1074
1080
  }
1075
1081
  return jobs;
@@ -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/index.d.ts CHANGED
@@ -218,6 +218,14 @@ export interface MountOptions {
218
218
  onPick?: (event: PickEvent) => void;
219
219
  /** Opt in to the selection popover (requires `onPick`): a pick opens a popover at the spot with the sub-part's appearance controls, and with `slot: true` an empty region the host floats its own UI over (`runtime.selection`). */
220
220
  selectionPopover?: { slot: boolean };
221
+ /**
222
+ * Bound, in MB, on the geometry worker's solid cache — the built solids it keeps so
223
+ * an edit rebuilds only what changed. Unbounded by default. Past the bound the least
224
+ * recently built sub-parts' solids are freed and those sub-parts rebuild cold on the
225
+ * next edit. Pass a small bound on phones: a very large view's cache can hold
226
+ * gigabytes, more than a mobile browser tab is allowed.
227
+ */
228
+ cacheBudgetMB?: number;
221
229
  /** Receive exported bytes instead of partforge's own DOM download. */
222
230
  onDownload?: (file: DownloadPayload) => void;
223
231
  /** The active view (tab) name — emitted once on mount, then on every change. */
package/types/kernel.d.ts CHANGED
@@ -642,6 +642,14 @@ 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;
647
+ /** Bound the solid cache's retained bytes (`Infinity` = unbounded, the default). */
648
+ setCacheBudget?(bytes: number): void;
649
+ /** Drop the least recently built partitions until the cache fits its budget. Only where no cached solid is held. */
650
+ trimCache?(): number;
651
+ /** The cache's estimated retained bytes. */
652
+ cacheBytes?(): number;
645
653
  cacheStats?(): { hits: number; misses: number };
646
654
  resetCacheStats?(): void;
647
655
  /** Free per-job WASM objects (Manifold backend); call after each job. */