partforge 0.44.0 → 0.46.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.
@@ -80,6 +80,27 @@ export async function handle(kernel, part, msg, post, opts = {}) {
80
80
  const transfer = meshes.flatMap((m) =>
81
81
  [m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
82
82
  post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.() }, transfer);
83
+ } else if (msg.type === "capture-generate") {
84
+ // A private, job-correlated one-shot channel for captureView — builds a
85
+ // (possibly non-active) view's meshes off the regen loop, so it can never
86
+ // race or clobber live state. Same per-sub-part build+cache-round as
87
+ // `generate` above (cache:true reuses the worker's geometry memo), but no
88
+ // isStale/superseded polling — there's nothing to supersede a one-shot.
89
+ const useCache = msg.cache !== false;
90
+ const meshes = [];
91
+ for (const name of msg.subparts) {
92
+ if (useCache) kernel.beginSubPart?.(name);
93
+ try {
94
+ const m = posed(name, "display").toMesh({ quality: "preview" });
95
+ meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features });
96
+ } finally {
97
+ if (useCache) kernel.endSubPart?.();
98
+ kernel.cleanup?.();
99
+ }
100
+ }
101
+ const captureTransfer = meshes.flatMap((m) =>
102
+ [m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
103
+ post({ type: "capture-meshes", jobId: msg.jobId, meshes }, captureTransfer);
83
104
  } else if (msg.type === "export-stl") {
84
105
  const names = selected();
85
106
  if (names.length === 0) throw new Error("no exportable parts selected");
@@ -84,14 +84,26 @@ export const ANIMATION_RULES = [
84
84
  `animations.${name}.steps`));
85
85
  continue;
86
86
  }
