partforge 0.37.0 → 0.38.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.
@@ -731,6 +731,25 @@ returns instead:
731
731
  Pass `onDownload({ data, filename, mime })` to `mount()` to receive the exported bytes
732
732
  yourself (e.g. to download from a different origin) instead of partforge's own DOM download.
733
733
 
734
+ **Showcase capture (the mount handle).** The handle can also render the user's *current*
735
+ framing offscreen at a resolution independent of the window size and devicePixelRatio —
736
+ for gallery/preview images, where grabbing the live canvas would be capped at the viewer
737
+ pane's pixel size:
738
+
739
+ - `runtime.captureCurrent({ size = 2048, hideGrid = true, quality = 0.9 } = {}) → string | null` —
740
+ one offscreen render from the live camera's pose (position, up, and orbit target — not a
741
+ canonical pose) with the live viewport's aspect ratio, `size` px on the long edge
742
+ (clamped into `[256, maxTextureSize]`). Renders with 4× MSAA and the same
743
+ camera-relative capture lighting as `captureViews`, so the result is print-quality even
744
+ from a small window on a 1× display. Returns a `data:image/jpeg;base64,…` string, or
745
+ `null` when the runtime is disposed or nothing is built/visible yet — it never throws.
746
+ `hideGrid: false` keeps the floor grid so the capture matches the on-screen look
747
+ exactly. The live view is untouched: the camera never moves, and lights/grid/render
748
+ target are restored after the render.
749
+ - `runtime.captureViews(viewNames) → [{ view, dataUrl }]` — the canonical-angle
750
+ counterpart (fixed poses, framed to the visible assembly, 1024², grid hidden). Sized
751
+ for feeding a vision model, not for display; use `captureCurrent` for showcase images.
752
+
734
753
  **The markup convention (`demo.html` is the canonical copy-me page):** `<body>` carries
735
754
  `class="pf-shell"`, the flex row that lays the viewer column next to the rail. `#app`
736
755
  (`class="pf-stage"`) *is* that viewer column, and now contains the floating chrome
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.37.0",
3
+ "version": "0.38.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",
package/src/app-demo.js CHANGED
@@ -9,7 +9,9 @@ import { mount } from "./framework/index.js";
9
9
  // Dev-only example app for the demo part (a parametric spacer). Identical wiring to
10
10
  // app.js — the only thing that differs per part is which definition you import and
11
11
  // which worker entry you point at. `npm run dev`, then open /demo.html.
