partforge 0.130.1 → 0.130.2

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.
@@ -867,6 +867,12 @@ The new mount claims that reply and drops it, rather than letting its own handle
867
867
  the reply's `error` as a failed build or its `download` as a viewbar save. The previous
868
868
  mount already rejected that export when it was disposed.
869
869
 
870
+ `captureView` replies cross a rebind the same way, and `mount()` handles them the same
871
+ way. Its `capture-generate` jobIds (`cap-<b>-<n>`) are unique to each mount, so a previous
872
+ mount's `capture-meshes` can never settle the new mount's capture with the old geometry,
873
+ and the new mount claims and drops that reply, including an `error`. The previous mount
874
+ already resolved that capture to `null` when it was disposed.
875
+
870
876
  **Cancellation granularity is the sub-part.** The guard is checked only between
871
877
  sub-parts (one macrotask yield each), so a single long WASM op — a big boolean, an OCCT
872
878
  fillet — runs to completion no matter how stale it is. That is by design: WASM kernel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.130.1",
3
+ "version": "0.130.2",
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",
@@ -2,7 +2,23 @@
2
2
  // does NOT go through the regen loop. Same shape as export-controller's pending
3
3
  // Map (export-controller.js): allocate a jobId, resolve when the matching
4
4
  // reply arrives. Pure — no DOM, no worker; `send` is injected.
5
+
6
+ // Job ids are unique per capture build, not just per mount: `cap-<b>-<n>`,
7
+ // where <b> numbers the capture builds this module has made. Each mount makes
8
+ // one, but a host may keep a worker across mounts (partforge-cloud's pool
9
+ // rebinds it with setPart), and that worker's replies then reach whichever
10
+ // mount is listening now. A capture-generate runs to completion, so a disposed
11
+ // mount's capture still answers after the remount. When every mount numbered
12
+ // its captures from 1, that late reply settled the next mount's same-numbered
13
+ // capture with pre-edit geometry. The same scheme as export-controller.js's
14
+ // `export-<c>-<n>`.
15
+ let builds = 0;
16
+ // Every id any capture build mints, so a reply to another build's job is
17
+ // recognised even though this build never sent it.
18
+ const CAPTURE_JOB_ID = /^cap-\d+-\d+$/;
19
+
5
20
  export function createCaptureBuild({ send }) {
21
+ const build = ++builds;
6
22
  let nextId = 1;
7
23
  let disposed = false;
8
24
  const pending = new Map(); // jobId -> resolve
@@ -12,12 +28,12 @@ export function createCaptureBuild({ send }) {
12
28
  // worker (a silent no-op) and its promise would hang forever. Resolve null instead
13
29
  // — captureView's documented "disposed runtime resolves null" contract.
14
30
  if (disposed) return Promise.resolve(null);
15
- // String-namespaced ("cap-N") so a capture jobId stays outside
31
+ // String-namespaced ("cap-") so a capture jobId stays outside
16
32
  // export-controller's "export-"/"warm-" ids — both share the same worker
17
33
  // message space, and exportCtl.handleMessage claims every id in its own
18
34
  // namespace before checking type, so a colliding id could otherwise settle
19
35
  // the wrong promise.
20
- const jobId = `cap-${nextId++}`;
36
+ const jobId = `cap-${build}-${nextId++}`;
21
37
  return new Promise((resolve) => {
22
38
  pending.set(jobId, resolve);
23
39
  // cache:true so the worker reuses its per-sub-part geometry memo (the
@@ -26,13 +42,14 @@ export function createCaptureBuild({ send }) {
26
42
  });
27
43
  }
28
44
 
29
- // Returns true iff this message was a reply this controller owns (so the
30
- // caller — mount.js's onWorkerMessage — can skip it entirely). Keyed on
31
- // membership in `pending` first: the namespaced jobId guarantees another
32
- // channel's message never matches, so a hit here is always ours. A failed
33
- // build (the worker's shared catch posts a generic error/needs-occt/
34
- // needs-import-mesh, jobId intact) resolves to null rather than leaving the
35
- // caller hanging forever — captureView treats null as "capture failed, skip".
45
+ // Returns true iff this message is a reply to a capture job (so the caller —
46
+ // mount.js's onWorkerMessage — can skip it entirely): a pending job is
47
+ // settled, any other capture id is dropped. Keyed on membership in `pending`
48
+ // first: the namespaced jobId guarantees another channel's message never
49
+ // matches, so a hit here is always ours. A failed build (the worker's shared
50
+ // catch posts a generic error/needs-occt/needs-import-mesh, jobId intact)
51
+ // resolves to null rather than leaving the caller hanging forever —
52
+ // captureView treats null as "capture failed, skip".
36
53
  // needs-import-mesh MUST be claimed here rather than falling through to
37
54
  // mount's live-loop crossover case: this capture job never went through the
38
55
  // regen loop, so treating its reply as a live crossover would call
@@ -43,7 +60,12 @@ export function createCaptureBuild({ send }) {
43
60
  // gets primed by the next live build instead.
44
61
  function handleMessage(data) {
45
62
  const jobId = data?.jobId;
46
- if (jobId == null || !pending.has(jobId)) return false;
63
+ // A capture id with nothing pending here: a job this build already
64
+ // settled, or one a disposed build sent to a worker the host kept. Either
65
+ // way its promise is already settled, so claim the reply and drop it.
66
+ // Passing it on would let mount's switch read an "error" as a failed build
67
+ // and a needs-import-mesh as a live crossover.
68
+ if (jobId == null || !pending.has(jobId)) return typeof jobId === "string" && CAPTURE_JOB_ID.test(jobId);
47
69
  if (data.type === "capture-meshes") {
48
70
  pending.get(jobId)(data.meshes);
49
71
  } else if (data.type === "error" || data.type === "needs-occt" || data.type === "needs-import-mesh") {
@@ -526,7 +526,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
526
526
  // Lets the "error" case below tell a correlated tessellation failure apart
527
527
  // from an unrelated build error sharing the same message type, so it can
528
528
  // reset the latch instead of stranding it at "requested" forever.
529
- // String-namespaced ("tess-N"), mirroring capture-build.js's "cap-N" —
529
+ // String-namespaced ("tess-N"), mirroring capture-build.js's "cap-" ids —
530
530
  // this request shares the OCCT worker's message space with
531
531
  // export-controller's "export-"/"warm-" jobIds, and exportCtl.handleMessage
532
532
  // (called before mount's own switch, below) claims every id in its own
@@ -1015,6 +1015,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1015
1015
  if (exportCtl.handleMessage(data, onDownload)) return;
1016
1016
  // captureView's off-loop build channel: consume its replies before the
1017
1017
  // live `meshes` case — capture-meshes must never touch live cache/display.
1018
+ // Like exportCtl, it also claims a disposed mount's late capture reply on
1019
+ // a worker the host kept, so a stale "error" never reads as a failed build.
1018
1020
  if (captureBuild.handleMessage(data)) return;
1019
1021
  switch (data.type) {
1020
1022
  case "ready":