partforge 0.167.0 → 0.168.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/README.md CHANGED
@@ -187,11 +187,37 @@ stock, no layer-line frames). Previews carry no edge lines, textures or patterns
187
187
  `resolveDefaultView(part)` (main entry and `partforge/testing`) names the view
188
188
  `mount()` opens on, so a preview built headlessly matches it.
189
189
 
190
+ `mountPreview` takes presentation options (0.168.0+), each usable alone and
191
+ each defaulting to the viewer above: `chrome: false` (no view cube, viewbar
192
+ controls, view style, tooltips or play button), `background: "transparent"`
193
+ (alpha-0 canvas, no grid) and `contactShadow: true` (a soft shadow under the
194
+ part) — both CAD mode only, since realistic mode draws its own environment and
195
+ ground — `zoom: false`
196
+ and `pan: false` (a wheel then scrolls the page), `turntable: <degrees per
197
+ second>` (a model with no playing animation turns until the first camera grab,
198
+ never under `prefers-reduced-motion`), `fill: 0.8` (frame the model so its
199
+ bounding box, as seen from the opening view, spans that fraction of the preview
200
+ along its tighter axis — larger in a small card; with a turntable the model swept
201
+ a full turn is framed, so it never clips as it turns and a round part still fills; the model is
202
+ centred on its geometry, so a lopsided part turns about its hub), `camera: "default"` (drop the carried camera
203
+ and projection and keep the file's own framing; a carried render mode and
204
+ environment still apply) and `renderMode: "cad" | "realistic"` (the opening
205
+ mode, over any carried one; it can change afterwards). An unknown option key
206
+ throws. A baked animation always plays as authored — it
207
+ loops, or plays once and holds its end pose. Every handle carries `drawn`, which
208
+ resolves after the first frame showing the meshes, so a host can cross-fade from
209
+ a still image.
210
+
190
211
  ```js
191
212
  const glb = await runtime.exportPreview(); // Uint8Array: the view on screen
192
213
  mount(part, { createWorker, elements, preview: glb }); // drawn at once, replaced by the build
193
214
  const shown = mountPreview(stage, { glb }); // no kernel at all
194
215
  await shown.ready;
216
+ const card = mountPreview(cardStage, { // a landing-page card
217
+ glb, chrome: false, background: "transparent", contactShadow: true,
218
+ zoom: false, pan: false, turntable: 14, fill: 0.8, camera: "default", renderMode: "cad",
219
+ });
220
+ await card.drawn; // the first frame is on the canvas
195
221
  ```
196
222
 
197
223
  Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.167.0",
3
+ "version": "0.168.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",
@@ -30,6 +30,51 @@
30
30
  // file, with `animate: false`, or for a host with no #viewbar; the host can
31
31
  // still drive `handle.animation` (state / pause / resume / replay) itself.
32
32
  //
33
+ // Presentation options (0.168.0), each usable alone; every default is the
34
+ // viewer above, so a mount that names none is unchanged:
35
+ // - `chrome: false` — no view cube, no #viewbar controls, no view style, no
36
+ // tooltips, no play button and no `elements.chrome` wiring. A one-time
37
+ // animation still holds its end pose; there is just no replay button.
38
+ // - `background: "transparent"` — the canvas clears to alpha 0 with no theme
39
+ // background and no grid, so the host page shows through. CAD mode ONLY:
40
+ // realistic mode draws its own environment backdrop (pair it with
41
+ // `renderMode: "cad"` to open transparent).
42
+ // - `contactShadow: true` — the product shot's soft contact shadow under the
43
+ // part. CAD mode ONLY: realistic mode draws its own ground and shadow. Cast
44
+ // once, at showAssembly, from the meshes as the file stores them (the rest
45
+ // pose, before a player applies its first pose); a playing animation does
46
+ // not move it.
47
+ // - `zoom: false`, `pan: false` — those orbit gestures are off (a wheel then
48
+ // scrolls the page); orbiting stays on.
49
+ // - `turntable: <degrees per second>` (default 0, off) — a file with no
50
+ // playing animation (a still file, or `animate: false`) turns about the
51
+ // vertical off the viewer's frame loop. Never under reduced motion; the
52
+ // first camera grab — a canvas drag or the view cube — stops it for good.
53
+ // Dragging an animated model orbits the camera and the animation plays on.
54
+ // - `fill: <0 < f <= 1>` — the opening framing (and the viewer's reframe)
55
+ // places the camera, along the kept view direction, so the model's posed
56
+ // bounding box as projected on screen spans that fraction of the viewport
57
+ // along its limiting axis. A turning model is framed swept a full turn
58
+ // about the vertical (its farthest vertex from the axis), so the turntable
59
+ // never clips and a round part still fills. The model is centred on its
60
+ // horizontal vertex centroid (a lopsided part turns about its hub); a baked
61
+ // animation that moves parts outside the rest box can. Unset, the viewer's
62
+ // fixed framing distance, as before.
63
+ // - `camera: "default"` — the camera and projection of `viewerState` and of
64
+ // every setViewerState are dropped: the mount opens, and stays, at the
65
+ // file's own default framing. Their render mode and environment still apply.
66
+ // - `renderMode: "cad" | "realistic"` — the OPENING mode: applied at mount,
67
+ // over any carried render mode. After that it is an ordinary mode —
68
+ // setViewerState, renderMode.set and the view style control change it.
69
+ // Unset, the carried state decides, as before.
70
+ // Any other key throws (an option this version does not know is a host bug).
71
+ //
72
+ // `handle.drawn` resolves after the first frame that includes the meshes has
73
+ // rendered (so a host can cross-fade from a still image without a flash). It
74
+ // rejects with `ready`'s error when the file will not decode, and rejects if
75
+ // the handle is disposed before that frame. A parked preview draws nothing, so
76
+ // `drawn` waits for setActive(true).
77
+ //
33
78
  // The decoder loads on demand (it carries the
