partforge 0.44.0 → 0.45.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/bin/cli.js CHANGED
@@ -159,15 +159,35 @@ const commands = {
159
159
  try {
160
160
  const part = await loadPart(partPath, usage);
161
161
  const baseParams = flags.params ? JSON.parse(flags.params) : {};
162
+ // `--params '[1,2]'` or '42' parses fine and then merges into nothing, so the
163
+ // flag silently does nothing. Only an object can carry param overrides.
164
+ if (baseParams === null || typeof baseParams !== "object" || Array.isArray(baseParams)) {
165
+ die(`--params takes a JSON object of param overrides\n${usage}`);
166
+ }
162
167
  const outDir = flags.out || "render";
163
168
  const views = flags.views ? flags.views.split(",") : undefined;
164
- // Usage check BEFORE the kernel: a flag typo shouldn't pay a WASM boot.
165
- if (!flags.animation && (flags.at || flags.step)) {
169
+ // Usage checks BEFORE the kernel: a flag typo shouldn't pay a WASM boot.
170
+ // Test `=== undefined`, not falsiness: `--animation ""` is a flag the user
171
+ // passed and got wrong (an unset shell variable, typically), not one they
172
+ // omitted, and silently rendering a non-animation still hides the mistake.
173
+ if (flags.animation !== undefined && flags.animation.trim() === "") {
174
+ die(`--animation needs an animation name\n${usage}`);
175
+ }
176
+ if (flags.animation === undefined && (flags.at || flags.step)) {
166
177
  die(`--at/--step require --animation\n${usage}`);
167
178
  }
179
+ if (flags.at != null && flags.step != null) {
180
+ die(`--at and --step are alternatives — pass one, not both\n${usage}`);
181
+ }
182
+ // Own-key test: `part.views?.["constructor"]` resolves through
183
+ // Object.prototype and would sail past a plain lookup, straight back into
184
+ // the background-only render this guard exists to stop.
185
+ if (view !== undefined && !Object.hasOwn(part.views ?? {}, view)) {
186
+ die(`unknown view "${view}" (have: ${Object.keys(part.views ?? {}).join(", ") || "none"})\n${usage}`);
187
+ }
168
188
  const kernel = await bootKernel(part);
169
189
 
170
- if (!flags.animation) {
190
+ if (flags.animation === undefined) {
171
191
  const files = await renderViews(kernel, part, view, { views, out: outDir, params: baseParams });
172
192
  for (const f of files) console.log(`wrote ${f}`);
173
193
  process.exit(0);
@@ -196,11 +216,29 @@ const commands = {
196
216
  // step's camera instead of this step's own.
197
217
  frames = [{ t: end, cueT: anim.stepStarts[idx], tag: `${flags.animation}-step${idx + 1}` }];
198
218
  } else {
199
- const ts = (flags.at ?? "1").split(",").map(Number);
219
+ // Split first and reject blanks: Number("") is 0, so "0.2,,0.8" would
220
+ // otherwise slip a silent extra frame at t=0 past the range check.
221
+ const raw = (flags.at ?? "1").split(",");
222
+ const ts = raw.map((s) => (s.trim() === "" ? Number.NaN : Number(s)));
200
223
  if (!ts.length || ts.some((t) => !Number.isFinite(t) || t < 0 || t > 1)) {
201
224
  die(`--at takes comma-separated positions in 0..1\n${usage}`);
202
225
  }
203
- frames = ts.map((t) => ({ t, tag: `${flags.animation}-t${String(Math.round(t * 100)).padStart(3, "0")}` }));
226
+ // The tag is the only thing distinguishing one frame's file from another.
227
+ // Two decimals suits the usual `--at 0,0.5,1`, but a dense request like
228
+ // 0.001,0.004 collides and the later render would silently overwrite the
229
+ // earlier — one file for two frames asked for. Widen the tag just enough
230
+ // for THIS request instead of refusing it: ordinary runs keep their
231
+ // familiar t000/t050/t100 names, dense ones get one file each.
232
+ const tagsAt = (decimals) =>
233
+ ts.map((t) => String(Math.round(t * 10 ** decimals)).padStart(decimals + 1, "0"));
234
+ let decimals = 2;
235
+ while (decimals < 6 && new Set(tagsAt(decimals)).size !== ts.length) decimals++;
236
+ const tags = tagsAt(decimals);
237
+ if (new Set(tags).size !== ts.length) {
238
+ // No precision separates them: the same position was listed twice.
239
+ die(`--at lists the same position more than once\n${usage}`);
240
+ }
241
+ frames = ts.map((t, i) => ({ t, tag: `${flags.animation}-t${tags[i]}` }));
204
242
  }
205
243
  for (const frame of frames) {
206
244
  const { values } = evaluate(anim, frame.t);
@@ -167,6 +167,13 @@ Rules (all lint-enforced):
167
167
  inside the owning control's min/max (the engine applies them unclamped).
168
168
  - Params not tracked anywhere keep their current values; a param tracked in
169
169
  one step holds its nearest keyframe value while other steps play.
170
+ - A step may declare a `camera` and **no** `tracks` — an establishing shot that
171
+ swings the view while the model holds still. At least one step still has to
172
+ carry tracks, or the animation animates nothing. Note the holding value is the
173
+ nearest keyframe, not whatever the user last set: a leading camera-only step
174
+ shows the animation's opening pose, the same one `t = 0` would show.
175
+ - `loop` and `autoplay` must be literal booleans. Anything else is reported by
176
+ lint and treated as `false` at runtime, so `loop: "false"` never means "loop".
170
177
  - Couple motions through `derive` (animate one master param; derive the rest),
171
178
  not by tracking dependent params separately.
172
179
  - `camera` cues use the seven canonical angles (`iso front back top bottom
@@ -484,6 +491,12 @@ choosing a preset updates both numeric and text fields.
484
491
  Every `key` used must exist in `defaults`. `src/parts/demo.js` is the worked example for
485
492
  everything below.
486
493
 
494
+ A feature's `on` is **required and must be greater than 0** — it is the real value the
495
+ parameter takes when the box is ticked (a diameter, a count), and the panel reads
496
+ `> 0` as "enabled", so there is nothing sensible to fall back to. `partforge lint`
497
+ reports a missing or non-positive one as `features-requires-on`. A `toggles` entry is
498
+ the exception: its `on` is just a flag and defaults to 1.
499
+
487
500
  **Standalone toggles** (a plain on/off checkbox, no accompanying sliders): add a
488
501
  `toggles` array to a preset section — shown below the preset picker, outside the
489
502
  Advanced fold, so it stays visible:
@@ -791,6 +804,27 @@ Copy `demo.html` and change the title, the panel heading, and the `<script src>`
791
804
  workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
792
805
  `"occt"` for STEP — handled for you).
793
806
 
807
+ **View control (the mount handle).** For an embedder driving the view tabs from its own UI
808
+ instead of (or in addition to) the built-in `#part` bar:
809
+
810
+ - `runtime.getView() → string` — the active view name; never null once the runtime is ready
811
+ (mount resolves a default before first build — see "Which view the viewer opens on" above).
812
+ - `runtime.setView(name) → boolean` — switch tabs programmatically, the same path as clicking
813
+ a tab. Returns `false` (and leaves the active tab untouched) for a name the part doesn't
814
+ declare in `views`; `true` otherwise, including when `name` is already active.
815
+ - `await runtime.captureView(viewName?, opts?) → Promise<string | null>` — a JPEG data URL of
816
+ `viewName` rendered offscreen (falling back to the resolved default view — see
817
+ `resolveDefaultView` / `default-view.js` — when `viewName` is omitted or names a view the
818
+ part doesn't declare). Never disturbs the active tab, the live camera, or the on-screen
819
+ scene; `opts` forwards to the underlying render (size, quality, angle). Resolves `null` on
820
+ failure rather than throwing (a build error, a part with no sub-parts in that view, a
821
+ disposed runtime).
822
+
823
+ Pass `onViewChange(name)` to `mount()` to be told the active view: it fires once
824
+ synchronously during mount with the initial resolved view (before `runtime.ready` settles),
825
+ then again on every subsequent change — a tab click or a `setView` call — always with the
826
+ new view name.
827
+
794
828
  **Headless export (the mount handle).** The `#download*` buttons above are the built-in,
795
829
  view-bound export UI. An embedder that wants its own export UI (e.g. a "pick which parts,
796
830
  pick a format" modal) can skip those buttons and drive export off the handle `mount()`
@@ -1083,9 +1117,10 @@ previously didn't; that's the fix working as intended, not a regression.
1083
1117
  `missing-views`, `part-view-unknown` (all errors); `view-unused`,
1084
1118
  `default-view-ambiguous` (warnings).
1085
1119
 
1086
- **Parameter schema** — `features-requires-sliders`, `control-key-not-in-defaults`,
1087
- `preset-key-not-in-defaults` (errors); `slider-range-excludes-default`,
1088
- `unknown-control-field`, `duplicate-control-key`, `default-not-exposed` (warnings).
1120
+ **Parameter schema** — `features-requires-sliders`, `features-requires-on`,
1121
+ `control-key-not-in-defaults`, `preset-key-not-in-defaults` (errors);
1122
+ `slider-range-excludes-default`, `unknown-control-field`, `duplicate-control-key`,
1123
+ `default-not-exposed` (warnings).
1089
1124
 
1090
1125
  **Kernel API**, found by executing `build()` against a geometry-free probe —
1091
1126
  `unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.44.0",
3
+ "version": "0.45.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",
@@ -100,7 +100,7 @@
100
100
  "@fontsource-variable/geist-mono": "^5.3.0",
101
101
  "happy-dom": "^20.10.6",
102
102
  "playwright": "^1.49.0",
103
- "typescript": "^7.0.2",
103
+ "typescript": "^5.9.3",
104
104
  "vite": "^8.0.16",
105
105
  "vitest": "^4.1.9"
106
106
  }
@@ -112,6 +112,11 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
112
112
  // propagate would take the other listeners down with it. Warn once, then
113
113
  // stay quiet so a bad frame can't flood the console 60x a second.
114
114
  let frameFailureWarned = false;
115
+ function warnFrameFailure(err) {
116
+ if (frameFailureWarned) return;
117
+ frameFailureWarned = true;
118
+ console.warn("partforge: animation frame failed", err);
119
+ }
115
120
  function apply(r) {
116
121
  if (!r) return;
117
122
  try {
@@ -124,18 +129,24 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
124
129
  duration: tweenDuration,
125
130
  // An intro cue gates playback until the tween settles; mid-timeline
126
131
  // cues overlap playback and need no completion signal.
127
- onComplete: r.status === "intro" ? () => apply(playback.introDone()) : undefined,
132
+ onComplete: r.status === "intro" ? () => guarded(() => playback.introDone()) : undefined,
128
133
  });
129
134
  }
130
135
  syncUi();
131
136
  } catch (err) {
132
- if (!frameFailureWarned) {
133
- frameFailureWarned = true;
134
- console.warn("partforge: animation frame failed", err);
135
- }
137
+ warnFrameFailure(err);
136
138
  }
137
139
  }
138
140
 
141
+ // Every transport entry point goes through here so the STATE-MACHINE call is
142
+ // inside the guard too, not just apply(). playback.tick() is evaluated in the
143
+ // render loop, and three re-arms requestAnimationFrame only after the frame
144
+ // callback returns — a throw escaping from there stops the rAF chain and
145
+ // freezes the viewer permanently instead of costing one frame.
146
+ function guarded(produce) {
147
+ try { apply(produce()); } catch (err) { warnFrameFailure(err); }
148
+ }
149
+
139
150
  function doReset() {
140
151
  playback.reset();
141
152
  viewer.cancelCameraTween();
@@ -154,14 +165,14 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
154
165
  syncUi();
155
166
  }
156
167
 
157
- const offFrame = viewer.onFrame((dt) => apply(playback.tick(dt)));
168
+ const offFrame = viewer.onFrame((dt) => guarded(() => playback.tick(dt)));
158
169
  // User orbit: the viewer has already cancelled any cue tween (its own
159
170
  // "start" handler); disarm the remaining cues, and if an intro tween was
160
171
  // gating playback, settle the gate — cancel() never fires onComplete, so
161
172
  // without this the machine would sit in "intro" forever.
162
173
  const offOrbit = viewer.onCameraStart(() => {
163
174
  playback.disarmCues();
164
- if (playback.state().status === "intro") apply(playback.introDone());
175
+ if (playback.state().status === "intro") guarded(() => playback.introDone());
165
176
  });
166
177
 
167
178
  const onPlayClick = () => {
@@ -169,14 +180,14 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
169
180
  const active = playback.state().status;
170
181
  if (active === "playing" || active === "intro") {
171
182
  viewer.cancelCameraTween();
172
- apply(playback.pause());
183
+ guarded(() => playback.pause());
173
184
  } else {
174
- apply(playback.play());
185
+ guarded(() => playback.play());
175
186
  }
176
187
  };
177
- const onScrub = () => { disarmAutoplay(); apply(playback.seek(Number(scrub.value) / 1000)); };
178
- const onPrev = () => { disarmAutoplay(); apply(playback.stepPrev()); };
179
- const onNext = () => { disarmAutoplay(); apply(playback.stepNext()); };
188
+ const onScrub = () => { disarmAutoplay(); guarded(() => playback.seek(Number(scrub.value) / 1000)); };
189
+ const onPrev = () => { disarmAutoplay(); guarded(() => playback.stepPrev()); };
190
+ const onNext = () => { disarmAutoplay(); guarded(() => playback.stepNext()); };
180
191
  const onPick = () => { disarmAutoplay(); selectAnimation(pick.value); };
181
192
  const onResetClick = () => { disarmAutoplay(); doReset(); };
182
193
  playBtn.addEventListener("click", onPlayClick);
@@ -200,10 +211,10 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
200
211
  return;
201
212
  }
202
213
  if (name) selectAnimation(name);
203
- apply(playback.play());
214
+ guarded(() => playback.play());
204
215
  },
205
- pause() { disarmAutoplay(); viewer.cancelCameraTween(); apply(playback.pause()); },
206
- seek(t) { disarmAutoplay(); apply(playback.seek(t)); },
216
+ pause() { disarmAutoplay(); viewer.cancelCameraTween(); guarded(() => playback.pause()); },
217
+ seek(t) { disarmAutoplay(); guarded(() => playback.seek(t)); },
207
218
  stop() { disarmAutoplay(); doReset(); },
208
219
  state: () => ({ animation: current.name, ...playback.state() }),
209
220
  };
@@ -223,7 +234,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
223
234
  if (!autoplayArmed || !autoplayAnim) return;
224
235
  if (current !== autoplayAnim) selectAnimation(autoplayAnim.name);
225
236
  const { status } = playback.state();
226
- if (status !== "playing" && status !== "intro") apply(playback.play());
237
+ if (status !== "playing" && status !== "intro") guarded(() => playback.play());
227
238
  },
228
239
  detach() {
229
240
  offFrame();
@@ -13,6 +13,32 @@ export const EASINGS = {
13
13
  };
14
14
  export const DEFAULT_EASING = "ease-in-out";
15
15
 
16
+ // Look easings up by OWN key only. `EASINGS[name]` / `name in EASINGS` would walk
17
+ // the prototype chain, so "toString" resolves to a function that silently returns
18
+ // garbage and "__proto__" resolves to a non-function that throws — mid-frame, from
19
+ // inside the render loop. Lint applies the same test, so an unknown easing is an
20
+ // authoring error there and a quiet fall back to the default here.
21
+ export const easingFor = (name) =>
22
+ (Object.hasOwn(EASINGS, name) ? EASINGS[name] : EASINGS[DEFAULT_EASING]);
23
+
24
+ // A track value has to be a non-empty keyframe array to be evaluable. Lint reports
25
+ // anything else as `animation-keyframes-invalid`; this predicate is what keeps the
26
+ // runtime total when a part reaches it unlinted, and it must stay the single rule
27
+ // both segmentsFor and trackedKeys agree on — if they disagree, evaluate() is asked
28
+ // for a key that has no segment and throws.
29
+ const usableKeyframes = (kf) => Array.isArray(kf) && kf.length > 0;
30
+
31
+ // t is clamped to [0,1]. Only an unorderable t (NaN, or anything that coerces to
32
+ // it, such as a host calling seek() with no argument) folds to 0 — ±Infinity is
33
+ // ordered and clamps normally. NaN has to be caught rather than clamped because
34
+ // it fails every comparison: `Math.min(1, Math.max(0, NaN))` is still NaN, and an
35
+ // unclamped NaN leaves `t >= 1` permanently false, so playback could never reach
36
+ // `done` and every later cue test would silently fail.
37
+ const clampT = (t) => {
38
+ const n = Number(t);
39
+ return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n));
40
+ };
41
+
16
42
  // Normalize one animations-map entry to the canonical shape every consumer
17
43
  // (playback, transport UI, lint, CLI) works against: a step list (a bare
18
44
  // `tracks` form becomes one anonymous step), normalized step starts, and the
@@ -39,10 +65,18 @@ export function normalizeAnimation(name, spec) {
39
65
  if (typeof spec.camera === "string") cues = [{ t: 0, view: spec.camera }];
40
66
  else if (Array.isArray(spec.camera)) cues = spec.camera.map(([t, view]) => ({ t, view }));
41
67
  else cues = steps.flatMap((s, i) => (s.camera ? [{ t: stepStarts[i], view: s.camera }] : []));
42
- const trackedKeys = [...new Set(steps.flatMap((s) => Object.keys(s.tracks)))];
68
+ const trackedKeys = [...new Set(steps.flatMap((s) =>
69
+ Object.entries(s.tracks).filter(([, kf]) => usableKeyframes(kf)).map(([key]) => key)))];
43
70
  return {
44
71
  name, label: spec.label ?? name, description: spec.description ?? null,
45
- loop: !!spec.loop, autoplay: !!spec.autoplay, steps, stepStarts, totalDuration, cues, trackedKeys,
72
+ // Fail CLOSED on both flags: only a literal `true` turns them on. Coercing
73
+ // with `!!` reads `loop: "false"` as "loop forever", which is the worst
74
+ // available reading of that typo, and nothing downstream would catch it —
75
+ // lint reports non-booleans, but a part can mount in a browser without ever
76
+ // having been linted. An invalid flag therefore does the quiet thing here and
77
+ // is reported there.
78
+ loop: spec.loop === true, autoplay: spec.autoplay === true,
79
+ steps, stepStarts, totalDuration, cues, trackedKeys,
46
80
  };
47
81
  }
48
82
 
@@ -52,7 +86,7 @@ export function normalizeAnimations(part) {
52
86
 
53
87
  // Step containing t. Boundaries belong to the LATER step, and t clamps to [0,1].
54
88
  export function stepIndexAt(anim, t) {
55
- const tc = Math.min(1, Math.max(0, t));
89
+ const tc = clampT(t);
56
90
  let idx = 0;
57
91
  for (let i = 0; i < anim.stepStarts.length; i++) if (tc >= anim.stepStarts[i]) idx = i;
58
92
  return idx;
@@ -71,7 +105,7 @@ function segmentsFor(anim, key) {
71
105
  const out = [];
72
106
  anim.steps.forEach((step, i) => {
73
107
  const kf = step.tracks[key];
74
- if (!kf) return;
108
+ if (!usableKeyframes(kf)) return;
75
109
  const start = anim.stepStarts[i];
76
110
  const end = i + 1 < anim.steps.length ? anim.stepStarts[i + 1] : 1;
77
111
  out.push({ start, end, keyframes: kf, easing: step.easing });
@@ -94,12 +128,16 @@ function interpKeyframes(kf, u) {
94
128
 
95
129
  function evaluateTrack(anim, key, t) {
96
130
  const segs = segmentsFor(anim, key);
131
+ // trackedKeys and segmentsFor share usableKeyframes, so a tracked key always
132
+ // has a segment. Guard anyway: this runs inside the render loop, where a throw
133
+ // costs the whole viewer, not just the frame.
134
+ if (!segs.length) return undefined;
97
135
  let prev = null;
98
136
  for (const seg of segs) {
99
137
  if (t < seg.start) break;
100
138
  if (t <= seg.end) {
101
139
  const span = seg.end - seg.start || 1;
102
- const local = (EASINGS[seg.easing] ?? EASINGS[DEFAULT_EASING])((t - seg.start) / span);
140
+ const local = easingFor(seg.easing)((t - seg.start) / span);
103
141
  return interpKeyframes(seg.keyframes, local);
104
142
  }
105
143
  prev = seg;
@@ -112,7 +150,7 @@ function evaluateTrack(anim, key, t) {
112
150
  // Evaluate the whole animation at normalized position t ∈ [0,1] (over the
113
151
  // TOTAL duration — the same t the scrubber, seek(t), and the CLI's --at use).
114
152
  export function evaluate(anim, t) {
115
- const tc = Math.min(1, Math.max(0, t));
153
+ const tc = clampT(t);
116
154
  const values = {};
117
155
  for (const key of anim.trackedKeys) values[key] = evaluateTrack(anim, key, tc);
118
156
  return { stepIndex: stepIndexAt(anim, tc), values };
@@ -131,6 +169,7 @@ export function createPlayback(anim) {
131
169
  let t = 0;
132
170
  let armed = true; // user orbit disarms cues until reset/replay
133
171
  let firedCueT = -1; // cues with t <= firedCueT already fired this run
172
+ let pendingCueT = null; // cue handed to an in-flight intro tween, not yet settled
134
173
  let stopAt = null; // stepNext/playStep pause playback on reaching this t
135
174
 
136
175
  const snapshot = (cue = null) => ({ t, status, ...evaluate(anim, t), cue });
@@ -144,7 +183,11 @@ export function createPlayback(anim) {
144
183
 
145
184
  function begin() {
146
185
  const cue = governingCue();
147
- if (cue) { firedCueT = Math.max(firedCueT, cue.t); status = "intro"; }
186
+ // The cue is NOT counted as fired yet — only introDone() retires it. Pausing
187
+ // mid-intro cancels the tween and drops its completion callback, so a cue
188
+ // retired here would never be re-issued on resume and the camera would stay
189
+ // stranded wherever the cancelled sweep left it.
190
+ if (cue) { pendingCueT = cue.t; status = "intro"; }
148
191
  else status = "playing";
149
192
  return snapshot(cue);
150
193
  }
@@ -157,17 +200,23 @@ export function createPlayback(anim) {
157
200
  }
158
201
  function pause() {
159
202
  if (status === "playing" || status === "intro") status = "paused";
203
+ pendingCueT = null; // an unsettled intro cue is abandoned, so resume re-issues it
160
204
  return snapshot();
161
205
  }
162
206
  function introDone() {
163
- if (status === "intro") status = "playing";
207
+ if (status === "intro") {
208
+ if (pendingCueT != null) firedCueT = Math.max(firedCueT, pendingCueT);
209
+ status = "playing";
210
+ }
211
+ pendingCueT = null;
164
212
  return snapshot();
165
213
  }
166
214
  function seek(v) {
167
- t = Math.min(1, Math.max(0, v));
215
+ t = clampT(v);
168
216
  status = "paused";
169
217
  stopAt = null;
170
218
  firedCueT = -1; // a later play() re-honors the cue governing the new position
219
+ pendingCueT = null;
171
220
  return snapshot();
172
221
  }
173
222
  function playStep(i) {
@@ -175,6 +224,7 @@ export function createPlayback(anim) {
175
224
  t = anim.stepStarts[idx];
176
225
  stopAt = idx + 1 < anim.steps.length ? anim.stepStarts[idx + 1] : 1;
177
226
  firedCueT = -1;
227
+ pendingCueT = null;
178
228
  return begin();
179
229
  }
180
230
  function stepNext() {
@@ -185,7 +235,7 @@ export function createPlayback(anim) {
185
235
  return playStep(Math.max(0, stepIndexAt(anim, t) - 1));
186
236
  }
187
237
  function reset() {
188
- t = 0; status = "idle"; stopAt = null; firedCueT = -1; armed = true;
238
+ t = 0; status = "idle"; stopAt = null; firedCueT = -1; pendingCueT = null; armed = true;
189
239
  return snapshot();
190
240
  }
191
241
  function disarmCues() { armed = false; }
@@ -194,10 +244,14 @@ export function createPlayback(anim) {
194
244
  function tick(dt) {
195
245
  if (status !== "playing" || !(dt > 0)) return null;
196
246
  t += dt / anim.totalDuration;
197
- if (anim.loop) {
198
- if (t >= 1) t -= Math.floor(t);
199
- } else if (stopAt != null && t >= stopAt) {
247
+ // A pending step boundary outranks looping. Lint rejects loop on a stepped
248
+ // animation, so the two rarely coexist — but when they do, an explicit
249
+ // "play this step" must still stop where it was told to, rather than being
250
+ // swallowed by the wrap and running forever.
251
+ if (stopAt != null && t >= stopAt) {
200
252
  t = stopAt; stopAt = null; status = "paused";
253
+ } else if (anim.loop) {
254
+ if (t >= 1) t -= Math.floor(t);
201
255
  } else if (t >= 1) {
202
256
  t = 1; status = "done";
203
257
  }
@@ -0,0 +1,59 @@
1
+ // Correlated one-shot geometry builds for captureView — a private channel that
2
+ // does NOT go through the regen loop. Same shape as export-controller's pending
3
+ // Map (export-controller.js): allocate a jobId, resolve when the matching
4
+ // reply arrives. Pure — no DOM, no worker; `send` is injected.
5
+ export function createCaptureBuild({ send }) {
6
+ let nextId = 1;
7
+ let disposed = false;
8
+ const pending = new Map(); // jobId -> resolve
9
+
10
+ function request({ subparts, view, params, backend }) {
11
+ // After teardown the workers are gone, so a fresh send would post to a terminated
12
+ // worker (a silent no-op) and its promise would hang forever. Resolve null instead
13
+ // — captureView's documented "disposed runtime resolves null" contract.
14
+ if (disposed) return Promise.resolve(null);
15
+ // String-namespaced ("cap-N") so a capture jobId can never collide with
16
+ // export-controller's numeric jobIds — both share the same worker message
17
+ // space, and exportCtl.handleMessage does a raw pending.get(m.jobId) before
18
+ // checking type, so a colliding id could otherwise settle the wrong promise.
19
+ const jobId = `cap-${nextId++}`;
20
+ return new Promise((resolve) => {
21
+ pending.set(jobId, resolve);
22
+ // cache:true so the worker reuses its per-sub-part geometry memo (the
23
+ // expensive CSG); only the per-view place() + meshing re-run.
24
+ send({ type: "capture-generate", jobId, subparts, view, params, cache: true }, backend);
25
+ });
26
+ }
27
+
28
+ // Returns true iff this message was a reply this controller owns (so the
29
+ // caller — mount.js's onWorkerMessage — can skip it entirely). Keyed on
30
+ // membership in `pending` first: the namespaced jobId guarantees another
31
+ // channel's message never matches, so a hit here is always ours. A failed
32
+ // build (the worker's shared catch posts a generic error/needs-occt, jobId
33
+ // intact) resolves to null rather than leaving the caller hanging forever —
34
+ // captureView treats null as "capture failed, skip".
35
+ function handleMessage(data) {
36
+ const jobId = data?.jobId;
37
+ if (jobId == null || !pending.has(jobId)) return false;
38
+ if (data.type === "capture-meshes") {
39
+ pending.get(jobId)(data.meshes);
40
+ } else if (data.type === "error" || data.type === "needs-occt") {
41
+ pending.get(jobId)(null);
42
+ } else {
43
+ return false;
44
+ }
45
+ pending.delete(jobId);
46
+ return true;
47
+ }
48
+
49
+ // Teardown / worker death: settle every in-flight request to null instead
50
+ // of leaving its promise permanently pending (a caller awaiting captureView
51
+ // across a viewer dispose must still get an answer, even a negative one).
52
+ function dispose() {
53
+ disposed = true;
54
+ for (const resolve of pending.values()) resolve(null);
55
+ pending.clear();
56
+ }
57
+
58
+ return { request, handleMessage, dispose };
59
+ }
@@ -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
@@ -427,7 +427,8 @@ export function createViewer(container, part) {
427
427
  // no error, live view unaffected, wrong only in the capture.
428
428
  const RT_OPTIONS = { samples: 4, stencilBuffer: true };
429
429
  function renderOffscreen({ position, up, target },
430
- { width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {}) {
430
+ { width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {},
431
+ renderScene = scene) {
431
432
  const cachedSize = width === _rtSize && height === _rtSize;
432
433
  const rt = cachedSize
433
434
  ? (_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, RT_OPTIONS))
@@ -451,7 +452,7 @@ export function createViewer(container, part) {
451
452
  scene.add(capKey, capKey.target, capFill, capFill.target);
452
453
  try {
453
454
  renderer.setRenderTarget(rt);
454
- renderer.render(scene, cam);
455
+ renderer.render(renderScene, cam);
455
456
  // render() resolves the multisample renderbuffer into the target texture, so this
456
457
  // reads antialiased pixels.
457
458
  renderer.readRenderTargetPixels(rt, 0, 0, width, height, buf);
@@ -510,6 +511,76 @@ export function createViewer(container, part) {
510
511
  });
511
512
  }
512
513
 
514
+ // Offscreen render of an arbitrary mesh set (a non-active view), for thumbnails.
515
+ // Assembles a THROWAWAY scene mirroring the live pivot convention, frames it from a
516
+ // canonical angle, renders through the parameterized renderOffscreen, and disposes
517
+ // everything. Never touches the live scene, camera, subMesh, or subCache. `payloads`
518
+ // is the worker's [{name, positions, normals, indices, …}] array — placement is
519
+ // already baked into shared-frame coords, so meshes are NOT recentred.
520
+ function renderMeshPayloads(payloads, { angle = "iso", size = 640, quality = 0.8 } = {}) {
521
+ if (disposed) return null; // same guard as captureCurrent/captureCanonicalViews — never touch a torn-down renderer
522
+ const tmpScene = new THREE.Scene();
523
+ const tmpPivot = new THREE.Group();
524
+ tmpPivot.rotation.x = -Math.PI / 2; // model Z (CAD up) -> vertical, same as live pivot
525
+ tmpScene.add(tmpPivot);
526
+
527
+ const built = [];
528
+ for (const payload of payloads) {
529
+ const geo = buildGeometry(payload); // shared-frame coords, NOT recentred
530
+ const mesh = new THREE.Mesh(geo, materialFor(payload.name));
531
+ tmpPivot.add(mesh);
532
+ built.push(mesh);
533
+ }
534
+
535
+ // Frame in WORLD space, AFTER the pivot rotation. The meshes are built in model
536
+ // coords but rendered rotated by tmpPivot, so a model-space bbox centre would aim
537
+ // the camera at the wrong point — an off-origin part would render off-centre or blank.
538
+ tmpPivot.updateMatrixWorld(true);
539
+ const box = new THREE.Box3().setFromObject(tmpPivot);
540
+ const center = box.getCenter(new THREE.Vector3()).toArray();
541
+ const radius = box.getSize(new THREE.Vector3()).length() / 2 || 1;
542
+ const pose = cameraPoseForView(angle, { center, radius });
543
+
544
+ // Light the throwaway scene ourselves: renderOffscreen's own key/fill (and the
545
+ // persistent hemisphere) live in the LIVE scene, which is never rendered here — so
546
+ // without our own ambient + camera-relative key/fill it comes back near-black.
547
+ const hemi = createHemisphereLight();
548
+ const capLights = createCaptureLights();
549
+ const poses = captureLightPoses(pose);
550
+ capLights.key.position.set(poses.key[0], poses.key[1], poses.key[2]);
551
+ capLights.fill.position.set(poses.fill[0], poses.fill[1], poses.fill[2]);
552
+ for (const light of [capLights.key, capLights.fill]) {
553
+ light.target.position.set(pose.target[0], pose.target[1], pose.target[2]);
554
+ }
555
+ tmpScene.add(hemi, capLights.key, capLights.key.target, capLights.fill, capLights.fill.target);
556
+
557
+ // Feature-edge lines, so the thumbnail carries the same hole/seam/chamfer outlines the
558
+ // live viewer shows. A dedicated LineMaterial at the render resolution (the live one is
559
+ // sized to the on-screen canvas); added after framing so it can't perturb the bbox.
560
+ const lineMat = new LineMaterial({ color: THEME.dark.line, linewidth: 1.0 });
561
+ lineMat.resolution.set(size, size);
562
+ for (const mesh of built) {
563
+ const edges = mesh.geometry.userData.edges;
564
+ if (edges) tmpPivot.add(new LineSegments2(edges, lineMat));
565
+ }
566
+
567
+ try {
568
+ // fov matches the live camera (and captureViews/captureCurrent) — cameraPoseForView's
569
+ // distance is tuned to it, so a narrower fov would crop long, thin parts.
570
+ return renderOffscreen(pose, { width: size, height: size, fov: camera.fov, quality }, tmpScene);
571
+ } finally {
572
+ for (const mesh of built) {
573
+ mesh.geometry.userData.edges?.dispose();
574
+ mesh.geometry.dispose();
575
+ if (mesh.material !== material) mesh.material.dispose(); // clone only — never the shared singleton
576
+ }
577
+ lineMat.dispose();
578
+ hemi.dispose?.();
579
+ capLights.key.dispose?.();
580
+ capLights.fill.dispose?.();
581
+ }
582
+ }
583
+
513
584
  // --- render loop ----------------------------------------------------------
514
585
  // The tween is applied after controls.update() so the cue wins the frame, and
515
586
  // the frame listeners run before render so a playback frame draws its own pose.
@@ -523,7 +594,13 @@ export function createViewer(container, part) {
523
594
  camera.position.fromArray(tw.position);
524
595
  controls.target.fromArray(tw.target);
525
596
  }
526
- for (const cb of [...frameListeners]) cb(dt);
597
+ // Per-listener guard, because three re-arms requestAnimationFrame only AFTER
598
+ // this callback returns (WebGLAnimation.onAnimationFrame): a listener that
599
+ // throws would stop the rAF chain outright and freeze the viewer for good, not
600
+ // just skip a frame. Containment belongs here rather than in every subscriber.
601
+ for (const cb of [...frameListeners]) {
602
+ try { cb(dt); } catch (e) { console.warn("partforge: frame listener failed", e); }
603
+ }
527
604
  if (cutaway.isEnabled) cutaway.updateForCamera();
528
605
  renderer.render(scene, camera);
529
606
  cutaway.renderOverlay(renderer, camera);
@@ -662,6 +739,7 @@ export function createViewer(container, part) {
662
739
  frame,
663
740
  captureCanonicalViews,
664
741
  captureCurrent,
742
+ renderMeshPayloads,
665
743
  onFrame,
666
744
  tweenCameraTo,
667
745
  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 {
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
package/types/part.d.ts CHANGED
@@ -297,11 +297,18 @@ export type Easing = "linear" | "ease-in" | "ease-out" | "ease-in-out";
297
297
  export type Keyframes = Array<[number, number]>;
298
298
 
299
299
  /**
300
- * A camera cue angle: one of the seven canonical angles (`CanonicalView` in the
301
- * app entry `"iso" | "front" | "back" | "top" | "bottom" | "left" | "right"`).
302
- * Cues fire during play only; scrubbing never moves the camera.
300
+ * The seven angles the viewer can frame a part from. Defined here rather than in
301
+ * the app entry so `CameraCue` can be the real union without an import cycle
302
+ * the app entry re-exports it under its own name.
303
303
  */
304
- export type CameraCue = string;
304
+ export type CanonicalView = "iso" | "front" | "back" | "left" | "right" | "top" | "bottom";
305
+
306
+ /**
307
+ * A camera cue angle. `partforge lint` rejects anything outside the canonical
308
+ * seven (`animation-camera-invalid`), so the type says so too. Cues fire during
309
+ * play only; scrubbing never moves the camera.
310
+ */
311
+ export type CameraCue = CanonicalView;
305
312
 
306
313
  /** One step of a multi-step animation. Steps play in order; prev/next navigate them. */
307
314
  export interface AnimationStep {
@@ -310,31 +317,31 @@ export interface AnimationStep {
310
317
  /** Seconds. Step durations are relative — they set each step's share of the timeline. */
311
318
  duration: number;
312
319
  easing?: Easing;
313
- /** Param key -> keyframes. A param tracked nowhere keeps its current value. */
314
- tracks: Record<string, Keyframes>;
320
+ /**
321
+ * Param key -> keyframes. A param tracked nowhere keeps its current value.
322
+ *
323
+ * Optional so a step can move only the camera — an establishing shot that
324
+ * holds the pose while the view swings round. `partforge lint` still requires
325
+ * that at least one step in the animation carries tracks, which is a
326
+ * whole-animation rule the type system can't express per step.
327
+ */
328
+ tracks?: Record<string, Keyframes>;
315
329
  /** Swing the camera to this angle when the step begins. */
316
330
  camera?: CameraCue;
317
331
  }
318
332
 
319
333
  /**
320
- * One named animation: pure keyframe data over EXISTING params. Declare either
321
- * `tracks` (one anonymous step) or `steps`, never both. See
322
- * docs/AUTHORING-PARTS.md "Animations".
334
+ * The fields both animation forms share. Exported so a host can extend it —
335
+ * `AnimationSpec` itself is a union and cannot be `extends`-ed.
323
336
  */
324
- export interface AnimationSpec {
337
+ export interface AnimationSpecCommon {
325
338
  /** Shown in the transport bar's picker. Defaults to the animation's key. */
326
339
  label?: string;
327
340
  /** CommonMark, shown behind the ⓘ glyph. */
328
341
  description?: string;
329
- /** Seconds. Required in the single-step (`tracks`) form. */
330
- duration?: number;
331
342
  easing?: Easing;
332
343
  /** Wrap continuously. Single-step animations only. */
333
344
  loop?: boolean;
334
- /** The single-step form: param key -> keyframes. */
335
- tracks?: Record<string, Keyframes>;
336
- /** The multi-step form. */
337
- steps?: AnimationStep[];
338
345
  /**
339
346
  * One mechanism per animation: an angle (an intro cue at t=0), a
340
347
  * `[[t, angle], …]` cue list, or per-step `camera` names.
@@ -350,6 +357,27 @@ export interface AnimationSpec {
350
357
  autoplay?: boolean;
351
358
  }
352
359
 
360
+ /**
361
+ * An animation is EITHER single-phase (`tracks` + `duration`) OR stepped
362
+ * (`steps`) — never both, never neither. `partforge lint` enforces that
363
+ * (`animation-tracks-or-steps`), and the union says the same thing, so a block
364
+ * carrying both is rejected before it ever reaches lint.
365
+ */
366
+ export type AnimationSpec =
367
+ | (AnimationSpecCommon & {
368
+ /** Seconds — the whole animation's duration in the single-phase form. */
369
+ duration: number;
370
+ /** Param key -> keyframes. */
371
+ tracks: Record<string, Keyframes>;
372
+ steps?: never;
373
+ })
374
+ | (AnimationSpecCommon & {
375
+ /** The multi-step form; each step carries its own relative `duration`. */
376
+ steps: AnimationStep[];
377
+ tracks?: never;
378
+ duration?: never;
379
+ });
380
+
353
381
  // --- the part itself --------------------------------------------------------
354
382
 
355
383
  /**