partforge 0.127.0 → 0.128.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.127.0",
3
+ "version": "0.128.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",
@@ -495,6 +495,9 @@ export function attachAnimationControls(viewer, part, {
495
495
  }
496
496
  function apply(r) {
497
497
  if (!r || !current) return;
498
+ // A playing frame always draws, whatever it changed: the viewer renders on
499
+ // demand, and playback must never stall waiting for something to ask.
500
+ viewer.requestRender?.();
498
501
  try {
499
502
  // Params go through applyValues (the slider path) only when the animation
500
503
  // actually drives some: an opacity-only frame must neither snapshot nor
@@ -40,6 +40,9 @@ export function createCutaway({
40
40
  edgeColor,
41
41
  schedule = defaultSchedule,
42
42
  now,
43
+ // The viewer draws on demand; the idle fade lands on a timer, with nothing
44
+ // else to ask for the frame that shows it.
45
+ requestRender = () => {},
43
46
  }) {
44
47
  // Reassignable: a projection swap (perspective <-> ortho) hands the cutaway
45
48
  // a new camera after construction, and the gizmo must follow it too.
@@ -191,7 +194,7 @@ export function createCutaway({
191
194
  gizmo.setActiveAppearance(true);
192
195
  const scheduled = schedule(() => {
193
196
  cancelIdle = null;
194
- if (enabled && !disposed) gizmo.setActiveAppearance(false);
197
+ if (enabled && !disposed) { gizmo.setActiveAppearance(false); requestRender(); }
195
198
  }, IDLE_DELAY_MS);
196
199
  cancelIdle = typeof scheduled === "function"
197
200
  ? scheduled
@@ -99,6 +99,10 @@ export function defaultPaintLabel({ text, palette }) {
99
99
  }
100
100
 
101
101
  export function createDimScene(viewer, { paintLabel = defaultPaintLabel } = {}) {
102
+ // Dims are scene objects edited in place, not through a viewer call, so they
103
+ // ask for the frame themselves (the viewer draws on demand). tick() needs no
104
+ // request: it only follows the camera, and a camera move is already a frame.
105
+ const redraw = () => viewer.requestRender?.();
102
106
  const group = new THREE.Group();
103
107
  group.name = "pf-dims";
104
108
  let attached = false;
@@ -238,6 +242,7 @@ export function createDimScene(viewer, { paintLabel = defaultPaintLabel } = {})
238
242
 
239
243
  function update(drawings) {
240
244
  if (!ensureAttached()) return;
245
+ redraw();
241
246
  disposeChildren();
242
247
  for (const d of drawings) {
243
248
  const key = matFor(d.tier);
@@ -423,6 +428,7 @@ export function createDimScene(viewer, { paintLabel = defaultPaintLabel } = {})
423
428
  function setTheme(mode) {
424
429
  if (!DIM_THEME[mode] || mode === theme) return;
425
430
  theme = mode;
431
+ redraw();
426
432
  lineMats.static.color.set(DIM_THEME[theme].static);
427
433
  lineMats.strong.color.set(DIM_THEME[theme].strong);
428
434
  fillMats.static.color.set(DIM_THEME[theme].static);
@@ -434,9 +440,10 @@ export function createDimScene(viewer, { paintLabel = defaultPaintLabel } = {})
434
440
  }
435
441
  }
436
442
 
437
- function clear() { disposeChildren(); }
443
+ function clear() { disposeChildren(); redraw(); }
438
444
 
439
445
  function dispose() {
446
+ redraw();
440
447
  disposeChildren();
441
448
  unregisterCapture();
442
449
  if (attached) group.parent?.remove(group);
@@ -61,8 +61,13 @@ export function createFeatureHighlight(viewer) {
61
61
  overlay.visible = true;
62
62
  }
63
63
 
64
+ // The overlay is edited in place, not through a viewer call, so it asks for
65
+ // the frame itself (the viewer draws on demand).
66
+ const redraw = () => viewer.requestRender?.();
67
+
64
68
  return {
65
69
  show(hit) {
70
+ redraw();
66
71
  if (!hit.feature) { mount(hit.mesh.geometry, hit.mesh); return; }
67
72
  const cached = subsets.get(hit.subPart);
68
73
  let byId = cached?.geo === hit.mesh.geometry ? cached.byId : null;
@@ -75,7 +80,7 @@ export function createFeatureHighlight(viewer) {
75
80
  if (!g) { g = featureSubset(hit.mesh.geometry, hit.feature.id); byId.set(hit.feature.id, g); }
76
81
  mount(g, hit.mesh);
77
82
  },
78
- clear() { overlay.visible = false; },
83
+ clear() { overlay.visible = false; redraw(); },
79
84
  dispose() {
80
85
  // Every step isolated: a throw disposing one cached subset (or
81
86
  // unregistering from cutaway) must not skip the rest — same discipline
@@ -232,6 +232,31 @@ export function createViewer(container, part) {
232
232
  const controls = new OrbitControls(activeCamera, renderer.domElement);
233
233
  controls.enableDamping = true;
234
234
 
235
+ // --- render on demand -------------------------------------------------------
236
+ // The loop ticks every animation frame (controls, tweens, frame listeners —
237
+ // playback, measure, the view cube all run there), but only DRAWS when
238
+ // something asked for it: a still view costs no GPU work. A frame is asked
239
+ // for by: any call through the returned viewer API (wrapped at the bottom of
240
+ // this function — a regen, a pose, a mode switch, a capture), the controls
241
+ // moving the camera (their damping included), a camera tween, input on the
242
+ // stage, and the internal async landings (a texture, a contact shadow, a
243
+ // resize, a marker timing out). Modules that edit the scene graph directly
244
+ // rather than through the API (measure's dims, the hover highlight, the
245
+ // animation driver) call viewer.requestRender() themselves.
246
+ //
247
+ // `graceMs` keeps drawing for a while after the request: input and API calls
248
+ // have follow-ups that land a frame or two later (hover.js picks in its own
249
+ // requestAnimationFrame; a regen's measure dims rebuild on the next tick).
250
+ const INPUT_GRACE_MS = 400;
251
+ const API_GRACE_MS = 150;
252
+ let renderRequested = true; // the first frame
253
+ let renderUntil = 0;
254
+ function requestRender(graceMs = 0) {
255
+ renderRequested = true;
256
+ if (graceMs > 0) renderUntil = Math.max(renderUntil, performance.now() + graceMs);
257
+ }
258
+ controls.addEventListener("change", () => requestRender());
259
+
235
260
  // --- lights + grid --------------------------------------------------------
236
261
  const liveLights = addViewerLights(scene);
237
262
  // 1 cm grid (mm units): 300 mm wide, 30 divisions -> 10 mm (1 cm) squares.
@@ -523,6 +548,7 @@ export function createViewer(container, part) {
523
548
  domElement: renderer.domElement,
524
549
  getBounds: getVisibleWorldBounds,
525
550
  edgeColor: THEME.dark.line,
551
+ requestRender: () => requestRender(),
526
552
  });
527
553
  for (const name of names) {
528
554
  cutaway.setSubpart(name, subMesh[name], subLines[name]);
@@ -589,7 +615,7 @@ export function createViewer(container, part) {
589
615
  const settled = new Promise((resolve) => { settle = resolve; });
590
616
  pendingTextures.add(settled);
591
617
  settled.then(() => pendingTextures.delete(settled));
592
- t = textureLoader.load(assetUrl(file), () => settle(), undefined, () => settle());
618
+ t = textureLoader.load(assetUrl(file), () => { settle(); requestRender(); }, undefined, () => settle());
593
619
  t.anisotropy = Math.min(8, renderer.capabilities?.getMaxAnisotropy?.() ?? 1);
594
620
  textureCache.set(file, t);
595
621
  }
@@ -760,6 +786,7 @@ export function createViewer(container, part) {
760
786
  try {
761
787
  const plane = cutaway.getPlane?.(_shadowClipPlane) ?? null;
762
788
  realisticRig.shadow.render(scene, castersNow(), { ...opts, clippingPlanes: plane ? [plane] : null });
789
+ requestRender();
763
790
  } catch (e) {
764
791
  console.warn("partforge: contact shadow failed", e);
765
792
  }
@@ -796,6 +823,7 @@ export function createViewer(container, part) {
796
823
  // contact shadow: low-res while the plane is dragged, full once it settles —
797
824
  // the same schedule as a moving sub-part.
798
825
  cutaway.onChange?.(() => { if (realisticRig) shadowMovedAt = performance.now(); });
826
+ cutaway.onChange?.(() => requestRender());
799
827
 
800
828
  const livePixelRatio = (mode) =>
801
829
  Math.min(devicePixelRatio, mode === "realistic" && isCoarsePointer() ? 1.5 : 2);
@@ -1725,6 +1753,7 @@ export function createViewer(container, part) {
1725
1753
  lineMaterial.resolution.set(w, h); // fat lines need the viewport size for px width
1726
1754
  for (const m of fadeLineMats.values()) m.resolution.set(w, h); // clones need it too
1727
1755
  cutaway.setViewportSize(w, h, renderer.getPixelRatio());
1756
+ requestRender(); // a resize clears the drawing buffer
1728
1757
  }
1729
1758
  const ro = new ResizeObserver(resize);
1730
1759
  ro.observe(container);
@@ -2254,11 +2283,12 @@ export function createViewer(container, part) {
2254
2283
  function renderFrame(time) {
2255
2284
  const dt = lastFrameTime == null ? 0 : Math.min(0.1, (time - lastFrameTime) / 1000);
2256
2285
  lastFrameTime = time;
2257
- controls.update();
2286
+ controls.update(); // fires "change" (→ requestRender) while the camera moves, damping included
2258
2287
  const tw = camTween.update(dt);
2259
2288
  if (tw) {
2260
2289
  activeCamera.position.fromArray(tw.position);
2261
2290
  controls.target.fromArray(tw.target);
2291
+ requestRender();
2262
2292
  }
2263
2293
  checkAutoProjection();
2264
2294
  // Per-listener guard, because three re-arms requestAnimationFrame only AFTER
@@ -2269,6 +2299,9 @@ export function createViewer(container, part) {
2269
2299
  try { cb(dt); } catch (e) { console.warn("partforge: frame listener failed", e); }
2270
2300
  }
2271
2301
  updateMovingShadow(); // after the listeners, so it sees this frame's poses
2302
+ // Nothing asked for a frame: the canvas keeps showing the last one.
2303
+ if (!renderRequested && performance.now() >= renderUntil) return;
2304
+ renderRequested = false;
2272
2305
  // After the frame listeners, before anything reads the camera to draw with:
2273
2306
  // a playback frame may have moved sub-parts or the camera itself, and both
2274
2307
  // change where the planes belong.
@@ -2336,6 +2369,20 @@ export function createViewer(container, part) {
2336
2369
  // recoverable (three's own listener re-uploads on restore); the subscribers
2337
2370
  // let an embedder surface it instead of showing a dead rectangle.
2338
2371
  const contextLostListeners = new Set();
2372
+ // Input on the stage: hover highlights, measure dims, cutaway gizmo hover and
2373
+ // drag all react to it, some a frame or two later — draw through the grace.
2374
+ const INPUT_EVENTS = ["pointermove", "pointerdown", "pointerup", "pointerleave", "pointercancel", "wheel"];
2375
+ const onStageInput = () => requestRender(INPUT_GRACE_MS);
2376
+ for (const type of INPUT_EVENTS) container.addEventListener(type, onStageInput, { capture: true, passive: true });
2377
+ const onKeyInput = () => requestRender(INPUT_GRACE_MS);
2378
+ window.addEventListener("keydown", onKeyInput, { capture: true, passive: true });
2379
+ window.addEventListener("keyup", onKeyInput, { capture: true, passive: true });
2380
+ // A restored context has re-uploaded everything but drawn nothing, and a
2381
+ // returning tab may have had its drawing buffer discarded.
2382
+ const onRedrawNeeded = () => requestRender();
2383
+ renderer.domElement.addEventListener("webglcontextrestored", onRedrawNeeded);
2384
+ document.addEventListener("visibilitychange", onRedrawNeeded);
2385
+
2339
2386
  const onContextLostEvent = (event) => {
2340
2387
  event.preventDefault();
2341
2388
  for (const listener of [...contextLostListeners]) listener();
@@ -2453,6 +2500,7 @@ export function createViewer(container, part) {
2453
2500
  }
2454
2501
 
2455
2502
  function dropFlashDot(dot) {
2503
+ requestRender();
2456
2504
  scene.remove(dot);
2457
2505
  dot.material.dispose(); // the geometry is shared and freed in dispose()
2458
2506
  flashDots.delete(dot);
@@ -2532,6 +2580,11 @@ export function createViewer(container, part) {
2532
2580
  // context left to lose, and a surviving listener would keep the embedder's
2533
2581
  // closure (and whatever it captured) alive.
2534
2582
  renderer.domElement.removeEventListener("webglcontextlost", onContextLostEvent);
2583
+ for (const type of INPUT_EVENTS) container.removeEventListener(type, onStageInput, { capture: true });
2584
+ window.removeEventListener("keydown", onKeyInput, { capture: true });
2585
+ window.removeEventListener("keyup", onKeyInput, { capture: true });
2586
+ renderer.domElement.removeEventListener("webglcontextrestored", onRedrawNeeded);
2587
+ document.removeEventListener("visibilitychange", onRedrawNeeded);
2535
2588
  contextLostListeners.clear();
2536
2589
  controls.removeEventListener("start", onControlsStart);
2537
2590
  cameraStartListeners.clear();
@@ -2586,7 +2639,8 @@ export function createViewer(container, part) {
2586
2639
  renderer.domElement.remove();
2587
2640
  }
2588
2641
 
2589
- return {
2642
+ return withRenderRequests({
2643
+ requestRender,
2590
2644
  showAssembly,
2591
2645
  hideAssembly,
2592
2646
  setSubGeometry,
@@ -2666,5 +2720,25 @@ export function createViewer(container, part) {
2666
2720
  invalidatePrintFrames,
2667
2721
  whenRealisticReady,
2668
2722
  dispose,
2669
- };
2723
+ }, () => requestRender(API_GRACE_MS));
2724
+ }
2725
+
2726
+ // Every API method that can change what is drawn asks for a frame when called,
2727
+ // and again when a returned promise settles (a mode switch lands async). Pure
2728
+ // reads, subscriptions and hot per-frame queries are left unwrapped — a frame
2729
+ // listener polling getCutawayPlane() must not keep the loop drawing forever.
2730
+ // Getters (viewer.camera) and non-functions pass through untouched.
2731
+ const NO_RENDER_REQUEST = /^(get|is|has|on|__)|^(requestRender|projectPoint|subTriangles|cutawaySupported|cutawayEnabled|whenRealisticReady|dispose)$/;
2732
+ export function withRenderRequests(api, request) {
2733
+ for (const [key, desc] of Object.entries(Object.getOwnPropertyDescriptors(api))) {
2734
+ if (typeof desc.value !== "function" || NO_RENDER_REQUEST.test(key)) continue;
2735
+ const fn = desc.value;
2736
+ api[key] = function (...args) {
2737
+ request();
2738
+ const out = fn.apply(this, args);
2739
+ if (out && typeof out.then === "function") out.then(request, request);
2740
+ return out;
2741
+ };
2742
+ }
2743
+ return api;
2670
2744
  }