12
- mount(demoPart, {
12
+ // Dev-only: the handle is stashed on window so scripts/check-app.mjs can drive
13
+ // the embedding contract (runtime.captureCurrent) the way an embedder would.
14
+ window.__pfRuntime = mount(demoPart, {
13
15
  createWorker: (name) =>
14
16
  new Worker(new URL("./demo-worker.js", import.meta.url), { type: "module", name }),
15
17
  });
@@ -29,6 +29,7 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
29
29
  return {
30
30
  ready, dispose, setParams,
31
31
  captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
32
+ captureCurrent: (opts) => viewer.captureCurrent(opts),
32
33
  listExportableParts,
33
34
  exportParts,
34
35
  };
@@ -63,10 +64,14 @@ function createCleanupStack() {
63
64
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
64
65
  // so Vite can bundle the worker (see geometry-service.js).
65
66
  //
66
- // Embedding contract (0.36.0):
67
+ // Embedding contract (0.38.0):
67
68
  // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
68
69
  // await runtime.ready; // first successful build of the default view
69
70
  // runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
71
+ // runtime.captureCurrent({ size: 2048 }); // one offscreen render of the user's current
72
+ // // framing (live camera pose + viewport aspect) at the
73
+ // // given long-edge resolution → JPEG data URL, or null
74
+ // // when disposed / nothing built yet
70
75
  // runtime.listExportableParts(); // [{ name, label }] — every exportable sub-part,
71
76
  // // independent of the active view (for an embedder-drawn export UI)
72
77
  // await runtime.exportParts({ parts: ["base"], format: "stl", onProgress });
@@ -56,6 +56,36 @@ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, b
56
56
  }
57
57
  }
58
58
 
59
+ // Render the LIVE camera's current framing offscreen, once, at a caller-chosen
60
+ // resolution — the showcase capture behind the runtime handle's captureCurrent.
61
+ // Same injected-renderer split as captureViewsFromScene so it runs without a GL
62
+ // context: pose comes from the live camera (never a canonical pose), the output
63
+ // long edge is `size` clamped into [256, maxTextureSize], and the short edge
64
+ // follows the live camera's aspect so the capture matches what the user framed.
65
+ export function captureCurrentFromScene(
66
+ { size = 2048, hideGrid = true, quality = 0.9 } = {},
67
+ { renderer, liveCamera, target, grid, maxTextureSize },
68
+ ) {
69
+ const MIN_SIZE = 256;
70
+ // WebGL2 guarantees MAX_TEXTURE_SIZE >= 2048; only trust a larger reported cap.
71
+ const long = Math.min(Math.max(Math.round(size) || MIN_SIZE, MIN_SIZE), maxTextureSize ?? 2048);
72
+ const aspect = liveCamera.aspect || 1;
73
+ const width = aspect >= 1 ? long : Math.max(1, Math.round(long * aspect));
74
+ const height = aspect >= 1 ? Math.max(1, Math.round(long / aspect)) : long;
75
+ const before = liveCamera.position.clone();
76
+ const gridWasVisible = grid?.visible;
77
+ if (grid && hideGrid) grid.visible = false;
78
+ try {
79
+ return renderer.renderOffscreen(
80
+ { position: liveCamera.position.toArray(), up: liveCamera.up.toArray(), target },
81
+ { width, height, fov: liveCamera.fov, quality },
82
+ );
83
+ } finally {
84
+ if (grid && hideGrid) grid.visible = gridWasVisible;
85
+ liveCamera.position.copy(before); // belt-and-suspenders: never leak camera state
86
+ }
87
+ }
88
+
59
89
  export function createViewer(container, part) {
60
90
  const names = Object.keys(part.parts);
61
91
 
@@ -345,14 +375,22 @@ export function createViewer(container, part) {
345
375
  const _rtSize = 1024;
346
376
  let _rt = null;
347
377
  let _capLights = null;
348
- function renderOffscreen({ position, up, target }) {
349
- _rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, { samples: 4 });
378
+ // Defaults reproduce the canonical-view capture exactly (1024² cached target,
379
+ // fov 45, quality 0.9). A custom size (captureCurrent) gets a fresh render
380
+ // target, disposed after the read — those captures are rare, so per-call
381
+ // allocation beats caching one target per size ever requested.
382
+ function renderOffscreen({ position, up, target },
383
+ { width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {}) {
384
+ const cachedSize = width === _rtSize && height === _rtSize;
385
+ const rt = cachedSize
386
+ ? (_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, { samples: 4 }))
387
+ : new THREE.WebGLRenderTarget(width, height, { samples: 4 });
350
388
  _capLights = _capLights ?? createCaptureLights();
351
- const cam = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
389
+ const cam = new THREE.PerspectiveCamera(fov, width / height, 0.1, 1000);
352
390
  cam.position.set(position[0], position[1], position[2]);
353
391
  cam.up.set(up[0], up[1], up[2]);
354
392
  cam.lookAt(target[0], target[1], target[2]);
355
- const buf = new Uint8Array(_rtSize * _rtSize * 4);
393
+ const buf = new Uint8Array(width * height * 4);
356
394
  // Swap the world-fixed key/fill for the camera-relative pair, for this one render
357
395
  // only. A DirectionalLight aims at its `target`, whose matrixWorld only updates
358
396
  // while it is in the scene graph, so both go in and both come back out.
@@ -365,30 +403,31 @@ export function createViewer(container, part) {
365
403
  liveLights.fill.visible = false;
366
404
  scene.add(capKey, capKey.target, capFill, capFill.target);
367
405
  try {
368
- renderer.setRenderTarget(_rt);
406
+ renderer.setRenderTarget(rt);
369
407
  renderer.render(scene, cam);
370
408
  // render() resolves the multisample renderbuffer into the target texture, so this
371
409
  // reads antialiased pixels.
372
- renderer.readRenderTargetPixels(_rt, 0, 0, _rtSize, _rtSize, buf);
410
+ renderer.readRenderTargetPixels(rt, 0, 0, width, height, buf);
373
411
  } finally {
374
412
  // Never leave the user's own view unlit or pointed at the offscreen target.
375
413
  renderer.setRenderTarget(null);
376
414
  scene.remove(capKey, capKey.target, capFill, capFill.target);
377
415
  liveLights.key.visible = true;
378
416
  liveLights.fill.visible = true;
417
+ if (!cachedSize) rt.dispose();
379
418
  }
380
419
  const canvas = document.createElement("canvas");
381
- canvas.width = _rtSize; canvas.height = _rtSize;
420
+ canvas.width = width; canvas.height = height;
382
421
  const ctx = canvas.getContext("2d");
383
- const img = ctx.createImageData(_rtSize, _rtSize);
422
+ const img = ctx.createImageData(width, height);
384
423
  // flip rows (GL origin is bottom-left)
385
- for (let y = 0; y < _rtSize; y++) {
386
- const src = (_rtSize - 1 - y) * _rtSize * 4;
387
- img.data.set(buf.subarray(src, src + _rtSize * 4), y * _rtSize * 4);
424
+ for (let y = 0; y < height; y++) {
425
+ const src = (height - 1 - y) * width * 4;
426
+ img.data.set(buf.subarray(src, src + width * 4), y * width * 4);
388
427
  }
389
428
  srgbEncodeInPlace(img.data);
390
429
  ctx.putImageData(img, 0, 0);
391
- return canvas.toDataURL("image/jpeg", 0.9);
430
+ return canvas.toDataURL("image/jpeg", quality);
392
431
  }
393
432
 
394
433
  // Render the canonical camera angles offscreen, framed to whatever is visible,
@@ -408,6 +447,22 @@ export function createViewer(container, part) {
408
447
  });
409
448
  }
410
449
 
450
+ // One offscreen render of the user's CURRENT framing (live camera pose +
451
+ // orbit target, live aspect) at a caller-chosen resolution — the showcase
452
+ // capture. Returns a JPEG data URL, or null when disposed / nothing visible.
453
+ function captureCurrent(opts) {
454
+ if (disposed) return null;
455
+ const box = getVisibleWorldBounds();
456
+ if (!box || box.isEmpty()) return null;
457
+ return captureCurrentFromScene(opts, {
458
+ renderer: { renderOffscreen },
459
+ liveCamera: camera,
460
+ target: controls.target.toArray(),
461
+ grid,
462
+ maxTextureSize: renderer.capabilities?.maxTextureSize,
463
+ });
464
+ }
465
+
411
466
  // --- render loop ----------------------------------------------------------
412
467
  renderer.setAnimationLoop(() => {
413
468
  controls.update();
@@ -484,6 +539,7 @@ export function createViewer(container, part) {
484
539
  subTriangles,
485
540
  frame,
486
541
  captureCanonicalViews,
542
+ captureCurrent,
487
543
  setAutoRotate,
488
544
  setTheme,
489
545
  getCameraState,