partforge 0.129.0 → 0.130.1

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.
@@ -353,7 +353,10 @@ meta: { title: "…", units: "mm", environment: "studio" },
353
353
  colour, flattened so dark materials stay readable. **Realistic** mode — the
354
354
  viewer's toggle — shows the full material under environment lighting, with a
355
355
  ground and soft shadow and no feature lines. The part never moves when the
356
- mode changes.
356
+ mode changes. The ground is placed under the part when a view is shown and
357
+ after each edit; while an animation plays or is scrubbed it stays where it is
358
+ (dropping lower only if the part would otherwise sink into it), so a motion
359
+ that changes the part's footprint never slides the floor around under it.
357
360
 
358
361
  **3D-print layer lines** (`pla-print`, `petg-print`) run perpendicular to the
359
362
  **export** pose's +Z — the way the part will be printed, not the way it is
@@ -857,6 +857,16 @@ Manifold backend, thrown from `imports.js`'s registration policy, not `errors.js
857
857
  `KernelCapabilityError`/`NEEDS_OCCT`) — is the same message shape and the same
858
858
  non-epoch-gated risk as `needs-occt`; a host handling one should handle both the same way.
859
859
 
860
+ **Export replies cross a rebind by design.** Exports run to completion, so a host that
861
+ keeps one worker across mounts, and routes that worker's messages to whichever mount is
862
+ listening now, hands the next mount every reply to the previous mount's exports.
863
+ `mount()` handles this itself. Its export jobIds (`export-<c>-<n>`, and `warm-<c>-<n>`
864
+ for `warmExportKernel`) are unique to each mount, not numbered from 1 per mount. So a
865
+ previous mount's reply can never settle the new mount's export with the old geometry.
866
+ The new mount claims that reply and drops it, rather than letting its own handler read
867
+ the reply's `error` as a failed build or its `download` as a viewbar save. The previous
868
+ mount already rejected that export when it was disposed.
869
+
860
870
  **Cancellation granularity is the sub-part.** The guard is checked only between
861
871
  sub-parts (one macrotask yield each), so a single long WASM op — a big boolean, an OCCT
862
872
  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.129.0",