34
79
  // GLTFLoader and meshopt), so a bundle that never mounts a preview never ships it.
35
80
  // Hence `ready`. Every handle method is safe before it resolves.
@@ -45,6 +90,66 @@ import { ENVIRONMENTS } from "./materials/environments.js";
45
90
  import { createPreviewPlayer } from "./preview-player.js";
46
91
  import { attachPreviewPlayButton } from "./preview-play-button.js";
47
92
 
93
+ // A value the option does not know is a host bug: say so at the call rather
94
+ // than quietly showing the default.
95
+ function oneOf(name, value, allowed, fallback) {
96
+ if (value === undefined) return fallback;
97
+ if (!allowed.includes(value)) throw new TypeError(`mountPreview: ${name} must be ${allowed.map((a) => JSON.stringify(a)).join(" or ")}`);
98
+ return value;
99
+ }
100
+ function flag(name, value, fallback) {
101
+ if (value === undefined) return fallback;
102
+ if (typeof value !== "boolean") throw new TypeError(`mountPreview: ${name} must be a boolean`);
103
+ return value;
104
+ }
105
+ const KNOWN_OPTIONS = new Set([
106
+ "glb", "elements", "viewerState", "animate",
107
+ "chrome", "background", "contactShadow", "zoom", "pan", "turntable", "fill", "camera", "renderMode",
108
+ ]);
109
+
110
+ function presentation({ chrome, background, contactShadow, zoom, pan, turntable, fill, camera, renderMode }) {
111
+ if (fill !== undefined && !(typeof fill === "number" && fill > 0 && fill <= 1)) {
112
+ throw new TypeError("mountPreview: fill must be a number above 0 and at most 1");
113
+ }
114
+ if (turntable !== undefined && !(typeof turntable === "number" && Number.isFinite(turntable) && turntable >= 0)) {
115
+ throw new TypeError("mountPreview: turntable must be degrees per second, 0 or more");
116
+ }
117
+ return {
118
+ chrome: flag("chrome", chrome, true),
119
+ transparent: oneOf("background", background, ["theme", "transparent"], "theme") === "transparent",
120
+ contactShadow: flag("contactShadow", contactShadow, false),
121
+ zoom: flag("zoom", zoom, true),
122
+ pan: flag("pan", pan, true),
123
+ turntable: turntable ?? 0,
124
+ fill: fill ?? null,
125
+ defaultCamera: oneOf("camera", camera, ["carry", "default"], "carry") === "default",
126
+ renderMode: oneOf("renderMode", renderMode, ["cad", "realistic"], null),
127
+ };
128
+ }
129
+
130
+ // Turn the camera about the vertical at `degPerSec` until the first camera grab.
131
+ // It turns through viewer.orbitBy, which itself announces a camera grab — so the
132
+ // turntable's own steps are told apart from the user's by a flag held across
133
+ // the call. Returns stop().
134
+ function startTurntable(viewer, container, degPerSec) {
135
+ let turning = false, stopped = false;
136
+ const offFrame = viewer.onFrame((dt) => {
137
+ // orbitBy's rate: a drag the full stage height is a full turn.
138
+ const dx = ((degPerSec * dt) / 360) * Math.max(1, container.clientHeight || 1);
139
+ if (!(dx > 0)) return;
140
+ turning = true;
141
+ try { viewer.orbitBy(dx, 0); } finally { turning = false; }
142
+ });
143
+ const offGrab = viewer.onCameraStart(() => { if (!turning) stop(); });
144
+ function stop() {
145
+ if (stopped) return;
146
+ stopped = true;
147
+ offFrame();
148
+ offGrab();
149
+ }
150
+ return stop;
151
+ }
152
+
48
153
  function previewPart({ meshes, view, meta }) {
49
154
  return {
50
155
  meta: { title: "Preview", ...(meta ?? {}) },
@@ -65,24 +170,43 @@ const relay = (listeners) => (event) => {
65
170
  }
66
171
  };
67
172
 
68
- export function mountPreview(container, { glb, elements = {}, viewerState = null, animate = true } = {}) {
173
+ export function mountPreview(container, options = {}) {
69
174
  if (!container) throw new TypeError("mountPreview: a container element is required");
175
+ for (const key of Object.keys(options ?? {})) {
176
+ if (!KNOWN_OPTIONS.has(key)) throw new TypeError(`mountPreview: unknown option "${key}"`);
177
+ }
178
+ const { glb, elements = {}, viewerState = null, animate = true } = options ?? {};
179
+ const opts = presentation(options ?? {});
180
+ // A carried state as this mount applies it: under camera: "default" without
181
+ // its camera and projection (render mode and environment still carry).
182
+ const carry = (state) => {
183
+ if (!state || !opts.defaultCamera) return state ?? null;
184
+ const { camera: _camera, projection: _projection, ...rest } = state;
185
+ return rest;
186
+ };
70
187
  let disposed = false;
71
188
  let active = true;
72
- let carried = viewerState ?? null; // applied once the viewer exists, and on every setViewerState
189
+ // Applied once the viewer exists, and on every setViewerState. The opening
190
+ // render mode, when one is named, wins over the carried one at mount only.
191
+ let carried = carry(viewerState);
192
+ if (opts.renderMode) carried = { ...(carried ?? {}), renderMode: opts.renderMode };
73
193
  let live = null; // { viewer, view } once the GLB has decoded
74
194
  let player = null; // the baked animation's player, once drawn
75
195
  const cleanups = [];
76
196
  const modeListeners = new Set();
77
197
  const envListeners = new Set();
78
198
  const lostListeners = new Set();
199
+ let resolveDrawn, rejectDrawn;
200
+ const drawn = new Promise((resolve, reject) => { resolveDrawn = resolve; rejectDrawn = reject; });
201
+ drawn.catch(() => {}); // the host awaits it; never an unhandled rejection
79
202
 
80
203
  // Same precedence mount() uses: projection first (orthographic only for a
81
204
  // face-view camera), then the camera, then environment and render mode.
82
205
  function applyState(s) {
83
206
  if (!s || !live) return;
84
207
  const { viewer } = live;
85
- viewer.setProjection(s.projection === "orthographic" && isFaceAlignedState(s.camera) ? "orthographic" : "perspective");
208
+ // Under camera: "default" the framing is the file's own: no projection either.
209
+ if (!opts.defaultCamera) viewer.setProjection(s.projection === "orthographic" && isFaceAlignedState(s.camera) ? "orthographic" : "perspective");
86
210
  if (s.camera) viewer.setCameraState(s.camera);
87
211
  if (typeof s.environment === "string") viewer.setEnvironment(s.environment);
88
212
  if ((s.renderMode === "cad" || s.renderMode === "realistic") && s.renderMode !== viewer.getRenderMode()) {
@@ -94,18 +218,29 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
94
218
  if (disposed) return;
95
219
  if (decoded.meshes.length === 0) throw new Error("mountPreview: the preview holds no meshes");
96
220
  const view = decoded.view ?? "preview";
97
- const viewer = createViewer(container, previewPart({ ...decoded, view }));
221
+ const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
222
+ const plays = !!decoded.animation && animate !== false;
223
+ const turns = !plays && opts.turntable > 0 && !reducedMotion;
224
+ const viewer = createViewer(container, previewPart({ ...decoded, view }), {
225
+ transparent: opts.transparent, contactShadow: opts.contactShadow, zoom: opts.zoom, pan: opts.pan,
226
+ // A turning model is framed swept a full turn, so no angle of it clips.
227
+ frameFill: opts.fill, frameSwept: turns,
228
+ });
98
229
  cleanups.push(() => viewer.dispose());
99
- const tooltip = createTooltipPresenter({ id: null });
100
- cleanups.push(() => tooltip.dispose());
101
- const viewcube = attachViewcubeControls(viewer, { stage: container });
102
- cleanups.push(() => viewcube.detach());
103
- const toolbar = container.querySelector("#viewbar");
104
- const viewStyle = attachViewStyleControls(viewer, { stage: container, toolbar, anchor: viewcube.element }, { tooltip });
105
- cleanups.push(() => viewStyle.detach());
106
- cleanups.push(observeViewbarClear(container, toolbar));
107
- const chrome = attachViewerControls(viewer, elements.chrome ?? {}, { tooltip });
108
- cleanups.push(() => chrome.detach());
230
+ let tooltip = null, toolbar = null, viewStyle = null;
231
+ if (opts.chrome) {
232
+ tooltip = createTooltipPresenter({ id: null });
233
+ const tip = tooltip;
234
+ cleanups.push(() => tip.dispose());
235
+ const viewcube = attachViewcubeControls(viewer, { stage: container });
236
+ cleanups.push(() => viewcube.detach());
237
+ toolbar = container.querySelector("#viewbar");
238
+ const style = viewStyle = attachViewStyleControls(viewer, { stage: container, toolbar, anchor: viewcube.element }, { tooltip });
239
+ cleanups.push(() => style.detach());
240
+ cleanups.push(observeViewbarClear(container, toolbar));
241
+ const chrome = attachViewerControls(viewer, elements.chrome ?? {}, { tooltip });
242
+ cleanups.push(() => chrome.detach());
243
+ }
109
244
  cleanups.push(viewer.onRenderModeChange(relay(modeListeners)));
110
245
  cleanups.push(viewer.onEnvironmentChange(relay(envListeners)));
111
246
  cleanups.push(viewer.onContextLost(relay(lostListeners)));
@@ -114,8 +249,9 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
114
249
  live = { viewer, view };
115
250
  applyState(carried);
116
251
  if (!active) viewer.setActive(false);
117
- if (decoded.animation && animate !== false) {
118
- const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
252
+ const offRendered = viewer.onRendered(() => { offRendered(); resolveDrawn(); });
253
+ cleanups.push(offRendered);
254
+ if (plays) {
119
255
  player = createPreviewPlayer(viewer, decoded.animation, { reducedMotion });
120
256
  const own = player;
121
257
  cleanups.push(() => own.dispose());
@@ -124,9 +260,11 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
124
260
  const playButton = attachPreviewPlayButton(own, { toolbar, before: viewStyle.element }, { tooltip });
125
261
  cleanups.push(() => playButton.detach());
126
262
  }
263
+ } else if (turns) {
264
+ cleanups.push(startTurntable(viewer, container, opts.turntable));
127
265
  }
128
266
  });
129
- ready.catch(() => {}); // the host awaits it; never an unhandled rejection
267
+ ready.catch(rejectDrawn); // the host awaits it; never an unhandled rejection
130
268
 
131
269
  function getViewerState() {
132
270
  if (!live) return { camera: null, projection: "perspective", renderMode: "cad", ...carried, cutaway: null };
@@ -148,9 +286,11 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
148
286
 
149
287
  return {
150
288
  ready,
289
+ drawn,
151
290
  dispose() {
152
291
  if (disposed) return;
153
292
  disposed = true;
293
+ rejectDrawn(new Error("mountPreview: disposed before the preview was drawn")); // a no-op once drawn
154
294
  live = null;
155
295
  player = null; // `animation` answers null once disposed (the cleanup below disposes it)
156
296
  const errors = [];
@@ -167,7 +307,7 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
167
307
  },
168
308
  getViewerState,
169
309
  setViewerState(next) {
170
- carried = next ?? null;
310
+ carried = carry(next);
171
311
  applyState(carried);
172
312
  },
173
313
  setActive(next) {
@@ -163,7 +163,22 @@ export function captureCurrentFromScene(
163
163
  }
164
164
  }
165
165
 
166
- export function createViewer(container, part) {
166
+ // Options (all default to today's viewer): `transparent` clears the canvas to
167
+ // alpha 0 in CAD mode (no theme background, no grid) so the host page shows
168
+ // through — realistic mode keeps its environment backdrop; `contactShadow`
169
+ // draws the product shot's soft contact shadow (contact-shadow.js, the mask
170
+ // captures use) under the part in CAD mode, on the live scene; `zoom` / `pan`
171
+ // false turn those OrbitControls gestures off (a wheel then scrolls the page).
172
+ // `frameFill` (0 < f <= 1) changes how frameTo frames: the framed assembly's
173
+ // posed bounding box, PROJECTED from the kept view direction, spans that
174
+ // fraction of the viewport along its limiting axis, centred on the horizontal
175
+ // vertex centroid; `frameSwept` frames the model swept a full turn about the
176
+ // vertical instead (a turntable never clips).
177
+ // Unset keeps the fixed r·2.6 + 6 distance. mountPreview's presentation
178
+ // options are the caller.
179
+ export function createViewer(container, part, {
180
+ transparent = false, contactShadow = false, zoom = true, pan = true, frameFill = null, frameSwept = false,
181
+ } = {}) {
167
182
  const names = Object.keys(part.parts);
168
183
  // Membership for host-supplied names. subMesh, cadMats and part.parts are
169
184
  // plain objects, so "toString" or "constructor" would read as present on
@@ -172,7 +187,10 @@ export function createViewer(container, part) {
172
187
  const has = (name) => nameSet.has(name);
173
188
 
174
189
  // --- renderer / scene / camera --------------------------------------------
175
- const renderer = new THREE.WebGLRenderer({ antialias: true, stencil: true });
190
+ const renderer = new THREE.WebGLRenderer(transparent
191
+ ? { antialias: true, stencil: true, alpha: true }
192
+ : { antialias: true, stencil: true });
193
+ if (transparent) renderer.setClearColor(0x000000, 0);
176
194
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
177
195
  container.appendChild(renderer.domElement);
178
196
 
@@ -188,7 +206,10 @@ export function createViewer(container, part) {
188
206
  dark: { bg: part.meta?.background ?? CAD_DARK_THEME.bg, grid: [0x2c333d, 0x222831], line: CAD_DARK_THEME.line },
189
207
  light: { bg: CAD_LIGHT_THEME.bg, grid: [0xc4ccd6, 0xd6dce4], line: CAD_LIGHT_THEME.line },
190
208
  };
191
- scene.background = new THREE.Color(THEME.dark.bg);
209
+ // CAD mode's backdrop and grid; a transparent viewer has neither.
210
+ const cadBackground = (mode) => (transparent ? null : new THREE.Color(THEME[mode].bg));
211
+ const cadGrid = !transparent;
212
+ scene.background = cadBackground("dark");
192
213
 
193
214
  let currentTheme = "dark";
194
215
  const themeListeners = new Set();
@@ -216,6 +237,8 @@ export function createViewer(container, part) {
216
237
 
217
238
  const controls = new OrbitControls(activeCamera, renderer.domElement);
218
239
  controls.enableDamping = true;
240
+ controls.enableZoom = zoom !== false;
241
+ controls.enablePan = pan !== false;
219
242
  // A finger a host gesture swallowed would otherwise stay "down" forever in
220
243
  // whatever it went down on — the controls (every later drag zooms), the view
221
244
  // cube, the cutaway handles, measure mode — so the guard watches the whole
@@ -253,6 +276,7 @@ export function createViewer(container, part) {
253
276
  const GRID_SIZE = 300, GRID_DIVS = 30;
254
277
  let floorY = 0; // world Y of the grid plane; set to the part's bbox bottom in frameTo
255
278
  let grid = new THREE.GridHelper(GRID_SIZE, GRID_DIVS, THEME.dark.grid[0], THEME.dark.grid[1]);
279
+ grid.visible = cadGrid;
256
280
  scene.add(grid);
257
281
 
258
282
  // --- material + part groups -----------------------------------------------
@@ -626,6 +650,8 @@ export function createViewer(container, part) {
626
650
  // bigger half of the answer the way the grid is in CAD: without it the far
627
651
  // plane cuts the disc's back edge off.
628
652
  if (realisticRig) _depthBounds.union(_groundBounds.setFromObject(realisticRig.ground));
653
+ // The CAD contact shadow reaches past the part by its blur margin.
654
+ if (liveShadow) _depthBounds.union(_groundBounds.setFromObject(liveShadow));
629
655
  if (_depthBounds.isEmpty()) return null;
630
656
  return {
631
657
  center: _depthBounds.getCenter(_depthCenter).toArray(),
@@ -962,6 +988,53 @@ export function createViewer(container, part) {
962
988
  // contact shadow: low-res while the plane is dragged, full once it settles —
963
989
  // the same schedule as a moving sub-part.
964
990
  cutaway.onChange?.(() => { if (realisticRig) shadowMovedAt = performance.now(); });
991
+
992
+ // --- the CAD contact shadow (the `contactShadow` option) -------------------
993
+ // The product shot's shadow (renderStyles' thumbnail style), on the live
994
+ // scene: the same CPU mask the captures cast, as a black alpha-mapped plane a
995
+ // hair under the part. CAD mode only — realistic brings its own ground and
996
+ // shadow. Computed ONCE per assembly — on showAssembly, from the pose on
997
+ // screen then (a preview's meshes as the file stores them — the rest pose,
998
+ // before its player applies a first pose) — and again on entering CAD. Never
999
+ // for animation frames: the mask is a CPU rasterization plus a new texture,
1000
+ // and rebuilding it a few times a second while a preview played stalled the
1001
+ // host page's main thread (a landing page's typing slowed ~25×). So a moving
1002
+ // sub-part leaves its rest-pose shadow behind — accepted. A turntable needs no
1003
+ // rebuild: it moves the camera, never a caster or the ground.
1004
+ const LIVE_SHADOW = RENDER_STYLES.thumbnail.shadow;
1005
+ let liveShadow = null;
1006
+ function disposeLiveShadow() {
1007
+ if (!liveShadow) return;
1008
+ scene.remove(liveShadow);
1009
+ liveShadow.geometry.dispose();
1010
+ liveShadow.material.alphaMap.dispose();
1011
+ liveShadow.material.dispose();
1012
+ liveShadow = null;
1013
+ }
1014
+ const _liveShadowV = new THREE.Vector3();
1015
+ function rebuildLiveShadow() {
1016
+ disposeLiveShadow();
1017
+ if (!contactShadow || renderMode !== "cad") return;
1018
+ try {
1019
+ const bounds = getVisibleWorldBounds();
1020
+ if (bounds.isEmpty()) return;
1021
+ const box = { min: bounds.min.toArray(), max: bounds.max.toArray() };
1022
+ const meshes = castersNow().filter((m) => m.geometry?.attributes?.position).map((mesh) => {
1023
+ mesh.updateWorldMatrix(true, false);
1024
+ const pos = mesh.geometry.attributes.position;
1025
+ const positions = new Float32Array(pos.count * 3);
1026
+ for (let i = 0; i < pos.count; i++) _liveShadowV.fromBufferAttribute(pos, i).applyMatrix4(mesh.matrixWorld).toArray(positions, i * 3);
1027
+ return { positions, indices: mesh.geometry.index?.array ?? null };
1028
+ });
1029
+ const mask = contactShadowMask(meshes, box, LIVE_SHADOW);
1030
+ if (!mask) return;
1031
+ liveShadow = buildContactShadowPlane(mask, LIVE_SHADOW);
1032
+ scene.add(liveShadow);
1033
+ requestRender();
1034
+ } catch (e) {
1035
+ console.warn("partforge: contact shadow failed", e);
1036
+ }
1037
+ }
965
1038
  cutaway.onChange?.(() => requestRender());
966
1039
 
967
1040
  const livePixelRatio = (mode) =>
@@ -996,6 +1069,7 @@ export function createViewer(container, part) {
996
1069
  scene.backgroundRotation.set(0, rig.rotationY ?? 0, 0);
997
1070
  scene.environmentRotation.set(0, rig.rotationY ?? 0, 0);
998
1071
  scene.add(rig.ground, rig.shadow.group, ...(rig.lights ?? []));
1072
+ disposeLiveShadow();
999
1073
  for (const l of Object.values(liveLights)) l.visible = false;
1000
1074
  grid.visible = false;
1001
1075
  renderer.toneMapping = THREE.NeutralToneMapping;
@@ -1030,16 +1104,17 @@ export function createViewer(container, part) {
1030
1104
  shadowMovedAt = null;
1031
1105
  attempt(() => {
1032
1106
  scene.environment = null;
1033
- scene.background = new THREE.Color(THEME[currentTheme].bg);
1107
+ scene.background = cadBackground(currentTheme);
1034
1108
  scene.backgroundBlurriness = 0;
1035
1109
  scene.backgroundIntensity = 1;
1036
1110
  scene.backgroundRotation.set(0, 0, 0);
1037
1111
  scene.environmentRotation.set(0, 0, 0);
1038
1112
  });
1039
1113
  attempt(() => { for (const l of Object.values(liveLights)) l.visible = true; });
1040
- attempt(() => { grid.visible = true; });
1114
+ attempt(() => { grid.visible = cadGrid; });
1041
1115
  for (const n of names) attempt(() => { if (baseMats[n] !== cadMats[n]) rebaseSubMaterial(n, cadMats[n]); else applySubOpacity(n); });
1042
1116
  if (live) attempt(() => applyPixelRatio("cad"));
1117
+ if (contactShadow) attempt(rebuildLiveShadow);
1043
1118
  attempt(refreshFeatureLines);
1044
1119
  if (live) invalidateMaterialThumbnails();
1045
1120
  if (firstError) throw firstError;
@@ -1242,6 +1317,10 @@ export function createViewer(container, part) {
1242
1317
  // (setActive(false)) automatically halts playback too: no loop, no ticks.
1243
1318
  const frameListeners = new Set();
1244
1319
  function onFrame(cb) { frameListeners.add(cb); return () => frameListeners.delete(cb); }
1320
+ // After a frame has actually DRAWN to the canvas (the loop renders on demand,
1321
+ // so most frames draw nothing and call nobody). mountPreview's `drawn`.
1322
+ const renderedListeners = new Set();
1323
+ function onRendered(cb) { renderedListeners.add(cb); return () => renderedListeners.delete(cb); }
1245
1324
 
1246
1325
  // Fired at the end of every showAssembly, for hosts that need to react to
1247
1326
  // which sub-parts are visible (e.g. re-deriving a view style thumbnail).
@@ -1864,6 +1943,11 @@ export function createViewer(container, part) {
1864
1943
  }
1865
1944
  if (_box.isEmpty()) return;
1866
1945
  const center = _box.getCenter(new THREE.Vector3());
1946
+ // A fill framing centres on the geometry, not its box: the horizontal
1947
+ // vertex centroid (model x/y — the pivot stands model Z up) with the box's
1948
+ // vertical centre. A lopsided part (a three-blade propeller) then turns
1949
+ // about its hub instead of orbiting the middle of its box.
1950
+ if (frameFill) horizontalCentroid(visibleNames, center);
1867
1951
  partsGroup.position.copy(center).multiplyScalar(-1); // centre assembly on the pivot
1868
1952
  const size = _box.getSize(new THREE.Vector3());
1869
1953
  // Drop the grid to the bottom of the bounding box (model Z -> world Y), so it reads
@@ -1871,6 +1955,7 @@ export function createViewer(container, part) {
1871
1955
  floorY = -size.z / 2;
1872
1956
  grid.position.y = floorY;
1873
1957
  const r = Math.max(size.x, size.y, size.z) || 12;
1958
+ const fill = fillFraming(_box, visibleNames, center);
1874
1959
  // Keep the VIEW DIRECTION (target -> camera), not the camera's direction
1875
1960
  // from the origin: after a pan the target is off the origin, and scaling
1876
1961
  // the position about the origin would turn the view — which, in an ortho
@@ -1880,11 +1965,138 @@ export function createViewer(container, part) {
1880
1965
  _frameDir.copy(activeCamera.position).sub(controls.target);
1881
1966
  if (_frameDir.lengthSq() === 0) _frameDir.copy(activeCamera.position);
1882
1967
  if (_frameDir.lengthSq() === 0) _frameDir.set(1, 1, 1);
1883
- activeCamera.position.copy(_frameDir.normalize().multiplyScalar(r * 2.6 + 6));
1968
+ _frameDir.normalize();
1969
+ const filled = fill?.(_frameDir);
1970
+ activeCamera.position.copy(_frameDir).multiplyScalar(filled ? filled.distance : r * 2.6 + 6);
1884
1971
  controls.target.set(0, 0, 0);
1885
1972
  // Framing under ortho is a frustum, not a distance — without this the
1886
- // reframe button moves the camera and nothing visibly changes.
1887
- if (projectionMode === "orthographic") syncOrthoToPerspectiveFraming();
1973
+ // reframe button moves the camera and nothing visibly changes. A fill
1974
+ // framing sizes the frustum to the projected box directly (the sync
1975
+ // matches size at the surface under the screen centre, not the extent).
1976
+ if (projectionMode === "orthographic") {
1977
+ if (filled) {
1978
+ applyOrthoFrustum({ halfH: filled.orthoHalfH, halfW: filled.orthoHalfH * filled.aspect });
1979
+ orthoCamera.zoom = 1;
1980
+ orthoCamera.updateProjectionMatrix();
1981
+ } else {
1982
+ syncOrthoToPerspectiveFraming();
1983
+ }
1984
+ }
1985
+ }
1986
+
1987
+ // The `frameFill` framing. `size` is the posed box in the model frame; the
1988
+ // assembly is centred on the pivot, so its world corners are the half-extents
1989
+ // stood up by the pivot's rotation. With `frameSwept` the points are the
1990
+ // model swept about the vertical through the box centre — two circles at the
1991
+ // box's top and bottom whose radius is the farthest posed VERTEX from that
1992
+ // axis (nudged out so the polygon contains the circle). Not the box's
1993
+ // corners: those sit ~√2 farther out than a round part's rim, which framed a
1994
+ // turning gear at little more than half the asked-for width. Returns a function of the unit view
1995
+ // direction (target → camera) giving `{ distance, orthoHalfH, aspect }`:
1996
+ // - perspective: the distance, along that direction to the origin target,
1997
+ // at which the points' projected extent — the larger of (projected width /
1998
+ // viewport width, projected height / viewport height) — equals frameFill.
1999
+ // The extent only shrinks as the camera backs off, so it is bisected.
2000
+ // - orthographic: the half-height that gives the same extent.
2001
+ // Null when unset, or when the container has no size yet (a hidden or
2002
+ // unlaid-out stage): frameTo then falls back to the fixed distance, and the
2003
+ // next reframe fills.
2004
+ const SWEEP_SEGMENTS = 48;
2005
+ const _sweepV = new THREE.Vector3();
2006
+ // The farthest horizontal distance of any posed vertex of `names` from the
2007
+ // vertical through `center` (model frame), in world units. Reads positions
2008
+ // through BufferAttribute accessors, so normalized/quantized data is
2009
+ // dequantized.
2010
+ function sweepRadius(names, center) {
2011
+ let max = 0;
2012
+ for (const name of names) {
2013
+ const pos = subCache[name]?.getAttribute("position");
2014
+ if (!pos) continue;
2015
+ const pose = subMesh[name].matrix;
2016
+ for (let i = 0; i < pos.count; i++) {
2017
+ _sweepV.fromBufferAttribute(pos, i).applyMatrix4(pose).sub(center).applyQuaternion(pivot.quaternion);
2018
+ const d = Math.hypot(_sweepV.x, _sweepV.z);
2019
+ if (d > max) max = d;
2020
+ }
2021
+ }
2022
+ return max;
2023
+ }
2024
+ // Overwrite `center`'s model x/y with the mean posed vertex position of
2025
+ // `names` (z untouched). A plain vertex mean: preview meshes are close enough
2026
+ // to uniformly tessellated for it to land on the hub of a lopsided part.
2027
+ function horizontalCentroid(names, center) {
2028
+ let sx = 0, sy = 0, n = 0;
2029
+ for (const name of names) {
2030
+ const pos = subCache[name]?.getAttribute("position");
2031
+ if (!pos) continue;
2032
+ const pose = subMesh[name].matrix;
2033
+ for (let i = 0; i < pos.count; i++) {
2034
+ _sweepV.fromBufferAttribute(pos, i).applyMatrix4(pose);
2035
+ sx += _sweepV.x; sy += _sweepV.y; n++;
2036
+ }
2037
+ }
2038
+ if (n > 0) center.set(sx / n, sy / n, center.z);
2039
+ }
2040
+ function fillFraming(box, names, center) {
2041
+ if (!frameFill) return null;
2042
+ const w = container.clientWidth, h = container.clientHeight;
2043
+ if (!(w > 0 && h > 0)) return null;
2044
+ const aspect = w / h;
2045
+ pivot.updateMatrix();
2046
+ // The box corners about the framing centre (no longer the box's own centre).
2047
+ let points = [];
2048
+ for (const x of [box.min.x, box.max.x]) for (const y of [box.min.y, box.max.y]) for (const z of [box.min.z, box.max.z]) {
2049
+ points.push(new THREE.Vector3(x, y, z).sub(center).applyQuaternion(pivot.quaternion));
2050
+ }
2051
+ if (frameSwept) {
2052
+ const radius = sweepRadius(names, center) / Math.cos(Math.PI / SWEEP_SEGMENTS);
2053
+ const ys = [Math.min(...points.map((p) => p.y)), Math.max(...points.map((p) => p.y))];
2054
+ points = [];
2055
+ for (let k = 0; k < SWEEP_SEGMENTS; k++) {
2056
+ const a = (2 * Math.PI * k) / SWEEP_SEGMENTS;
2057
+ for (const y of ys) points.push(new THREE.Vector3(radius * Math.cos(a), y, radius * Math.sin(a)));
2058
+ }
2059
+ }
2060
+ if (points.every((p) => p.lengthSq() === 0)) return null;
2061
+ const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
2062
+ const tanH = tanV * aspect;
2063
+ return (dir) => {
2064
+ // The camera's basis for this view direction, `up` as the controls keep it.
2065
+ const forward = dir.clone().negate();
2066
+ const right = new THREE.Vector3().crossVectors(forward, activeCamera.up);
2067
+ if (right.lengthSq() < 1e-12) right.set(1, 0, 0); // looking straight along up
2068
+ right.normalize();
2069
+ const upAxis = new THREE.Vector3().crossVectors(right, forward);
2070
+ const xs = points.map((p) => p.dot(right)), ys = points.map((p) => p.dot(upAxis)), along = points.map((p) => p.dot(dir));
2071
+ // `fill` is the projected SPAN: the larger of (projected width / viewport
2072
+ // width, projected height / viewport height). The framing centre sits at
2073
+ // the screen centre, though, and a lopsided part reaches farther one way
2074
+ // than the other — so the camera also backs off until nothing leaves the
2075
+ // viewport (|NDC| <= 1 on both axes). For a part about as symmetric as
2076
+ // its centre, the span decides alone.
2077
+ const span = (v) => Math.max(...v) - Math.min(...v);
2078
+ const reach = (v) => Math.max(...v.map(Math.abs));
2079
+ const orthoHalfH = Math.max(span(xs) / (2 * frameFill * aspect), span(ys) / (2 * frameFill), reach(xs) / aspect, reach(ys));
2080
+ // How far over budget the projection is at distance d (<= 1 fits).
2081
+ const over = (d) => {
2082
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, r = 0;
2083
+ for (let i = 0; i < points.length; i++) {
2084
+ const z = d - along[i]; // depth in front of the camera
2085
+ const nx = xs[i] / (z * tanH), ny = ys[i] / (z * tanV);
2086
+ x0 = Math.min(x0, nx); x1 = Math.max(x1, nx); y0 = Math.min(y0, ny); y1 = Math.max(y1, ny);
2087
+ r = Math.max(r, Math.abs(nx), Math.abs(ny));
2088
+ }
2089
+ return Math.max(Math.max(x1 - x0, y1 - y0) / (2 * frameFill), r); // NDC spans 2 per axis
2090
+ };
2091
+ const nearest = Math.max(...along);
2092
+ let lo = nearest + 1e-6 * (1 + Math.abs(nearest)), hi = Math.max(lo * 2, 1);
2093
+ while (over(hi) > 1 && hi < 1e9) hi *= 2;
2094
+ for (let i = 0; i < 60; i++) {
2095
+ const mid = (lo + hi) / 2;
2096
+ if (over(mid) > 1) lo = mid; else hi = mid;
2097
+ }
2098
+ return { distance: hi, orthoHalfH, aspect };
2099
+ };
1888
2100
  }
1889
2101
 
1890
2102
  // Show exactly the named sub-parts (from the cache). When `frame` is set, also
@@ -1906,6 +2118,7 @@ export function createViewer(container, part) {
1906
2118
  if (frame) frameTo(visibleNames);
1907
2119
  cutaway.setVisible(effectiveVisible());
1908
2120
  placeGround(); // realistic only: the ground comes to the part, and the shadow re-renders
2121
+ if (contactShadow) rebuildLiveShadow(); // CAD only (a no-op in realistic)
1909
2122
  for (const cb of [...assemblyListeners]) {
1910
2123
  try { cb(); } catch (e) { console.warn("partforge: assembly listener failed", e); }
1911
2124
  }
@@ -1949,11 +2162,11 @@ export function createViewer(container, part) {
1949
2162
  const t = THEME[mode] ?? THEME.dark;
1950
2163
  // Realistic mode's backdrop belongs to the environment and its grid is off;
1951
2164
  // enterCad puts the theme's background back on the way out.
1952
- if (renderMode === "cad") scene.background = new THREE.Color(t.bg);
2165
+ if (renderMode === "cad") scene.background = cadBackground(THEME[mode] ? mode : "dark");
1953
2166
  scene.remove(grid);
1954
2167
  grid = new THREE.GridHelper(GRID_SIZE, GRID_DIVS, t.grid[0], t.grid[1]);
1955
2168
  grid.position.y = floorY; // keep the floor at the bbox bottom across theme swaps
1956
- grid.visible = renderMode === "cad";
2169
+ grid.visible = renderMode === "cad" && cadGrid;
1957
2170
  scene.add(grid);
1958
2171
  lineMaterial.color.set(t.line);
1959
2172
  for (const m of fadeLineMats.values()) m.color.set(t.line); // clones follow the theme
@@ -2756,6 +2969,9 @@ export function createViewer(container, part) {
2756
2969
  }
2757
2970
  renderWithBackdrop(scene, activeCamera);
2758
2971
  cutaway.renderOverlay(renderer, activeCamera);
2972
+ for (const cb of [...renderedListeners]) {
2973
+ try { cb(); } catch (e) { console.warn("partforge: rendered listener failed", e); }
2974
+ }
2759
2975
  }
2760
2976
  renderer.setAnimationLoop(renderFrame);
2761
2977
 
@@ -3032,6 +3248,7 @@ export function createViewer(container, part) {
3032
3248
  controls.removeEventListener("start", onControlsStart);
3033
3249
  cameraStartListeners.clear();
3034
3250
  frameListeners.clear();
3251
+ renderedListeners.clear();
3035
3252
  assemblyListeners.clear();
3036
3253
  themeListeners.clear();
3037
3254
  projectionListeners.clear();
@@ -3078,6 +3295,7 @@ export function createViewer(container, part) {
3078
3295
  fadeMats.clear();
3079
3296
  fadeLineMats.clear();
3080
3297
  disposeRealistic();
3298
+ disposeLiveShadow();
3081
3299
  material.dispose();
3082
3300
  lineMaterial.dispose();
3083
3301
  grid.geometry.dispose();
@@ -3113,6 +3331,7 @@ export function createViewer(container, part) {
3113
3331
  renderMaterialThumbnail,
3114
3332
  invalidateMaterialThumbnails,
3115
3333
  onFrame,
3334
+ onRendered,
3116
3335
  onAssemblyChange: (cb) => { assemblyListeners.add(cb); return () => assemblyListeners.delete(cb); },
3117
3336
  tweenCameraTo,
3118
3337
  getCameraCue,
package/types/index.d.ts CHANGED
@@ -798,7 +798,7 @@ export interface PartRuntime {
798
798
  /** Mount a full parametric-part app from a `PartDefinition`. */
799
799
  export function mount(part: PartDefinition, options: MountOptions): PartRuntime;
800
800
 
801
- /** Options for `mountPreview`. */
801
+ /** Options for `mountPreview`. Any other key throws a `TypeError`. */
802
802
  export interface MountPreviewOptions {
803
803
  /** A GLB from `partforge/gltf`'s `encodePreviewGlb`, or a runtime's `exportPreview()`. */
804
804
  glb: Uint8Array | ArrayBuffer;
@@ -808,12 +808,50 @@ export interface MountPreviewOptions {
808
808
  viewerState?: ViewerState | null;
809
809
  /** Play the file's baked animation once drawn (default true). The editor's own boot preview goes through mount(), not here. */
810
810
  animate?: boolean;
811
+ /** `false` (0.168.0+): no view cube, `#viewbar` controls, view style, tooltips, play button or `elements.chrome` wiring. Default true. */
812
+ chrome?: boolean;
813
+ /** `"transparent"` (0.168.0+): alpha-0 clear, no theme background, no grid. CAD mode only — realistic mode draws its own environment backdrop. Default `"theme"`. */
814
+ background?: "theme" | "transparent";
815
+ /** `true` (0.168.0+): a soft contact shadow under the part, cast once at showAssembly from the meshes as the file stores them (the rest pose, before a player applies its first pose); a playing animation does not move it. CAD mode only — realistic mode draws its own ground and shadow. Default false. */
816
+ contactShadow?: boolean;
817
+ /** `false` (0.168.0+): no wheel/pinch zoom (a wheel scrolls the page). Default true. */
818
+ zoom?: boolean;
819
+ /** `false` (0.168.0+): no pan. Default true. */
820
+ pan?: boolean;
821
+ /**
822
+ * Degrees per second (0.168.0+; default 0, off): a file with no playing animation
823
+ * turns about the vertical. Never under reduced motion; the first camera grab
824
+ * (a canvas drag or the view cube) stops it for good.
825
+ */
826
+ turntable?: number;
827
+ /**
828
+ * 0 < fill <= 1 (0.168.0+): the opening framing (and the viewer's reframe) puts the
829
+ * camera, along the opening view direction, so the model's bounding box as projected
830
+ * on screen spans this fraction of the viewport along its limiting axis. With a
831
+ * `turntable` (and no baked animation) the model swept a full turn is framed — its
832
+ * farthest vertex from the vertical axis, over the box's height — so the turn never
833
+ * clips and a round part still fills. With `fill` the model is centred on its
834
+ * horizontal vertex centroid (a lopsided part turns about its hub), and nothing
835
+ * leaves the viewport; a baked animation that moves parts outside the box may. Unset, the
836
+ * fixed framing distance.
837
+ */
838
+ fill?: number;
839
+ /** `"default"` (0.168.0+): drop the camera and projection of `viewerState` and `setViewerState`, so the mount keeps the file's own framing; their render mode and environment still apply. Default `"carry"`. */
840
+ camera?: "carry" | "default";
841
+ /** The opening render mode (0.168.0+): applied at mount, over any carried render mode; afterwards `setViewerState`, `renderMode.set` and the view style control change it as usual. Unset, the carried state decides. */
842
+ renderMode?: RenderMode;
811
843
  }
812
844
 
813
845
  /** The handle `mountPreview()` returns. Every method is safe before `ready` settles. */
814
846
  export interface PreviewRuntime {
815
847
  /** Resolves once the GLB is drawn; rejects when it will not decode or holds no meshes. */
816
848
  ready: Promise<void>;
849
+ /**
850
+ * Resolves after the first frame that includes the meshes has rendered (0.168.0+) —
851
+ * for cross-fading from a still image. Rejects with `ready`'s error when the file
852
+ * will not decode, or when the handle is disposed first. Waits while parked.
853
+ */
854
+ drawn: Promise<void>;
817
855
  /** Full teardown. Idempotent. */
818
856
  dispose(): void;
819
857
  /** The view the file was written for (`null` until drawn, or when the file names none). */