partforge 0.165.1 → 0.167.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/KERNEL-CONTRACT.md +19 -1
- package/package.json +1 -1
- package/src/framework/geometry/kernel.js +4 -1
- package/src/framework/geometry/manifold-backend.js +17 -1
- package/src/framework/geometry/solid-cache.js +61 -6
- package/src/framework/jobs.js +27 -4
- package/src/framework/mount.js +87 -30
- package/src/framework/regen-loop.js +3 -0
- package/src/framework/worker.js +7 -2
- package/types/index.d.ts +17 -0
- package/types/kernel.d.ts +6 -0
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -107,7 +107,7 @@ relying on consumption should gate it with a `verify` volume assertion.
|
|
|
107
107
|
OCCT/replicad backend is the reference.
|
|
108
108
|
|
|
109
109
|
**Optional ops.** `KERNEL_OPTIONAL_OPS` (`beginSubPart`/`endSubPart`/`sweepCache`/`dropCache`/
|
|
110
|
-
`cacheStats`/`resetCacheStats`/`cleanup`) and `SOLID_OPTIONAL_OPS` (`genus`/`isEmpty`) may
|
|
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
|
|
@@ -117,6 +117,13 @@ once, under the same never-mid-bracket rule: the worker calls it on each booted
|
|
|
117
117
|
before an export job, because a display build's retained solids and the export's own
|
|
118
118
|
build share one WASM heap, and on a very large part the cache alone can leave too little
|
|
119
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.
|
|
120
127
|
|
|
121
128
|
`beginSubPart`/`endSubPart` brackets MAY nest: only the outermost pair opens and
|
|
122
129
|
commits a round, and an inner pair is a balanced no-op. Nesting is real rather than
|
|
@@ -888,6 +895,17 @@ built. So **a `meshes` post is current as of the moment it is posted**: it is ne
|
|
|
888
895
|
previous part's geometry surfacing after a rebind, and a host may take it as the build
|
|
889
896
|
outcome for the part it currently has mounted.
|
|
890
897
|
|
|
898
|
+
**Progressive generates.** A generate carrying `progressive: true` (mount's
|
|
899
|
+
`progressiveBuild` option) also posts `{type:"meshes-partial", meshes}` while it runs:
|
|
900
|
+
the sub-parts built so far, batched at most every 300 ms, each posted right after a
|
|
901
|
+
sub-part boundary's staleness check. A batch that is stale by the time it is posted is
|
|
902
|
+
dropped by the worker, never downgraded to `superseded` — the job's own final post still
|
|
903
|
+
settles it. The final `{type:"meshes"}` then carries only the sub-parts no batch did, so
|
|
904
|
+
every sub-part arrives exactly once, its buffers transferred with it. A batch settles
|
|
905
|
+
nothing: `ready`, `onBuild` and a host's own "build finished" wait for `meshes`. A host
|
|
906
|
+
that reads worker replies itself and turns `progressive` on must add the batches up; one
|
|
907
|
+
that never sends it sees the protocol exactly as before.
|
|
908
|
+
|
|
891
909
|
**Host-side rule for `superseded`.** partforge's own `mount()` does not handle a
|
|
892
910
|
`superseded` message, and does not need to: in its single-mount flow the regen loop
|
|
893
911
|
serializes generates, so no generate is ever in flight when the next one is sent and the
|
package/package.json
CHANGED
|
@@ -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", "dropCache", "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
|
|
@@ -171,6 +171,9 @@ export const ROUTED_CAD_OPS = ["shell"];
|
|
|
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
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
|
|
174
177
|
* @property {() => {hits:number,misses:number}} [cacheStats]
|
|
175
178
|
* @property {() => void} [resetCacheStats]
|
|
176
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
|
-
|
|
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
|
|
@@ -1064,6 +1077,9 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
1064
1077
|
endSubPart: () => cache.end(),
|
|
1065
1078
|
sweepCache: () => cache.sweep(),
|
|
1066
1079
|
dropCache: () => cache.clear(),
|
|
1080
|
+
setCacheBudget: (bytes) => cache.setBudget(bytes),
|
|
1081
|
+
trimCache: () => cache.trim(),
|
|
1082
|
+
cacheBytes: () => cache.retainedBytes(),
|
|
1067
1083
|
cacheStats: () => cache.stats(),
|
|
1068
1084
|
resetCacheStats: () => cache.resetStats(),
|
|
1069
1085
|
// Drain the feature-skip warnings recorded since the last drain (see
|
|
@@ -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
|
-
|
|
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,11 +85,9 @@ 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
|
|
88
|
+
for (const n of [...caches.keys()]) {
|
|
62
89
|
if (generation - (lastBuilt.get(n) ?? 0) < 3) continue;
|
|
63
|
-
|
|
64
|
-
caches.delete(n);
|
|
65
|
-
lastBuilt.delete(n);
|
|
90
|
+
dropPartition(n);
|
|
66
91
|
}
|
|
67
92
|
},
|
|
68
93
|
|
|
@@ -78,6 +103,34 @@ export function createSolidCache() {
|
|
|
78
103
|
}
|
|
79
104
|
caches.clear();
|
|
80
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++;
|
|
132
|
+
}
|
|
133
|
+
return dropped;
|
|
81
134
|
},
|
|
82
135
|
|
|
83
136
|
lookup(hash, make) {
|
|
@@ -90,6 +143,7 @@ export function createSolidCache() {
|
|
|
90
143
|
misses++;
|
|
91
144
|
const entry = make();
|
|
92
145
|
entry.refs = 1;
|
|
146
|
+
unsized.add(entry);
|
|
93
147
|
active.set(hash, entry);
|
|
94
148
|
index.set(hash, entry);
|
|
95
149
|
pinned.add(entry.pin);
|
|
@@ -98,6 +152,7 @@ export function createSolidCache() {
|
|
|
98
152
|
|
|
99
153
|
isPinned: (pin) => pinned.has(pin),
|
|
100
154
|
stats: () => ({ hits, misses }),
|
|
155
|
+
retainedBytes: () => bytes,
|
|
101
156
|
resetStats: () => { hits = 0; misses = 0; },
|
|
102
157
|
};
|
|
103
158
|
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -65,7 +65,9 @@ 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?, progressive? } → { type:"meshes", meshes, ms }
|
|
69
|
+
// progressive: true also posts { type:"meshes-partial", meshes } batches while it
|
|
70
|
+
// builds; the final `meshes` then carries only the sub-parts no batch did
|
|
69
71
|
// { type:"export-stl", view, params } → { type:"download-parts", ext, mime, parts }
|
|
70
72
|
// { type:"export-step", view, params } → { type:"download", data, filename, mime }
|
|
71
73
|
// { type:"export-bundle", view, params, parts, options }
|
|
@@ -163,6 +165,11 @@ async function scoreMatchTargets(built, targets, onProgress) {
|
|
|
163
165
|
}
|
|
164
166
|
}
|
|
165
167
|
|
|
168
|
+
// A progressive generate hands over its built sub-parts at most this often (ms).
|
|
169
|
+
const PROGRESSIVE_BATCH_MS = 300;
|
|
170
|
+
const transferOf = (meshes) => meshes.flatMap((m) =>
|
|
171
|
+
[m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
|
|
172
|
+
|
|
166
173
|
// The WASM traps that mean the kernel ran out of heap (or was corrupted by doing so).
|
|
167
174
|
const HEAP_TRAP_RE = /out of bounds memory access|memory access out of bounds|Cannot enlarge memory|out of memory/i;
|
|
168
175
|
|
|
@@ -462,6 +469,10 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
462
469
|
kernel.takeBuildWarnings?.();
|
|
463
470
|
const warnings = [...jobWarnings]; // job-level notices ride along with the per-sub-part ones
|
|
464
471
|
kernel.resetCacheStats?.(); // count hits/misses for just this job
|
|
472
|
+
// The host's bound on the solid cache (mount's cacheBudgetMB), in bytes; null
|
|
473
|
+
// means unbounded. Kept by the kernel, so the oracle's builds share it.
|
|
474
|
+
if (Object.hasOwn(msg, "cacheBudget")) kernel.setCacheBudget?.(msg.cacheBudget ?? Infinity);
|
|
475
|
+
let lastBatchAt = t0; // progressive delivery's clock (below)
|
|
465
476
|
for (const [i, name] of msg.subparts.entries()) {
|
|
466
477
|
if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
|
|
467
478
|
try {
|
|
@@ -472,6 +483,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
472
483
|
for (const message of kernel.takeBuildWarnings?.() ?? []) warnings.push({ part: name, message });
|
|
473
484
|
if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
|
|
474
485
|
kernel.cleanup?.(); // free this round's transients (cached/pinned solids survive)
|
|
486
|
+
kernel.trimCache?.(); // over the host's budget: drop the least recently built sub-parts' solids
|
|
475
487
|
}
|
|
476
488
|
// Cooperative cancel: yield one macrotask so queued messages (a newer
|
|
477
489
|
// generate, a part rebind) can be seen, then stop at this boundary if
|
|
@@ -481,12 +493,22 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
481
493
|
if (i < msg.subparts.length - 1) {
|
|
482
494
|
await new Promise((r) => setTimeout(r, 0));
|
|
483
495
|
if (isStale()) return void post({ type: "superseded" });
|
|
496
|
+
// Progressive delivery (msg.progressive): hand over what is built so far,
|
|
497
|
+
// so a host shows a many-sub-part view filling in instead of waiting for
|
|
498
|
+
// the last sub-part (feedback #212: a 105-plate park showed nothing for
|
|
499
|
+
// ~45 s). Batched by time, never per sub-part, so a view of many cheap
|
|
500
|
+
// pieces is not a flood of messages. Posted only after the stale check
|
|
501
|
+
// above, so a batch is current as of the moment it is posted; the final
|
|
502
|
+
// `meshes` carries whatever is left, and the job's warnings and timing.
|
|
503
|
+
if (msg.progressive && meshes.length > 0 && Date.now() - lastBatchAt >= PROGRESSIVE_BATCH_MS) {
|
|
504
|
+
const batch = meshes.splice(0);
|
|
505
|
+
post({ type: "meshes-partial", meshes: batch }, transferOf(batch));
|
|
506
|
+
lastBatchAt = Date.now();
|
|
507
|
+
}
|
|
484
508
|
}
|
|
485
509
|
}
|
|
486
|
-
const transfer = meshes.flatMap((m) =>
|
|
487
|
-
[m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
|
|
488
510
|
post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.(),
|
|
489
|
-
...(warnings.length ? { warnings } : {}) },
|
|
511
|
+
...(warnings.length ? { warnings } : {}) }, transferOf(meshes));
|
|
490
512
|
} else if (msg.type === "capture-generate") {
|
|
491
513
|
// A private, job-correlated one-shot channel for captureView — builds a
|
|
492
514
|
// (possibly non-active) view's meshes off the regen loop, so it can never
|
|
@@ -705,5 +727,6 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
705
727
|
else post({ type: "error", message: String(err?.message || err), jobId: msg.jobId });
|
|
706
728
|
} finally {
|
|
707
729
|
kernel.cleanup?.();
|
|
730
|
+
kernel.trimCache?.(); // a job holds no cached solid once it has returned (solid-cache.js MEMORY BUDGET)
|
|
708
731
|
}
|
|
709
732
|
}
|
package/src/framework/mount.js
CHANGED
|
@@ -286,7 +286,11 @@ 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.
|
|
289
|
+
// Embedding contract (0.167.0: `progressiveBuild: true` shows a many-sub-part view
|
|
290
|
+
// filling in as its sub-parts finish — the worker then also posts `meshes-partial`
|
|
291
|
+
// batches, and the final `meshes` carries only what no batch did, so a host reading
|
|
292
|
+
// worker replies itself must add them up; 0.166.0: `cacheBudgetMB` bounds the worker's solid cache —
|
|
293
|
+
// pass a small one on phones; 0.158.0: a preview GLB may carry its view's autoplay
|
|
290
294
|
// animation — mount({ preview }) draws its opening pose and does not play it,
|
|
291
295
|
// mountPreview plays it unless `animate: false`, and exportPreview bakes it only
|
|
292
296
|
// when every track is place-only; 0.147.0: a cached `preview` GLB is drawn before
|
|
@@ -515,6 +519,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
515
519
|
files, panelState,
|
|
516
520
|
annotateSend = "viewbar",
|
|
517
521
|
selectionPopover,
|
|
522
|
+
cacheBudgetMB,
|
|
523
|
+
progressiveBuild = false,
|
|
518
524
|
container: legacyContainer, controls: legacyControls } = {}) {
|
|
519
525
|
// --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
|
|
520
526
|
const byId = (id) => document.getElementById(id);
|
|
@@ -641,6 +647,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
641
647
|
const qs = new URLSearchParams(location.search);
|
|
642
648
|
const debug = qs.has("debug");
|
|
643
649
|
let cachingOn = !(debug && qs.has("nocache"));
|
|
650
|
+
// The host's bound on the worker's solid cache, sent with every generate (null =
|
|
651
|
+
// unbounded). A phone host passes a small one: the cache of a very large view is
|
|
652
|
+
// most of its heap (feedback #212). Anything but a positive finite number is ignored.
|
|
653
|
+
const cacheBudgetBytes = Number.isFinite(cacheBudgetMB) && cacheBudgetMB > 0 ? Math.round(cacheBudgetMB * 2 ** 20) : null;
|
|
644
654
|
let lastGen = { skipped: 0, rebuilt: 0, posed: 0 }; // Layer-0/1 counts for the most recent generate
|
|
645
655
|
// Sub-parts the pose fast path has repaired since the last build was dispatched.
|
|
646
656
|
// A SET of names, not a running total: a slider drag re-repairs the same
|
|
@@ -1010,6 +1020,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1010
1020
|
|
|
1011
1021
|
let framedView = null; // the view the camera was last framed to (null until first show)
|
|
1012
1022
|
let cameraRestored = false; // saved camera applied once, on the first frame after load
|
|
1023
|
+
let partialView = null; // the view whose first build is shown in batches (showPartial)
|
|
1024
|
+
let partialCameraKept = false; // that build must not reframe on completion
|
|
1013
1025
|
let cutawayRestored = false; // a carried cutaway, applied once — after any preview, never on one
|
|
1014
1026
|
let selectionRestored = false; // a carried selection, tried once, after the camera
|
|
1015
1027
|
function restoreCarriedSelection() {
|
|
@@ -1069,7 +1081,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1069
1081
|
for (const backend of ["manifold", "occt"]) {
|
|
1070
1082
|
const subparts = missing.filter((n) => (backends[n] ?? "manifold") === backend);
|
|
1071
1083
|
if (subparts.length === 0) continue;
|
|
1072
|
-
service.send({ type: "generate", subparts, view: view(), params, cache: cachingOn }, backend);
|
|
1084
|
+
service.send({ type: "generate", subparts, view: view(), params, cache: cachingOn, cacheBudget: cacheBudgetBytes, ...(progressiveBuild ? { progressive: true } : {}) }, backend);
|
|
1073
1085
|
jobs++;
|
|
1074
1086
|
}
|
|
1075
1087
|
return jobs;
|
|
@@ -1131,7 +1143,13 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1131
1143
|
}
|
|
1132
1144
|
|
|
1133
1145
|
function showView(needed) {
|
|
1134
|
-
|
|
1146
|
+
let frame = view() !== framedView;
|
|
1147
|
+
// A first build shown in batches (showPartial) completes: frame the whole view now,
|
|
1148
|
+
// unless the user moved the camera meanwhile or a restored camera stands.
|
|
1149
|
+
if (partialView === view()) {
|
|
1150
|
+
partialView = null;
|
|
1151
|
+
if (frame && partialCameraKept) { frame = false; framedView = view(); cameraRestored = true; }
|
|
1152
|
+
}
|
|
1135
1153
|
viewer.showAssembly(needed, { frame });
|
|
1136
1154
|
if (frame) {
|
|
1137
1155
|
framedView = view();
|
|
@@ -1283,6 +1301,63 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1283
1301
|
}
|
|
1284
1302
|
}
|
|
1285
1303
|
|
|
1304
|
+
// Apply a delivery built at the LIVE params — a final `meshes` whose build is still
|
|
1305
|
+
// fresh, or a progressive batch (meshes-partial) of a build that still is.
|
|
1306
|
+
function applyFreshMeshes(meshes) {
|
|
1307
|
+
liveDelivered = true; // only a delivery that is applied beats a preview still decoding
|
|
1308
|
+
for (const m of meshes) {
|
|
1309
|
+
viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
|
|
1310
|
+
// The view its job was SENT for, and the frame the worker delivered
|
|
1311
|
+
// in: "canonical" (place() not applied — mount poses it below) or
|
|
1312
|
+
// "posed". An older worker sends no frame; its meshes are posed.
|
|
1313
|
+
cache.record(m.name, m.reads, dispatched.get(m.name)?.view ?? view(), m.frame ?? "posed");
|
|
1314
|
+
// A sub-part that built into NOTHING. Manifold booleans return an
|
|
1315
|
+
// empty solid rather than throwing (a bore wider than its body),
|
|
1316
|
+
// and typed values may sit outside the authored range, so a
|
|
1317
|
+
// "successful" build can deliver zero triangles — recorded here,
|
|
1318
|
+
// reported below once the view is whole. Undefined counts (a
|
|
1319
|
+
// host's synthetic reply) are not empty.
|
|
1320
|
+
if (m.triangles === 0) emptySubParts.add(m.name); else emptySubParts.delete(m.name);
|
|
1321
|
+
// Stamp the fast path's pose baseline. Only here, inside the
|
|
1322
|
+
// non-stale branch: the stamp must describe the geometry actually
|
|
1323
|
+
// delivered, which buildDone() true guarantees is at the live params.
|
|
1324
|
+
fastPath.recordDelivered(m.name);
|
|
1325
|
+
}
|
|
1326
|
+
// setSubGeometry reset every delivered mesh's matrix, so each one gets
|
|
1327
|
+
// its display pose now, after its geometry landed: a canonical mesh
|
|
1328
|
+
// place() at the live params, a posed one a cleared matrix. `fresh`:
|
|
1329
|
+
// an untrusted live place() here clears the pose but keeps the stamp
|
|
1330
|
+
// (host and worker probes agree by construction at these params;
|
|
1331
|
+
// forgetting on a disagreement would rebuild at the same params forever).
|
|
1332
|
+
fastPath.apply(meshes.map((m) => m.name), undefined, { fresh: true });
|
|
1333
|
+
recordFrames(meshes);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Progressive delivery (feedback #212). A view with many sub-parts posts what it has
|
|
1337
|
+
// built in batches while the job runs, and the view fills in as they land. Framing is
|
|
1338
|
+
// the one hazard: showView frames once per view, and framing to the first batch
|
|
1339
|
+
// would leave a park's camera zoomed in on five plates. So a first build with nothing
|
|
1340
|
+
// on screen frames to the first batch, and showView frames the whole view when the
|
|
1341
|
+
// build completes — unless the user has moved the camera since, or a carried or
|
|
1342
|
+
// persisted camera was restored, which win as they always have.
|
|
1343
|
+
cleanup.defer(viewer.onCameraStart(() => { if (partialView === view()) partialCameraKept = true; }));
|
|
1344
|
+
function showPartial() {
|
|
1345
|
+
const needed = viewSubParts(part, view(), params);
|
|
1346
|
+
// Everything already on screen (an edit), or a cached preview filling the
|
|
1347
|
+
// gaps: the ordinary path shows it.
|
|
1348
|
+
if (previewGate.isActive() || needed.every((n) => viewer.hasSubMesh(n))) return refreshView();
|
|
1349
|
+
const present = needed.filter((n) => viewer.hasSubMesh(n));
|
|
1350
|
+
let frame = false;
|
|
1351
|
+
if (view() !== framedView && partialView !== view()) {
|
|
1352
|
+
partialView = view();
|
|
1353
|
+
partialCameraKept = false;
|
|
1354
|
+
const cam = !cameraRestored ? viewerState?.camera ?? loadCamera() : null;
|
|
1355
|
+
if (cam) { viewer.setCameraState(cam); cameraRestored = true; partialCameraKept = true; }
|
|
1356
|
+
else frame = true;
|
|
1357
|
+
}
|
|
1358
|
+
viewer.showAssembly(present, { frame });
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1286
1361
|
ui.showBusy("booting kernel"); // visible from first paint until the kernel is ready
|
|
1287
1362
|
|
|
1288
1363
|
// Bundle filename for a multi-part export (single parts download under their own name).
|
|
@@ -1305,36 +1380,18 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1305
1380
|
ui.showBusy(data.phase);
|
|
1306
1381
|
ui.setStatus(`${data.phase}…`);
|
|
1307
1382
|
break;
|
|
1383
|
+
case "meshes-partial":
|
|
1384
|
+
// A batch of a progressive build: shown only while that build is still
|
|
1385
|
+
// current. It settles nothing — the job's final `meshes` does.
|
|
1386
|
+
if (!loop.isFresh()) break;
|
|
1387
|
+
applyFreshMeshes(data.meshes);
|
|
1388
|
+
showPartial();
|
|
1389
|
+
updateRelevance();
|
|
1390
|
+
break;
|
|
1308
1391
|
case "meshes": {
|
|
1309
1392
|
const fresh = loop.buildDone();
|
|
1310
1393
|
if (fresh) { // stale results (params changed mid-build) are discarded
|
|
1311
|
-
|
|
1312
|
-
for (const m of data.meshes) {
|
|
1313
|
-
viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
|
|
1314
|
-
// The view its job was SENT for, and the frame the worker delivered
|
|
1315
|
-
// in: "canonical" (place() not applied — mount poses it below) or
|
|
1316
|
-
// "posed". An older worker sends no frame; its meshes are posed.
|
|
1317
|
-
cache.record(m.name, m.reads, dispatched.get(m.name)?.view ?? view(), m.frame ?? "posed");
|
|
1318
|
-
// A sub-part that built into NOTHING. Manifold booleans return an
|
|
1319
|
-
// empty solid rather than throwing (a bore wider than its body),
|
|
1320
|
-
// and typed values may sit outside the authored range, so a
|
|
1321
|
-
// "successful" build can deliver zero triangles — recorded here,
|
|
1322
|
-
// reported below once the view is whole. Undefined counts (a
|
|
1323
|
-
// host's synthetic reply) are not empty.
|
|
1324
|
-
if (m.triangles === 0) emptySubParts.add(m.name); else emptySubParts.delete(m.name);
|
|
1325
|
-
// Stamp the fast path's pose baseline. Only here, inside the
|
|
1326
|
-
// non-stale branch: the stamp must describe the geometry actually
|
|
1327
|
-
// delivered, which buildDone() true guarantees is at the live params.
|
|
1328
|
-
fastPath.recordDelivered(m.name);
|
|
1329
|
-
}
|
|
1330
|
-
// setSubGeometry reset every delivered mesh's matrix, so each one gets
|
|
1331
|
-
// its display pose now, after its geometry landed: a canonical mesh
|
|
1332
|
-
// place() at the live params, a posed one a cleared matrix. `fresh`:
|
|
1333
|
-
// an untrusted live place() here clears the pose but keeps the stamp
|
|
1334
|
-
// (host and worker probes agree by construction at these params;
|
|
1335
|
-
// forgetting on a disagreement would rebuild at the same params forever).
|
|
1336
|
-
fastPath.apply(data.meshes.map((m) => m.name), undefined, { fresh: true });
|
|
1337
|
-
recordFrames(data.meshes);
|
|
1394
|
+
applyFreshMeshes(data.meshes);
|
|
1338
1395
|
// The whole view is live: every sub-part the preview drew has just been
|
|
1339
1396
|
// replaced in place, so the tools it held back come back. Before
|
|
1340
1397
|
// refreshView, so showView restores a carried cutaway on this build.
|
|
@@ -53,6 +53,9 @@ export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
|
|
|
53
53
|
return genVersion === paramsVersion;
|
|
54
54
|
},
|
|
55
55
|
version: () => paramsVersion,
|
|
56
|
+
// A build is in flight and nothing has edited the params since it was sent —
|
|
57
|
+
// the test a progressive batch (meshes-partial) must pass to be shown.
|
|
58
|
+
isFresh: () => pending > 0 && genVersion === paramsVersion,
|
|
56
59
|
// Terminal: cancel the pending debounce and refuse all future sends.
|
|
57
60
|
dispose() { disposed = true; clearTimeout(timer); },
|
|
58
61
|
};
|
package/src/framework/worker.js
CHANGED
|
@@ -138,8 +138,13 @@ export function runWorker(part, opts = {}) {
|
|
|
138
138
|
// generate that goes stale once dequeued. Both would otherwise post the OLD
|
|
139
139
|
// part's meshes after a rebind. Downgrading them to `superseded` keeps the
|
|
140
140
|
// contract simple: a `meshes` post is current as of the moment it is posted.
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
// A progressive batch that went stale is simply dropped: the job's own
|
|
142
|
+
// final reply (or its superseded) still settles the cycle.
|
|
143
|
+
const gated = (m, transfer = []) => {
|
|
144
|
+
if (m.type === "meshes-partial" && isStale()) return;
|
|
145
|
+
if (m.type === "meshes" && isStale()) return post({ type: "superseded" });
|
|
146
|
+
post(m, transfer);
|
|
147
|
+
};
|
|
143
148
|
await handle(kernel, job.part, job.data, gated, { isStale, importMeshes, jobs: opts.jobs });
|
|
144
149
|
} catch (thrown) {
|
|
145
150
|
// Same shape jobs.js posts for a failed build, so hosts need no new branch.
|
package/types/index.d.ts
CHANGED
|
@@ -218,6 +218,23 @@ 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;
|
|
229
|
+
/**
|
|
230
|
+
* Show a view filling in as its sub-parts finish, instead of all at once when the
|
|
231
|
+
* last one does — the difference on a forge of dozens of heavy pieces is the whole
|
|
232
|
+
* build time. Off by default. When on, the geometry worker also posts
|
|
233
|
+
* `{ type: "meshes-partial", meshes }` batches during a build and the final
|
|
234
|
+
* `{ type: "meshes" }` carries only the sub-parts no batch did: a host that reads
|
|
235
|
+
* worker replies itself must add the batches up.
|
|
236
|
+
*/
|
|
237
|
+
progressiveBuild?: boolean;
|
|
221
238
|
/** Receive exported bytes instead of partforge's own DOM download. */
|
|
222
239
|
onDownload?: (file: DownloadPayload) => void;
|
|
223
240
|
/** The active view (tab) name — emitted once on mount, then on every change. */
|
package/types/kernel.d.ts
CHANGED
|
@@ -644,6 +644,12 @@ export interface GeometryKernel {
|
|
|
644
644
|
sweepCache?(): void;
|
|
645
645
|
/** Drop every cache partition — the worker calls this before an export job. Never call mid-bracket. */
|
|
646
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;
|
|
647
653
|
cacheStats?(): { hits: number; misses: number };
|
|
648
654
|
resetCacheStats?(): void;
|
|
649
655
|
/** Free per-job WASM objects (Manifold backend); call after each job. */
|