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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.166.0",
3
+ "version": "0.167.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",
@@ -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 } : {}) }, transfer);
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
@@ -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.166.0: `cacheBudgetMB` bounds the worker's solid cache —
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
- const frame = view() !== framedView;
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
- liveDelivered = true; // only a delivery that is applied beats a preview still decoding
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
  };
@@ -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
- const gated = (m, transfer = []) =>
142
- (m.type === "meshes" && isStale() ? post({ type: "superseded" }) : post(m, transfer));
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. */