87
- rawSteps(a).forEach((s, i) => {
88
- const path = hasSteps ? `animations.${name}.steps[${i}].tracks` : `animations.${name}.tracks`;
89
- if (!isPlainObject(s.tracks) || Object.keys(s.tracks).length === 0) {
90
- out.push(err("animation-tracks-or-steps",
91
- `animation "${name}"${hasSteps ? ` step ${i}` : ""} has no tracks`,
92
- "Every step needs a non-empty `tracks` object mapping a param key to keyframes.",
93
- path));
94
- }
87
+ const steps = rawSteps(a);
88
+ const trackful = (s) => isPlainObject(s.tracks) && Object.keys(s.tracks).length > 0;
89
+ if (!steps.some(trackful)) {
90
+ out.push(err("animation-tracks-or-steps",
91
+ `animation "${name}" animates nothing`,
92
+ "At least one step needs a non-empty `tracks` object mapping a param key to keyframes.",
93
+ hasSteps ? `animations.${name}.steps` : `animations.${name}.tracks`));
94
+ continue;
95
+ }
96
+ steps.forEach((s, i) => {
97
+ if (trackful(s)) return;
98
+ // A camera-only step is legal: it holds the pose and just moves the
99
+ // camera — an establishing shot before the motion starts. The runtime
100
+ // emits its cue and evaluate() holds the surrounding values, so lint
101
+ // must not reject what plays correctly.
102
+ if (hasSteps && s.camera != null) return;
103
+ out.push(err("animation-tracks-or-steps",
104
+ `animation "${name}"${hasSteps ? ` step ${i}` : ""} has no tracks`,
105
+ "Every step needs a non-empty `tracks` object mapping a param key to keyframes — or, for a step that only moves the camera, a `camera` angle.",
106
+ hasSteps ? `animations.${name}.steps[${i}].tracks` : `animations.${name}.tracks`));
95
107
  });
96
108
  }
97
109
  return out;
@@ -201,12 +213,31 @@ export const ANIMATION_RULES = [
201
213
  },
202
214
  {
203
215
  id: "animation-loop-invalid",
204
- run: ({ part }) => animEntries(part)
205
- .filter(([, a]) => a.loop === true && Array.isArray(a.steps) && a.steps.length > 1)
206
- .map(([name]) => err("animation-loop-invalid",
207
- `animation "${name}" sets \`loop: true\` on a multi-step animation`,
208
- "Loop is for continuous single-phase motion (gears). A stepped sequence replays via the transport instead — drop `loop` or collapse to one step.",
209
- `animations.${name}.loop`)),
216
+ run: ({ part }) => {
217
+ const out = [];
218
+ for (const [name, a] of animEntries(part)) {
219
+ if (a.loop === undefined) continue;
220
+ // Type first, like `autoplay`. The runtime fails closed (normalizeAnimation
221
+ // reads `spec.loop === true`), so a truthy non-boolean does NOT loop — it
222
+ // silently means `false`. That is the safe default but not an obvious one,
223
+ // so the author has to hear about it here rather than wonder why `loop: 1`
224
+ // does nothing.
225
+ if (typeof a.loop !== "boolean") {
226
+ out.push(err("animation-loop-invalid",
227
+ `animation "${name}" has a non-boolean \`loop\``,
228
+ "`loop` must be `true` or `false`. Any other truthy value still loops at runtime, so it cannot be left to mean something else.",
229
+ `animations.${name}.loop`));
230
+ continue; // one error per field: the check below assumes a real boolean
231
+ }
232
+ if (a.loop && Array.isArray(a.steps) && a.steps.length > 1) {
233
+ out.push(err("animation-loop-invalid",
234
+ `animation "${name}" sets \`loop: true\` on a multi-step animation`,
235
+ "Loop is for continuous single-phase motion (gears). A stepped sequence replays via the transport instead — drop `loop` or collapse to one step.",
236
+ `animations.${name}.loop`));
237
+ }
238
+ }
239
+ return out;
240
+ },
210
241
  },
211
242
  {
212
243
  id: "animation-step-label-duplicate",
@@ -235,7 +266,11 @@ export const ANIMATION_RULES = [
235
266
  run: ({ part }) => {
236
267
  const out = [];
237
268
  const check = (easing, path) => {
238
- if (easing !== undefined && !(easing in EASINGS)) {
269
+ // Own-key test, not `in`: `"toString" in EASINGS` is true, so `in` would
270
+ // wave through every Object.prototype member. The runtime (easingFor)
271
+ // applies the same test and falls back to the default, so these names are
272
+ // caught here rather than silently mis-animating or throwing mid-frame.
273
+ if (easing !== undefined && !Object.hasOwn(EASINGS, easing)) {
239
274
  out.push(err("animation-easing-unknown",
240
275
  `unknown easing "${easing}"`,
241
276
  `Use one of: ${Object.keys(EASINGS).join(", ")}.`,
@@ -258,7 +293,7 @@ export const ANIMATION_RULES = [
258
293
  const stepCameras = Array.isArray(a.steps)
259
294
  ? a.steps.map((s, i) => [s?.camera, i]).filter(([c]) => c !== undefined && c !== null)
260
295
  : [];
261
- if (a.camera !== undefined && stepCameras.length) {
296
+ if (a.camera != null && stepCameras.length) {
262
297
  out.push(err("animation-camera-invalid",
263
298
  `animation "${name}" mixes an animation-level \`camera\` with per-step cameras`,
264
299
  "One camera mechanism per animation: either the animation-level name/cue-list, or per-step names — not both.",
@@ -272,7 +307,9 @@ export const ANIMATION_RULES = [
272
307
  `animations.${name}.steps[${i}].camera`));
273
308
  }
274
309
  }
275
- if (a.camera === undefined) continue;
310
+ // An explicit `camera: null` is "no camera", which is how
311
+ // normalizeAnimation reads it — not a malformed value to report.
312
+ if (a.camera == null) continue;
276
313
  if (typeof a.camera === "string") {
277
314
  if (badName(a.camera)) {
278
315
  out.push(err("animation-camera-invalid",
@@ -83,6 +83,28 @@ export const SCHEMA_RULES = [
83
83
  return out;
84
84
  },
85
85
  },
86
+ {
87
+ id: "features-requires-on",
88
+ run: ({ part }) => {
89
+ const out = [];
90
+ sections(part).forEach((sec, si) => {
91
+ if (!sectionRenders(sec)) return;
92
+ arr(sec?.features).forEach((f, i) => {
93
+ // The panel treats "enabled" as `params[key] > 0`, so `on` has to be a
94
+ // positive number: a missing one writes undefined (NaN in the build),
95
+ // and 0 or a negative writes a value the panel reads straight back as
96
+ // "still off", leaving a checkbox that won't stay ticked.
97
+ if (f && !f.hidden && !(typeof f.on === "number" && f.on > 0)) {
98
+ out.push(err("features-requires-on",
99
+ `section "${sec.id ?? si}" feature ${i}${f.key ? ` ("${f.key}")` : ""} has no positive numeric \`on\` value`,
100
+ "Ticking a feature's checkbox writes `on` into the feature's own parameter, so a missing one writes `undefined` and the build reads it as NaN. Unlike a `toggles` entry — a plain flag that falls back to 1 — a feature's `on` is the real value the parameter takes when enabled (a diameter, a count), so there is no safe default to guess. It must be greater than 0, because the panel reads `> 0` as \"enabled\". Give the feature the value it should switch on to.",
101
+ `parameters[${si}].features[${i}]`));
102
+ }
103
+ });
104
+ });
105
+ return out;
106
+ },
107
+ },
86
108
  {
87
109
  id: "control-key-not-in-defaults",
88
110
  run: ({ part }) => {
@@ -23,16 +23,24 @@ import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } fr
23
23
  import { createPickRequestClient, resolvePickServerUrl, PICK_SERVER_DEFAULT_URL } from "./pick-request/index.js";
24
24
  import { exportablePartNames, partLabel } from "./export-select.js";
25
25
  import { createExportController, backendForFormat } from "./export-controller.js";
26
+ import { createCaptureBuild } from "./capture-build.js";
26
27
  import { attachAnimationControls } from "./animation-controls.js";
28
+ import { resolveDefaultView } from "./default-view.js";
27
29
 
28
30
  // The mount handle, factored out so its shape is unit-testable without booting
29
31
  // the full mount() pipeline (WASM + workers + DOM).
30
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation }) {
32
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView }) {
31
33
  return {
32
34
  ready, dispose, setParams,
33
35
  // Part-declared animation playback (spec 2026-08-02): null when the part
34
36
  // declares no animations. { play(name?), pause(), seek(t), stop(), state() }.
35
37
  animation: animation ?? null,
38
+ // Active view name (never null once mounted). See onViewChange for the push side.
39
+ getView,
40
+ // Programmatic tab switch; false for a name the part doesn't declare.
41
+ setView,
42
+ // Offscreen render of a named view (default when omitted, or on an unknown name).
43
+ captureView,
36
44
  captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
37
45
  captureCurrent: (opts) => viewer.captureCurrent(opts),
38
46
  // Park/unpark the viewer: stops the render loop and frees the drawing
@@ -82,10 +90,17 @@ function createCleanupStack() {
82
90
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
83
91
  // so Vite can bundle the worker (see geometry-service.js).
84
92
  //
85
- // Embedding contract (0.44.0):
86
- // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
93
+ // Embedding contract (0.45.0):
94
+ // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload, onViewChange });
87
95
  // await runtime.ready; // first successful build of the default view
88
96
  // runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
97
+ // runtime.getView(); // active view name (string), never null once mounted
98
+ // runtime.setView("lid"); // switch tab programmatically; returns false (and leaves the
99
+ // // active tab untouched) for a name the part doesn't declare
100
+ // await runtime.captureView(); // JPEG data URL of the DEFAULT view rendered offscreen (pass
101
+ // // a name for a specific view, falling back to the default for
102
+ // // an unknown one), never disturbing the active tab or the live
103
+ // // scene; null on failure (never throws)
89
104
  // runtime.captureCurrent({ size: 2048 }); // one offscreen render of the user's current
90
105
  // // framing (live camera pose + viewport aspect) at the
91
106
  // // given long-edge resolution → JPEG data URL, or null
@@ -121,10 +136,13 @@ function createCleanupStack() {
121
136
  // runtime.dispose(); // full teardown
122
137
  // onBuild fires per completed build, so it does NOT fire for a pose-only edit —
123
138
  // those are repaired in the viewer and produce no build at all.
139
+ // onViewChange fires once synchronously during mount with the initial resolved
140
+ // view (before ready), then again on every subsequent view change (user click
141
+ // or a programmatic setView) — always the new view name.
124
142
  // Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
125
143
  // exactly once here — submodules take element refs and never query the document.
126
144
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
127
- export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload,
145
+ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange,
128
146
  container: legacyContainer, controls: legacyControls } = {}) {
129
147
  // --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
130
148
  const byId = (id) => document.getElementById(id);
@@ -214,10 +232,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
214
232
  // View tabs (generated from part.views) + live params. A tab switch shows the
215
233
  // cached assembly instantly if it's current, else auto-builds what's missing.
216
234
  const tabsCtl = createViewTabs(els.tabs, part, {
217
- onChange: () => { pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); animCtl?.autoplayKick(); },
235
+ onChange: (name) => {
236
+ pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); animCtl?.autoplayKick();
237
+ onViewChange?.(name);
238
+ },
218
239
  });
219
240
  cleanup.defer(() => tabsCtl.detach());
220
241
  const view = () => tabsCtl.current();
242
+ // Tell the embedder the starting tab exactly once, synchronously, so a host
243
+ // (partforge-cloud) never has to poll getView() to learn where we opened.
244
+ onViewChange?.(tabsCtl.current());
221
245
  const params = { ...part.defaults };
222
246
 
223
247
  // Current selection context for the pickers: the active view + live params +
@@ -364,6 +388,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
364
388
  function onWorkerMessage({ data }) {
365
389
  // Headless exportParts() correlation: consume its own replies first.
366
390
  if (exportCtl.handleMessage(data, onDownload)) return;
391
+ // captureView's off-loop build channel: consume its replies before the
392
+ // live `meshes` case — capture-meshes must never touch live cache/display.
393
+ if (captureBuild.handleMessage(data)) return;
367
394
  switch (data.type) {
368
395
  case "ready":
369
396
  loop.ready(); // auto-build the default view (keeps the busy spinner up)
@@ -444,6 +471,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
444
471
  const service = createGeometryService({ createWorker, onMessage: onWorkerMessage });
445
472
  cleanup.defer(() => service.terminate());
446
473
 
474
+ const captureBuild = createCaptureBuild({ send: (msg, backend) => service.send(msg, backend) });
475
+ cleanup.defer(() => captureBuild.dispose());
476
+
447
477
  const exportCtl = createExportController({
448
478
  send: (msg, backend) => service.send(msg, backend),
449
479
  currentView: () => view(),
@@ -558,9 +588,31 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
558
588
  cleanup.dispose();
559
589
  }
560
590
 
591
+ // Off-loop offscreen thumbnail: builds `viewName` (or the part's resolved
592
+ // default when omitted/unknown) via captureBuild's correlated channel, then
593
+ // renders it in a throwaway scene via viewer.renderMeshPayloads. Never
594
+ // touches the active tab, getView(), or the live scene — best-effort: any
595
+ // failure, including a resolved-null from a worker build failure (4A
596
+ // settles rather than throwing), returns null.
597
+ const captureView = async (viewName, opts = {}) => {
598
+ try {
599
+ const target = (viewName && part.views?.[viewName]) ? viewName : resolveDefaultView(part);
600
+ const subparts = viewSubParts(part, target, params);
601
+ if (!subparts.length) return null;
602
+ const meshes = await captureBuild.request({ subparts, view: target, params, backend: backendFor() });
603
+ if (!meshes || !meshes.length) return null; // 4A resolves null on a worker build failure
604
+ return viewer.renderMeshPayloads(meshes, { size: 640, quality: 0.8, angle: "iso", ...opts });
605
+ } catch {
606
+ return null; // best-effort: a failed thumbnail never breaks the caller
607
+ }
608
+ };
609
+
561
610
  return makeHandle({
562
611
  ready, dispose, viewer, setParams,
563
612
  setHostPane: paneTabs.setHostPane,
613
+ getView: view, // () => tabsCtl.current()
614
+ setView: (name) => tabsCtl.select(name),
615
+ captureView,
564
616
  listExportableParts: () =>
565
617
  exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
566
618
  exportParts: (opts) => exportCtl.exportParts(opts),
@@ -48,6 +48,19 @@ export function createViewTabs(el, part, { onChange }) {
48
48
 
49
49
  return {
50
50
  current: () => view,
51
+ // Programmatic switch — the click path without the click. Used by an
52
+ // embedder (mount's handle.setView) to change tabs from outside the DOM.
53
+ // Returns false for a name that isn't a tab so callers can validate.
54
+ select: (name) => {
55
+ if (name === view) return true; // already active — nothing to do
56
+ const btn = [...el.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === name);
57
+ if (!btn) return false;
58
+ view = name;
59
+ saveView(partKey, view);
60
+ setActive(btn);
61
+ onChange(view);
62
+ return true;
63
+ },
51
64
  detach: () => {
52
65
  el.removeEventListener("click", onClick);
53
66
  if (generated) el.innerHTML = ""; // we generated these buttons; hand-written markup stays
@@ -3,8 +3,15 @@ import * as THREE from "three";
3
3
  const KEY_COLOR = 0xffffff, KEY_INTENSITY = 1.45;
4
4
  const FILL_COLOR = 0xe5efff, FILL_INTENSITY = 0.65;
5
5
 
6
+ // The persistent sky/ground ambient. Shared with the offscreen thumbnail path
7
+ // (viewer.js renderMeshPayloads), which builds its own throwaway scene and so needs
8
+ // the same hemisphere fill to avoid rendering faces outside the key/fill cones near-black.
9
+ export function createHemisphereLight() {
10
+ return new THREE.HemisphereLight(0xdce9ff, 0x687586, 1.35);
11
+ }
12
+
6
13
  export function addViewerLights(scene) {
7
- const hemisphere = new THREE.HemisphereLight(0xdce9ff, 0x687586, 1.35);
14
+ const hemisphere = createHemisphereLight();
8
15
  const key = new THREE.DirectionalLight(KEY_COLOR, KEY_INTENSITY);
9
16
  key.position.set(8, 14, 10);
10
17
  const fill = new THREE.DirectionalLight(FILL_COLOR, FILL_INTENSITY);
@@ -6,7 +6,7 @@ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js
6
6
  import { LineMaterial } from "three/addons/lines/LineMaterial.js";
7
7
  import { createCutaway } from "./cutaway.js";
8
8
  import { createCameraTween } from "./camera-tween.js";
9
- import { addViewerLights, captureLightPoses, createCaptureLights } from "./viewer-lighting.js";
9
+ import { addViewerLights, captureLightPoses, createCaptureLights, createHemisphereLight } from "./viewer-lighting.js";
10
10
  import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
11
11
 
12
12
  // three renders into a render target in the LINEAR working colour space: as of r184
@@ -166,7 +166,7 @@ export function createViewer(container, part) {
166
166
  }
167
167
 
168
168
  // CAD-style feature edge lines (anti-aliased "fat" lines), one per sub-part.
169
- const EDGE_ANGLE = 35; // deg — OCCT fallback threshold (Manifold supplies seam-aware edges)
169
+ const EDGE_ANGLE = 35; // deg — last-ditch threshold for payloads with no kernel edge data
170
170
  const lineMaterial = new LineMaterial({ color: THEME.dark.line, linewidth: 1.0 }); // ~10% lighter, 1 px
171
171
  lineMaterial.resolution.set(1, 1); // real size set by resize() below
172
172
  const subLines = Object.fromEntries(
@@ -246,10 +246,10 @@ export function createViewer(container, part) {
246
246
  controls.addEventListener("start", onControlsStart);
247
247
  function onCameraStart(cb) { cameraStartListeners.add(cb); return () => cameraStartListeners.delete(cb); }
248
248
 
249
- // Smooth shading within CREASE_ANGLE of a shared edge, hard edge past it — so the
250
- // round body and helical groove read smooth while bore rims, drum faces, and
251
- // groove walls stay crisp. Lower = more hard edges; raise toward Math.PI/3 for
252
- // softer. (Worker normals are ignored; we recompute per the crease threshold.)
249
+ // Fallback creasing for payloads with no kernel normals. Both backends now
250
+ // ship authoritative normals (Manifold: policy-aware crease pass; OCCT:
251
+ // analytic B-rep normals), so this path is last-ditch only it must not be
252
+ // "improved" in lieu of fixing a backend that stopped sending normals.
253
253
  const CREASE_ANGLE = Math.PI / 6; // 30°
254
254
 
255
255
  // --- geometry builder -----------------------------------------------------
@@ -262,20 +262,23 @@ export function createViewer(container, part) {
262
262
  const triCount = triangles ?? (indices ? indices.length : positions.length / 3) / 3;
263
263
  let out;
264
264
  if (normals?.length) {
265
- // kernel-computed normals (Manifold) — smooth within a surface, hard at cut seams
265
+ // kernel-computed normals (both backends) — smooth within a surface, hard at cut seams
266
266
  geo.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
267
267
  geo.computeBoundingBox();
268
268
  out = geo;
269
269
  } else {
270
- // fallback (no kernel normals, e.g. OCCT): crease from the triangle soup
270
+ // fallback (payload with no kernel normals no current backend does this): crease from the triangle soup
271
271
  out = toCreasedNormals(geo, CREASE_ANGLE);
272
272
  out.computeBoundingBox();
273
273
  }
274
274
  out.userData.triangles = triCount;
275
275
  if (featureIds) { out.userData.featureIds = featureIds; out.userData.features = features; }
276
- // feature edge lines: Manifold supplies seam-aware segments; else derive by angle
276
+ // feature edge lines: kernel-supplied segments are authoritative an EMPTY
277
+ // array means "this solid has no feature edges" (e.g. a lone sphere), so
278
+ // draw none rather than falling back. Only a payload with NO edge data at
279
+ // all (edges === undefined; no current backend does this) derives by angle.
277
280
  const lg = new LineSegmentsGeometry();
278
- if (edges?.length) lg.setPositions(edges);
281
+ if (edges) lg.setPositions(edges); // edges is already a well-formed (possibly zero-length) Float32Array
279
282
  else lg.fromEdgesGeometry(new THREE.EdgesGeometry(out, EDGE_ANGLE));
280
283
  out.userData.edges = lg;
281
284
  return out;
@@ -427,7 +430,8 @@ export function createViewer(container, part) {
427
430
  // no error, live view unaffected, wrong only in the capture.
428
431
  const RT_OPTIONS = { samples: 4, stencilBuffer: true };
429
432
  function renderOffscreen({ position, up, target },
430
- { width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {}) {
433
+ { width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {},
434
+ renderScene = scene) {
431
435
  const cachedSize = width === _rtSize && height === _rtSize;
432
436
  const rt = cachedSize
433
437
  ? (_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, RT_OPTIONS))
@@ -451,7 +455,7 @@ export function createViewer(container, part) {
451
455
  scene.add(capKey, capKey.target, capFill, capFill.target);
452
456
  try {
453
457
  renderer.setRenderTarget(rt);
454
- renderer.render(scene, cam);
458
+ renderer.render(renderScene, cam);
455
459
  // render() resolves the multisample renderbuffer into the target texture, so this
456
460
  // reads antialiased pixels.
457
461
  renderer.readRenderTargetPixels(rt, 0, 0, width, height, buf);
@@ -510,6 +514,76 @@ export function createViewer(container, part) {
510
514
  });
511
515
  }
512
516
 
517
+ // Offscreen render of an arbitrary mesh set (a non-active view), for thumbnails.
518
+ // Assembles a THROWAWAY scene mirroring the live pivot convention, frames it from a
519
+ // canonical angle, renders through the parameterized renderOffscreen, and disposes
520
+ // everything. Never touches the live scene, camera, subMesh, or subCache. `payloads`
521
+ // is the worker's [{name, positions, normals, indices, …}] array — placement is
522
+ // already baked into shared-frame coords, so meshes are NOT recentred.
523
+ function renderMeshPayloads(payloads, { angle = "iso", size = 640, quality = 0.8 } = {}) {
524
+ if (disposed) return null; // same guard as captureCurrent/captureCanonicalViews — never touch a torn-down renderer
525
+ const tmpScene = new THREE.Scene();
526
+ const tmpPivot = new THREE.Group();
527
+ tmpPivot.rotation.x = -Math.PI / 2; // model Z (CAD up) -> vertical, same as live pivot
528
+ tmpScene.add(tmpPivot);
529
+
530
+ const built = [];
531
+ for (const payload of payloads) {
532
+ const geo = buildGeometry(payload); // shared-frame coords, NOT recentred
533
+ const mesh = new THREE.Mesh(geo, materialFor(payload.name));
534
+ tmpPivot.add(mesh);
535
+ built.push(mesh);
536
+ }
537
+
538
+ // Frame in WORLD space, AFTER the pivot rotation. The meshes are built in model
539
+ // coords but rendered rotated by tmpPivot, so a model-space bbox centre would aim
540
+ // the camera at the wrong point — an off-origin part would render off-centre or blank.
541
+ tmpPivot.updateMatrixWorld(true);
542
+ const box = new THREE.Box3().setFromObject(tmpPivot);
543
+ const center = box.getCenter(new THREE.Vector3()).toArray();
544
+ const radius = box.getSize(new THREE.Vector3()).length() / 2 || 1;
545
+ const pose = cameraPoseForView(angle, { center, radius });
546
+
547
+ // Light the throwaway scene ourselves: renderOffscreen's own key/fill (and the
548
+ // persistent hemisphere) live in the LIVE scene, which is never rendered here — so
549
+ // without our own ambient + camera-relative key/fill it comes back near-black.
550
+ const hemi = createHemisphereLight();
551
+ const capLights = createCaptureLights();
552
+ const poses = captureLightPoses(pose);
553
+ capLights.key.position.set(poses.key[0], poses.key[1], poses.key[2]);
554
+ capLights.fill.position.set(poses.fill[0], poses.fill[1], poses.fill[2]);
555
+ for (const light of [capLights.key, capLights.fill]) {
556
+ light.target.position.set(pose.target[0], pose.target[1], pose.target[2]);
557
+ }
558
+ tmpScene.add(hemi, capLights.key, capLights.key.target, capLights.fill, capLights.fill.target);
559
+
560
+ // Feature-edge lines, so the thumbnail carries the same hole/seam/chamfer outlines the
561
+ // live viewer shows. A dedicated LineMaterial at the render resolution (the live one is
562
+ // sized to the on-screen canvas); added after framing so it can't perturb the bbox.
563
+ const lineMat = new LineMaterial({ color: THEME.dark.line, linewidth: 1.0 });
564
+ lineMat.resolution.set(size, size);
565
+ for (const mesh of built) {
566
+ const edges = mesh.geometry.userData.edges;
567
+ if (edges) tmpPivot.add(new LineSegments2(edges, lineMat));
568
+ }
569
+
570
+ try {
571
+ // fov matches the live camera (and captureViews/captureCurrent) — cameraPoseForView's
572
+ // distance is tuned to it, so a narrower fov would crop long, thin parts.
573
+ return renderOffscreen(pose, { width: size, height: size, fov: camera.fov, quality }, tmpScene);
574
+ } finally {
575
+ for (const mesh of built) {
576
+ mesh.geometry.userData.edges?.dispose();
577
+ mesh.geometry.dispose();
578
+ if (mesh.material !== material) mesh.material.dispose(); // clone only — never the shared singleton
579
+ }
580
+ lineMat.dispose();
581
+ hemi.dispose?.();
582
+ capLights.key.dispose?.();
583
+ capLights.fill.dispose?.();
584
+ }
585
+ }
586
+
513
587
  // --- render loop ----------------------------------------------------------
514
588
  // The tween is applied after controls.update() so the cue wins the frame, and
515
589
  // the frame listeners run before render so a playback frame draws its own pose.
@@ -523,7 +597,13 @@ export function createViewer(container, part) {
523
597
  camera.position.fromArray(tw.position);
524
598
  controls.target.fromArray(tw.target);
525
599
  }
526
- for (const cb of [...frameListeners]) cb(dt);
600
+ // Per-listener guard, because three re-arms requestAnimationFrame only AFTER
601
+ // this callback returns (WebGLAnimation.onAnimationFrame): a listener that
602
+ // throws would stop the rAF chain outright and freeze the viewer for good, not
603
+ // just skip a frame. Containment belongs here rather than in every subscriber.
604
+ for (const cb of [...frameListeners]) {
605
+ try { cb(dt); } catch (e) { console.warn("partforge: frame listener failed", e); }
606
+ }
527
607
  if (cutaway.isEnabled) cutaway.updateForCamera();
528
608
  renderer.render(scene, camera);
529
609
  cutaway.renderOverlay(renderer, camera);
@@ -662,6 +742,7 @@ export function createViewer(container, part) {
662
742
  frame,
663
743
  captureCanonicalViews,
664
744
  captureCurrent,
745
+ renderMeshPayloads,
665
746
  onFrame,
666
747
  tweenCameraTo,
667
748
  cancelCameraTween,
@@ -110,7 +110,11 @@ export function runWorker(part) {
110
110
  await handle(kernel, job.part, job.data, gated, { isStale });
111
111
  } catch (err) {
112
112
  // Same shape jobs.js posts for a failed build, so hosts need no new branch.
113
- postMessage({ type: "error", message: String(err?.message || err) });
113
+ // Carry the job's jobId when it has one (capture/export are correlated by it):
114
+ // a boot failure hitting kernelFor here must reach the right controller, or a
115
+ // correlated caller (captureView, exportParts) would hang instead of settling.
116
+ const jobId = job.data?.jobId;
117
+ postMessage({ type: "error", message: String(err?.message || err), ...(jobId != null ? { jobId } : {}) });
114
118
  }
115
119
  }
116
120
  } finally {
@@ -86,8 +86,8 @@ export async function renderViews(kernel, part, view = Object.keys(part.views)[0
86
86
 
87
87
  for (const m of meshes) {
88
88
  const P = m.positions, N = m.normals, ind = m.indices;
89
- // Manifold meshes are a non-indexed soup (3 consecutive verts/triangle) with
90
- // per-vertex normals; OCCT meshes are indexed and carry no normals.
89
+ // Manifold meshes are a non-indexed soup (3 consecutive verts/triangle);
90
+ // OCCT meshes are indexed. Both carry per-vertex normals.
91
91
  const triCount = ind?.length ? ind.length / 3 : P.length / 9;
92
92
  for (let t = 0; t < triCount; t++) {
93
93
  const ai = ind?.length ? ind[t * 3] * 3 : t * 9;
package/types/index.d.ts CHANGED
@@ -5,14 +5,11 @@
5
5
  // their geometry helpers from "partforge/geometry".
6
6
 
7
7
  import type { BackendName } from "./kernel.js";
8
- import type { ParamValue, PartDefinition } from "./part.js";
8
+ import type { CanonicalView, ParamValue, PartDefinition } from "./part.js";
9
9
 
10
10
  export * from "./kernel.js";
11
11
  export * from "./part.js";
12
12
 
13
- /** A canonical capture angle. */
14
- export type CanonicalView = "iso" | "front" | "back" | "left" | "right" | "top" | "bottom";
15
-
16
13
  /** Which pane a narrow layout shows. `null` hands selection back to partforge. */
17
14
  export type HostPane = "stage" | "rail" | null;
18
15
 
@@ -110,6 +107,8 @@ export interface MountOptions {
110
107
  onPick?: (event: PickEvent) => void;
111
108
  /** Receive exported bytes instead of partforge's own DOM download. */
112
109
  onDownload?: (file: DownloadPayload) => void;
110
+ /** The active view (tab) name — emitted once on mount, then on every change. */
111
+ onViewChange?: (view: string) => void;
113
112
  /** @deprecated alias for `elements.viewer`. */
114
113
  container?: HTMLElement | null;
115
114
  /** @deprecated alias for `elements.controls`. */
@@ -134,6 +133,15 @@ export interface CaptureCurrentOptions {
134
133
  quality?: number;
135
134
  }
136
135
 
136
+ export interface CaptureViewOptions {
137
+ /** Square render resolution in px. Default 640. */
138
+ size?: number;
139
+ /** JPEG quality, 0..1. Default 0.8. */
140
+ quality?: number;
141
+ /** Canonical angle to render from. Default `"iso"`. */
142
+ angle?: CanonicalView | string;
143
+ }
144
+
137
145
  /** Where playback is: idle, swinging the camera to an intro cue, playing, or paused. */
138
146
  export type AnimationStatus = "idle" | "intro" | "playing" | "paused";
139
147
 
@@ -190,6 +198,17 @@ export interface PartRuntime {
190
198
  * when disposed or nothing is built yet. Never throws.
191
199
  */
192
200
  captureCurrent(opts?: CaptureCurrentOptions): string | null;
201
+ /** The active view (tab) name. Never null once mounted. */
202
+ getView(): string;
203
+ /** Switch the active view; `false` if the part declares no such view. Persists per part for the session. */
204
+ setView(name: string): boolean;
205
+ /**
206
+ * Render a named view OFFSCREEN → a `data:image/jpeg;base64,…` string, or `null` on
207
+ * failure (a build error, a view with no sub-parts, or a disposed runtime). Omit
208
+ * `viewName` — or pass an unknown one — to render the part's DEFAULT view. Never
209
+ * disturbs the active tab, the live camera, or the on-screen scene.
210
+ */
211
+ captureView(viewName?: string, opts?: CaptureViewOptions): Promise<string | null>;
193
212
  /**
194
213
  * Park/unpark the viewer: stops the render loop and releases the drawing
195
214
  * buffer and cached capture target. For a host that hides the canvas without