partforge 0.166.0 → 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 +11 -0
- package/package.json +1 -1
- package/src/framework/jobs.js +22 -4
- package/src/framework/mount.js +81 -30
- package/src/framework/regen-loop.js +3 -0
- package/src/framework/worker.js +7 -2
- package/types/index.d.ts +9 -0
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -895,6 +895,17 @@ built. So **a `meshes` post is current as of the moment it is posted**: it is ne
|
|
|
895
895
|
previous part's geometry surfacing after a rebind, and a host may take it as the build
|
|
896
896
|
outcome for the part it currently has mounted.
|
|
897
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
|
+
|
|
898
909
|
**Host-side rule for `superseded`.** partforge's own `mount()` does not handle a
|
|
899
910
|
`superseded` message, and does not need to: in its single-mount flow the regen loop
|
|
900
911
|
serializes generates, so no generate is ever in flight when the next one is sent and the
|
package/package.json
CHANGED
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, cacheBudget? } → { 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
|
|
|
@@ -465,6 +472,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
465
472
|
// The host's bound on the solid cache (mount's cacheBudgetMB), in bytes; null
|
|
466
473
|
// means unbounded. Kept by the kernel, so the oracle's builds share it.
|
|
467
474
|
if (Object.hasOwn(msg, "cacheBudget")) kernel.setCacheBudget?.(msg.cacheBudget ?? Infinity);
|
|
475
|
+
let lastBatchAt = t0; // progressive delivery's clock (below)
|
|
468
476
|
for (const [i, name] of msg.subparts.entries()) {
|
|
469
477
|
if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
|
|
470
478
|
try {
|
|
@@ -485,12 +493,22 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
485
493
|
if (i < msg.subparts.length - 1) {
|
|
486
494
|
await new Promise((r) => setTimeout(r, 0));
|
|
487
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
|
+
}
|
|
488
508
|
}
|
|
489
509
|
}
|
|
490
|
-
const transfer = meshes.flatMap((m) =>
|
|
491
|
-
[m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
|
|
492
510
|
post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.(),
|
|
493
|
-
...(warnings.length ? { warnings } : {}) },
|
|
511
|
+
...(warnings.length ? { warnings } : {}) }, transferOf(meshes));
|
|
494
512
|
} else if (msg.type === "capture-generate") {
|
|
495
513
|
// A private, job-correlated one-shot channel for captureView — builds a
|
|
496
514
|
// (possibly non-active) view's meshes off the regen loop, so it can never
|
package/src/framework/mount.js
CHANGED
|
@@ -286,7 +286,10 @@ 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 —
|
|
290
293
|
// pass a small one on phones; 0.158.0: a preview GLB may carry its view's autoplay
|
|
291
294
|
// animation — mount({ preview }) draws its opening pose and does not play it,
|
|
292
295
|
// mountPreview plays it unless `animate: false`, and exportPreview bakes it only
|
|
@@ -517,6 +520,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
517
520
|
annotateSend = "viewbar",
|
|
518
521
|
selectionPopover,
|
|
519
522
|
cacheBudgetMB,
|
|
523
|
+
progressiveBuild = false,
|
|
520
524
|
container: legacyContainer, controls: legacyControls } = {}) {
|
|
521
525
|
// --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
|
|
522
526
|
const byId = (id) => document.getElementById(id);
|
|
@@ -1016,6 +1020,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1016
1020
|
|
|
1017
1021
|
let framedView = null; // the view the camera was last framed to (null until first show)
|
|
1018
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
|
|
1019
1025
|
let cutawayRestored = false; // a carried cutaway, applied once — after any preview, never on one
|
|
1020
1026
|
let selectionRestored = false; // a carried selection, tried once, after the camera
|
|
1021
1027
|
function restoreCarriedSelection() {
|
|
@@ -1075,7 +1081,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1075
1081
|
for (const backend of ["manifold", "occt"]) {
|
|
1076
1082
|
const subparts = missing.filter((n) => (backends[n] ?? "manifold") === backend);
|
|
1077
1083
|
if (subparts.length === 0) continue;
|
|
1078
|
-
service.send({ type: "generate", subparts, view: view(), params, cache: cachingOn, cacheBudget: cacheBudgetBytes }, backend);
|
|
1084
|
+
service.send({ type: "generate", subparts, view: view(), params, cache: cachingOn, cacheBudget: cacheBudgetBytes, ...(progressiveBuild ? { progressive: true } : {}) }, backend);
|
|
1079
1085
|
jobs++;
|
|
1080
1086
|
}
|
|
1081
1087
|
return jobs;
|
|
@@ -1137,7 +1143,13 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1137
1143
|
}
|
|
1138
1144
|
|
|
1139
1145
|
function showView(needed) {
|
|
1140
|
-
|
|
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
|
+
}
|
|
1141
1153
|
viewer.showAssembly(needed, { frame });
|
|
1142
1154
|
if (frame) {
|
|
1143
1155
|
framedView = view();
|
|
@@ -1289,6 +1301,63 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1289
1301
|
}
|
|
1290
1302
|
}
|
|
1291
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
|
+
|
|
1292
1361
|
ui.showBusy("booting kernel"); // visible from first paint until the kernel is ready
|
|
1293
1362
|
|
|
1294
1363
|
// Bundle filename for a multi-part export (single parts download under their own name).
|
|
@@ -1311,36 +1380,18 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1311
1380
|
ui.showBusy(data.phase);
|
|
1312
1381
|
ui.setStatus(`${data.phase}…`);
|
|
1313
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;
|
|
1314
1391
|
case "meshes": {
|
|
1315
1392
|
const fresh = loop.buildDone();
|
|
1316
1393
|
if (fresh) { // stale results (params changed mid-build) are discarded
|
|
1317
|
-
|
|
1318
|
-
for (const m of data.meshes) {
|
|
1319
|
-
viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
|
|
1320
|
-
// The view its job was SENT for, and the frame the worker delivered
|
|
1321
|
-
// in: "canonical" (place() not applied — mount poses it below) or
|
|
1322
|
-
// "posed". An older worker sends no frame; its meshes are posed.
|
|
1323
|
-
cache.record(m.name, m.reads, dispatched.get(m.name)?.view ?? view(), m.frame ?? "posed");
|
|
1324
|
-
// A sub-part that built into NOTHING. Manifold booleans return an
|
|
1325
|
-
// empty solid rather than throwing (a bore wider than its body),
|
|
1326
|
-
// and typed values may sit outside the authored range, so a
|
|
1327
|
-
// "successful" build can deliver zero triangles — recorded here,
|
|
1328
|
-
// reported below once the view is whole. Undefined counts (a
|
|
1329
|
-
// host's synthetic reply) are not empty.
|
|
1330
|
-
if (m.triangles === 0) emptySubParts.add(m.name); else emptySubParts.delete(m.name);
|
|
1331
|
-
// Stamp the fast path's pose baseline. Only here, inside the
|
|
1332
|
-
// non-stale branch: the stamp must describe the geometry actually
|
|
1333
|
-
// delivered, which buildDone() true guarantees is at the live params.
|
|
1334
|
-
fastPath.recordDelivered(m.name);
|
|
1335
|
-
}
|
|
1336
|
-
// setSubGeometry reset every delivered mesh's matrix, so each one gets
|
|
1337
|
-
// its display pose now, after its geometry landed: a canonical mesh
|
|
1338
|
-
// place() at the live params, a posed one a cleared matrix. `fresh`:
|
|
1339
|
-
// an untrusted live place() here clears the pose but keeps the stamp
|
|
1340
|
-
// (host and worker probes agree by construction at these params;
|
|
1341
|
-
// forgetting on a disagreement would rebuild at the same params forever).
|
|
1342
|
-
fastPath.apply(data.meshes.map((m) => m.name), undefined, { fresh: true });
|
|
1343
|
-
recordFrames(data.meshes);
|
|
1394
|
+
applyFreshMeshes(data.meshes);
|
|
1344
1395
|
// The whole view is live: every sub-part the preview drew has just been
|
|
1345
1396
|
// replaced in place, so the tools it held back come back. Before
|
|
1346
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
|
@@ -226,6 +226,15 @@ export interface MountOptions {
|
|
|
226
226
|
* gigabytes, more than a mobile browser tab is allowed.
|
|
227
227
|
*/
|
|
228
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;
|
|
229
238
|
/** Receive exported bytes instead of partforge's own DOM download. */
|
|
230
239
|
onDownload?: (file: DownloadPayload) => void;
|
|
231
240
|
/** The active view (tab) name — emitted once on mount, then on every change. */
|