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.
- package/docs/AUTHORING-PARTS.md +19 -0
- package/package.json +1 -1
- package/src/app-demo.js +3 -1
- package/src/framework/mount.js +6 -1
- package/src/framework/viewer.js +68 -12
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
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
|
-
|
|
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
|
});
|
package/src/framework/mount.js
CHANGED
|
@@ -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.
|
|
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 });
|
package/src/framework/viewer.js
CHANGED
|
@@ -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
|
-
|
|
349
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
420
|
+
canvas.width = width; canvas.height = height;
|
|
382
421
|
const ctx = canvas.getContext("2d");
|
|
383
|
-
const img = ctx.createImageData(
|
|
422
|
+
const img = ctx.createImageData(width, height);
|
|
384
423
|
// flip rows (GL origin is bottom-left)
|
|
385
|
-
for (let y = 0; y <
|
|
386
|
-
const src = (
|
|
387
|
-
img.data.set(buf.subarray(src, src +
|
|
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",
|
|
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,
|