3
+ "version": "0.130.1",
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",
@@ -504,6 +504,12 @@ export function attachAnimationControls(viewer, part, {
504
504
  // write params, or it would dirty the regen loop for a display-only fade.
505
505
  // First write for this run also remembers what the user's params were, so
506
506
  // Reset can put them back.
507
+ // Anything the animation moves holds the realistic floor where it is (see
508
+ // viewer.js placeGround): a track that rebuilds geometry re-shows the
509
+ // assembly every delivery, and the floor must not chase each frame's
510
+ // bounds. Released by a user edit, reset/stop, or a view switch — NOT by
511
+ // reaching the end, which would just move the jump to the last frame.
512
+ if (Object.keys(r.values).length || Object.keys(r.opacity ?? {}).length) viewer.holdGround?.(true);
507
513
  if (Object.keys(r.values).length) {
508
514
  if (snapshot == null) snapshot = getParamValues(current.trackedKeys);
509
515
  applyValues(r.values);
@@ -539,6 +545,9 @@ export function attachAnimationControls(viewer, part, {
539
545
  }
540
546
 
541
547
  function doReset() {
548
+ // Before the snapshot is re-applied: the rebuild that restores the user's
549
+ // params is an ordinary placement, so the floor comes back to the part.
550
+ viewer.holdGround?.(false);
542
551
  playback?.reset();
543
552
  viewer.cancelCameraTween();
544
553
  viewer.clearSubPartOpacities?.();
@@ -814,6 +823,7 @@ export function attachAnimationControls(viewer, part, {
814
823
  // pause playback rather than fight over them.
815
824
  notifyUserEdit() {
816
825
  disarmAutoplay();
826
+ viewer.holdGround?.(false); // a user edit places the floor again, as without animation
817
827
  viewer.cancelCameraTween();
818
828
  playback?.userEdited();
819
829
  syncUi();
@@ -854,6 +864,7 @@ export function attachAnimationControls(viewer, part, {
854
864
  if (status !== "playing" && status !== "intro") guarded(() => playback.play());
855
865
  },
856
866
  detach() {
867
+ viewer.holdGround?.(false);
857
868
  offFrame();
858
869
  offOrbit();
859
870
  viewer.clearSubPartOpacities?.();
@@ -12,10 +12,11 @@ export function createCaptureBuild({ send }) {
12
12
  // worker (a silent no-op) and its promise would hang forever. Resolve null instead
13
13
  // — captureView's documented "disposed runtime resolves null" contract.
14
14
  if (disposed) return Promise.resolve(null);
15
- // String-namespaced ("cap-N") so a capture jobId can never collide with
16
- // export-controller's numeric jobIds — both share the same worker message
17
- // space, and exportCtl.handleMessage does a raw pending.get(m.jobId) before
18
- // checking type, so a colliding id could otherwise settle the wrong promise.
15
+ // String-namespaced ("cap-N") so a capture jobId stays outside
16
+ // export-controller's "export-"/"warm-" ids — both share the same worker
17
+ // message space, and exportCtl.handleMessage claims every id in its own
18
+ // namespace before checking type, so a colliding id could otherwise settle
19
+ // the wrong promise.
19
20
  const jobId = `cap-${nextId++}`;
20
21
  return new Promise((resolve) => {
21
22
  pending.set(jobId, resolve);
@@ -15,17 +15,32 @@ export function backendForFormat(format, defaultBackend) {
15
15
  return format === "step" ? "occt" : defaultBackend();
16
16
  }
17
17
 
18
+ // Job ids are unique per controller, not just per mount: `export-<c>-<n>` and
19
+ // `warm-<c>-<n>`, where <c> numbers the controllers this module has made. Each
20
+ // mount makes one controller, but a host may keep a worker across mounts
21
+ // (partforge-cloud's pool rebinds it with setPart), and that worker's replies
22
+ // then reach whichever mount is listening now. Exports are never epoch-guarded
23
+ // (KERNEL-CONTRACT), so a disposed mount's slow export still answers after the
24
+ // remount. When every mount numbered its jobs from 1, that late reply settled
25
+ // the next mount's same-numbered export with pre-edit geometry. A worker can
26
+ // only be shared within one realm, and this counter covers the whole realm.
27
+ let controllers = 0;
28
+ // Every id any controller mints, so a reply to another controller's job is
29
+ // recognised even though this controller never sent it.
30
+ const CONTROLLER_JOB_ID = /^(?:export|warm)-\d+-\d+$/;
31
+
18
32
  export function createExportController({ send, currentView, title, defaultBackend = () => "manifold", currentParams = () => ({}) }) {
19
33
  const pending = new Map(); // jobId -> { resolve, reject, onProgress }
34
+ const controller = ++controllers;
20
35
  let nextId = 1;
21
- // Warm jobs are STRING-namespaced ("warm-N") for the reason mount.js spells
22
- // out for tessellate-imports: this map is keyed by jobId alone and read before
23
- // any type check, so a bare numeric id here could be claimed by a pending
24
- // export (both counters start at 1) and settle the wrong Promise.
36
+ // Warm jobs get their own prefix for the reason mount.js spells out for
37
+ // tessellate-imports: this map is keyed by jobId alone and read before any
38
+ // type check, so a warm and an export sharing an id would settle each
39
+ // other's Promise.
25
40
  let nextWarmId = 1;
26
41
 
27
42
  function exportParts({ parts, format, quality = "print", onProgress } = {}) {
28
- const jobId = nextId++;
43
+ const jobId = `export-${controller}-${nextId++}`;
29
44
  const type = `export-${format}`;
30
45
  const backend = backendForFormat(format, defaultBackend);
31
46
  return new Promise((resolve, reject) => {
@@ -45,18 +60,25 @@ export function createExportController({ send, currentView, title, defaultBacken
45
60
  // failure or teardown, and NEVER rejects — a speculative warm must not become
46
61
  // an unhandled rejection in a host that fired it and moved on.
47
62
  function warmKernel() {
48
- const jobId = `warm-${nextWarmId++}`;
63
+ const jobId = `warm-${controller}-${nextWarmId++}`;
49
64
  return new Promise((resolve) => {
50
65
  pending.set(jobId, { resolve: () => resolve(true), reject: () => resolve(false) });
51
66
  send({ type: "warm-kernel", jobId }, backendForFormat("step", defaultBackend));
52
67
  });
53
68
  }
54
69
 
55
- // Returns true iff this message belonged to a pending export (so the caller
56
- // can skip legacy handling). `sink` is partforge's onDownload.
70
+ // Returns true iff this message is a reply to an export-controller job (so
71
+ // the caller can skip legacy handling): a pending job is settled, any other
72
+ // is dropped. `sink` is partforge's onDownload.
57
73
  function handleMessage(m, sink) {
58
- const entry = m && m.jobId != null ? pending.get(m.jobId) : undefined;
59
- if (!entry) return false;
74
+ const jobId = m?.jobId;
75
+ const entry = jobId != null ? pending.get(jobId) : undefined;
76
+ // A controller's id with nothing pending here: a job this controller
77
+ // already settled, or one a disposed controller sent to a worker the host
78
+ // kept. Either way its Promise is already settled, so claim the reply and
79
+ // drop it. Passing it on would let mount's switch read an "error" as a
80
+ // failed build and a "download" as a viewbar save.
81
+ if (!entry) return typeof jobId === "string" && CONTROLLER_JOB_ID.test(jobId);
60
82
  if (m.type === "progress") { entry.onProgress?.(m.phase); return true; }
61
83
  if (m.type === "kernel-warm") { pending.delete(m.jobId); entry.resolve(); return true; }
62
84
  if (m.type === "download") {
@@ -528,14 +528,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
528
528
  // reset the latch instead of stranding it at "requested" forever.
529
529
  // String-namespaced ("tess-N"), mirroring capture-build.js's "cap-N" —
530
530
  // this request shares the OCCT worker's message space with
531
- // export-controller's PLAIN NUMERIC jobIds (both start counting at 1), and
532
- // exportCtl.handleMessage (called before mount's own switch, below) does a
533
- // raw pending.get(m.jobId) before checking type. A bare numeric id here
534
- // could collide with a pending STEP export's jobId, so the export
535
- // controller would wrongly claim this reply — rejecting an unrelated
536
- // export with the tessellation failure text AND leaving importMeshState
537
- // stranded at "requested" (mount's own switch, which would have reset it,
538
- // never runs). The string namespace makes that collision impossible.
531
+ // export-controller's "export-"/"warm-" jobIds, and exportCtl.handleMessage
532
+ // (called before mount's own switch, below) claims every id in its own
533
+ // namespace before checking type. An id inside that namespace would let the
534
+ // export controller wrongly claim this reply — rejecting an unrelated
535
+ // export with the tessellation failure text, or dropping the reply, AND
536
+ // leaving importMeshState stranded at "requested" (mount's own switch,
537
+ // which would have reset it, never runs). The "tess-" prefix makes that
538
+ // collision impossible.
539
539
  let importTessellateJobId = null;
540
540
  let importTessellateJobSeq = 0;
541
541
 
@@ -794,15 +794,28 @@ export function createViewer(container, part) {
794
794
  // The ground comes to the part: it sits at the bottom of what is visible and
795
795
  // is sized from it. Only on showAssembly (and on entering the mode) — an
796
796
  // animated pose change moves the shadow, never the floor.
797
+ //
798
+ // An animation that REBUILDS geometry re-shows the assembly on every
799
+ // delivery, though, and re-deriving the floor from each frame's bounds slides
800
+ // it under the part as the bounds shift (feedback #138: a lid swinging open
801
+ // dragged the floor back with it). So while the animation driver holds the
802
+ // ground, a placement reuses the last one and may only LOWER it — never slide
803
+ // it sideways or resize it, and never lift it into the part. The hold is
804
+ // viewer-wide rather than per rig, so an environment switch mid-playback
805
+ // lands its floor in the same place.
806
+ let groundHeld = false;
807
+ let lastGround = null;
808
+ function holdGround(on) { groundHeld = !!on; }
797
809
  function placeGround({ force = false } = {}) {
798
810
  if (!realisticRig) return;
799
811
  const b = getVisibleWorldBounds(); // shared Box3: read it out before anything else runs
800
812
  if (b.isEmpty()) return;
801
813
  const center = b.getCenter(new THREE.Vector3());
802
814
  const size = b.getSize(new THREE.Vector3());
803
- realisticRig.setGround({
804
- y: b.min.y, centerX: center.x, centerZ: center.z, radius: size.length() / 2, footprintMm: Math.max(size.x, size.z),
805
- });
815
+ lastGround = groundHeld && lastGround
816
+ ? { ...lastGround, y: Math.min(lastGround.y, b.min.y) }
817
+ : { y: b.min.y, centerX: center.x, centerZ: center.z, radius: size.length() / 2, footprintMm: Math.max(size.x, size.z) };
818
+ realisticRig.setGround(lastGround);
806
819
  shadowMovedAt = null; // this render is the full-resolution one
807
820
  renderShadow(undefined, { force });
808
821
  }
@@ -2658,6 +2671,7 @@ export function createViewer(container, part) {
2658
2671
  onFrame,
2659
2672
  onAssemblyChange: (cb) => { assemblyListeners.add(cb); return () => assemblyListeners.delete(cb); },
2660
2673
  tweenCameraTo,
2674
+ holdGround,
2661
2675
  cancelCameraTween,
2662
2676
  orbitBy,
2663
2677
  onCameraStart,