partforge 0.39.0 → 0.41.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.
@@ -31,6 +31,15 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
31
31
  ready, dispose, setParams,
32
32
  captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
33
33
  captureCurrent: (opts) => viewer.captureCurrent(opts),
34
+ // Park/unpark the viewer: stops the render loop and frees the drawing
35
+ // buffer and the cached capture target. For an embedder that hides the
36
+ // canvas without unmounting it — `visibility: hidden`, an off-screen tab —
37
+ // where nothing collapses the container and the loop would otherwise run
38
+ // forever. See the setActive comment in viewer.js for what it costs.
39
+ setActive: (active) => viewer.setActive(active),
40
+ // Subscribe to WebGL context loss (returns an unsubscribe), so a host can
41
+ // say "the 3D view ran out of memory" instead of showing a dead canvas.
42
+ onContextLost: (listener) => viewer.onContextLost(listener),
34
43
  listExportableParts,
35
44
  exportParts,
36
45
  // Narrow-layout pane selection, for a host that draws its own tab bar
@@ -69,7 +78,7 @@ function createCleanupStack() {
69
78
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
70
79
  // so Vite can bundle the worker (see geometry-service.js).
71
80
  //
72
- // Embedding contract (0.39.0):
81
+ // Embedding contract (0.41.0):
73
82
  // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
74
83
  // await runtime.ready; // first successful build of the default view
75
84
  // runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
@@ -86,6 +95,17 @@ function createCleanupStack() {
86
95
  // runtime.setHostPane("rail"); // narrow layout only: show just the controls
87
96
  // // rail ('stage' | 'rail'), suppressing the
88
97
  // // built-in tab bar. null hands selection back.
98
+ // runtime.setActive(false); // park the viewer: stop the render loop and release
99
+ // // both large GPU allocations (the drawing buffer and
100
+ // // the cached capture target). For a host that hides the
101
+ // // canvas WITHOUT unmounting it (`visibility: hidden`, an
102
+ // // inactive tab) — nothing else can detect that, and the
103
+ // // loop would otherwise render a hidden pane forever.
104
+ // // Captures still work while parked (they re-allocate).
105
+ // // setActive(true) restores it. Safe after dispose().
106
+ // const off = runtime.onContextLost(() => …); // WebGL context loss, i.e. the GPU or the
107
+ // // OS gave up — surface it rather than showing a dead
108
+ // // canvas. Returns an unsubscribe.
89
109
  // runtime.dispose(); // full teardown
90
110
  // onBuild fires per completed build, so it does NOT fire for a pose-only edit —
91
111
  // those are repaired in the viewer and produce no build at all.
@@ -2,7 +2,9 @@
2
2
  // is a hard gate or a warning, and the diagnostics attached to a non-pass check:
3
3
  // `hint` (required — the report contract promises one on every fail/warn),
4
4
  // `pattern` (optional stable ERROR-PATTERNS.md#<id>), `locate` (optional
5
- // [x,y,z] source). `manifoldOnly` facts are null on OCCT parts.
5
+ // [x,y,z] source), `note` (optional caveat about HOW the value was measured,
6
+ // attached whatever the status — a passing-but-sampled reading is exactly the
7
+ // case a reader needs told about). `manifoldOnly` facts are null on OCCT parts.
6
8
  //
7
9
  // This lives in framework/ rather than testing/ deliberately: the set of legal
8
10
  // `verify.expect` metrics is part of the PartDefinition CONTRACT, which both the
@@ -32,7 +34,18 @@ export const SUBPART_METRICS = {
32
34
  minWall: { kind: "warn", extract: (s) => s.minWall,
33
35
  hint: "thinnest wall is at the reported location — increase the governing wall/thickness parameter or reduce the intersecting feature's depth",
34
36
  pattern: "minwall-sliver-triangles",
35
- locate: (s) => s.minWallAt },
37
+ locate: (s) => s.minWallAt,
38
+ // Two sampled outcomes, and the second is the one that most needs saying: a
39
+ // sampled run that found NO wall reports minWall null, which without this note
40
+ // is indistinguishable from a part nobody measured. `sampled` counts triangles
41
+ // the walk selected, not rays cast — degenerate triangles are skipped.
42
+ note: (s) => {
43
+ if (!s.minWallSampled || !s.minWallSamples) return null;
44
+ const { sampled, total } = s.minWallSamples;
45
+ return s.minWall == null
46
+ ? `no reading from the ${sampled} of ${total} triangles sampled — not a clean bill of health; a thin spot may exist between samples`
47
+ : `sampled ${sampled} of ${total} triangles — an upper bound; a thinner spot may exist between samples`;
48
+ } },
36
49
  };
37
50
  export const VIEW_METRICS = {
38
51
  bbox: { kind: "gate", extract: (r) => r.aggregate.bbox,
@@ -94,6 +94,10 @@ export function createViewer(container, part) {
94
94
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
95
95
  container.appendChild(renderer.domElement);
96
96
 
97
+ // Declared up here, not beside setActive() below, because the initial resize()
98
+ // runs during construction and reads it.
99
+ let active = true;
100
+
97
101
  const scene = new THREE.Scene();
98
102
 
99
103
  // Light/dark scene palettes (the page chrome is themed separately, via CSS on the
@@ -175,14 +179,24 @@ export function createViewer(container, part) {
175
179
  }
176
180
 
177
181
  // The cutaway plane lives in world space, so its initial/reset bounds must
178
- // include the pivot rotation and the per-view recentering transform.
182
+ // include the pivot rotation and the per-view recentering transform
183
+ // mesh.matrixWorld carries both. Union each visible mesh's own
184
+ // geometry.boundingBox rather than `Box3.expandByObject`, which recurses into
185
+ // children: the two stencil-pass meshes share `mesh.geometry` so that
186
+ // recursion is harmless for them, but the cut-face outline child carries its
187
+ // own independent geometry that only re-slices while the cutaway is enabled
188
+ // and visible — while hidden it can keep segments from an older, larger part
189
+ // and inflate these bounds. A subpart's initial placeholder BufferGeometry
190
+ // has no boundingBox computed (only buildGeometry computes one), so skip it.
179
191
  const _worldBounds = new THREE.Box3();
192
+ const _meshBounds = new THREE.Box3();
180
193
  function getVisibleWorldBounds() {
181
194
  _worldBounds.makeEmpty();
182
195
  for (const mesh of Object.values(subMesh)) {
183
- if (!mesh.visible || !mesh.geometry) continue;
196
+ if (!mesh.visible || !mesh.geometry?.boundingBox) continue;
184
197
  mesh.updateWorldMatrix(true, false);
185
- _worldBounds.expandByObject(mesh);
198
+ _meshBounds.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld);
199
+ _worldBounds.union(_meshBounds);
186
200
  }
187
201
  return _worldBounds;
188
202
  }
@@ -351,6 +365,10 @@ export function createViewer(container, part) {
351
365
  // --- resize ---------------------------------------------------------------
352
366
  // Size from the host container (not the window) so embedders control the pane.
353
367
  function resize() {
368
+ // Parked (see setActive): the buffer is deliberately 1x1 and must stay that
369
+ // way. iOS fires resizes constantly as the URL bar collapses, and every one
370
+ // of them would otherwise re-allocate a full MSAA buffer for a hidden pane.
371
+ if (!active) return;
354
372
  const w = container.clientWidth || 300, h = container.clientHeight || 150;
355
373
  renderer.setSize(w, h);
356
374
  camera.aspect = w / h;
@@ -471,12 +489,66 @@ export function createViewer(container, part) {
471
489
  }
472
490
 
473
491
  // --- render loop ----------------------------------------------------------
474
- renderer.setAnimationLoop(() => {
492
+ function renderFrame() {
475
493
  controls.update();
476
494
  if (cutaway.isEnabled) cutaway.updateForCamera();
477
495
  renderer.render(scene, camera);
478
496
  cutaway.renderOverlay(renderer, camera);
479
- });
497
+ }
498
+ renderer.setAnimationLoop(renderFrame);
499
+
500
+ // --- active / parked ------------------------------------------------------
501
+ // For a host that HIDES the viewer without unmounting it. partforge's own
502
+ // narrow layout uses `display: none` on the stage, which zeroes clientWidth
503
+ // and lets the ResizeObserver above collapse the buffer for free. An embedder
504
+ // that cannot do that — partforge-cloud's phone tab bar uses
505
+ // `visibility: hidden`, because the canvas has to keep its size for build
506
+ // screenshots — gets no such signal: the full-resolution MSAA drawing buffer
507
+ // stays resident and this loop keeps rendering an auto-rotating scene at
508
+ // 60fps behind an invisible pane. On an iPhone that is tens of megabytes and
509
+ // continuous GPU work nobody can see, so the host has to say so explicitly.
510
+ //
511
+ // Parking stops the loop and releases the drawing buffer. `setSize(1, 1,
512
+ // false)` leaves the canvas element's CSS box alone, so the host's layout
513
+ // does not move and the pane can be revealed again without a reflow.
514
+ function setActive(next) {
515
+ const want = next !== false;
516
+ if (disposed || want === active) return;
517
+ active = want;
518
+ if (!active) {
519
+ renderer.setAnimationLoop(null);
520
+ renderer.setSize(1, 1, false);
521
+ // The cached 1024² 4x-MSAA + stencil capture target is the other large
522
+ // allocation here — on a phone it is comparable to the canvas itself, so
523
+ // parking that kept it would leave half the memory behind. Dropping it
524
+ // costs one re-allocation on the next capture, which a parked viewer
525
+ // barely notices: the cache only ever hits on an exactly-square request,
526
+ // and a phone's capture aspect is not square, so those captures were
527
+ // allocating per call regardless.
528
+ _rt?.dispose();
529
+ _rt = null;
530
+ return;
531
+ }
532
+ resize(); // rebuild the buffer at whatever size the container is now
533
+ renderer.setAnimationLoop(renderFrame);
534
+ }
535
+
536
+ // --- context loss ---------------------------------------------------------
537
+ // Losing the WebGL context is how a memory-starved phone tells you it gave
538
+ // up. With no handler the canvas just freezes, indistinguishable from a hang,
539
+ // and three never re-initialises. preventDefault() is what makes the loss
540
+ // recoverable (three's own listener re-uploads on restore); the subscribers
541
+ // let an embedder surface it instead of showing a dead rectangle.
542
+ const contextLostListeners = new Set();
543
+ const onContextLostEvent = (event) => {
544
+ event.preventDefault();
545
+ for (const listener of [...contextLostListeners]) listener();
546
+ };
547
+ renderer.domElement.addEventListener("webglcontextlost", onContextLostEvent);
548
+ function onContextLost(listener) {
549
+ contextLostListeners.add(listener);
550
+ return () => contextLostListeners.delete(listener);
551
+ }
480
552
 
481
553
  // --- camera state (read/write for persistence; mount.js owns storage) -------
482
554
  function getCameraState() {
@@ -518,6 +590,11 @@ export function createViewer(container, part) {
518
590
  disposed = true;
519
591
  ro.disconnect();
520
592
  renderer.setAnimationLoop(null);
593
+ // Embedder callbacks must not outlive teardown — a disposed viewer has no
594
+ // context left to lose, and a surviving listener would keep the embedder's
595
+ // closure (and whatever it captured) alive.
596
+ renderer.domElement.removeEventListener("webglcontextlost", onContextLostEvent);
597
+ contextLostListeners.clear();
521
598
  controls.dispose();
522
599
  for (const t of flashTimers) clearTimeout(t);
523
600
  flashTimers.clear();
@@ -548,6 +625,8 @@ export function createViewer(container, part) {
548
625
  captureCanonicalViews,
549
626
  captureCurrent,
550
627
  setAutoRotate,
628
+ setActive,
629
+ onContextLost,
551
630
  setTheme,
552
631
  getCameraState,
553
632
  setCameraState,