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.
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
@@ -227,7 +234,7 @@ future contract v2 — but are not shown here; see `docs/KERNEL-CONTRACT.md`
227
234
  | `k.box({ size, center? })` · `k.box({ min, max })` | `{size:[x,y,z]}` = centered X/Y, base at z=0 (`center:true` also centers Z); `{min,max}` = explicit `[x,y,z]` corners |
228
235
  | `k.prism({ points, h, twist?, scaleTop? })` | extrude a 2-D polygon (or an **arc profile** from `roundedProfile`) from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
229
236
  | `k.extrude({ profile, h, twist?, scaleTop? })` | extrude a **polygon-with-holes** region from z=0 in one op — `profile` is `{ outer, holes? }` where each contour is a points array **or an arc profile** (`roundedProfile`, for true STEP fillets), or a bare points array / arc profile for outer-only; same `twist`/`scaleTop` as `prism` (both backends) |
230
- | `k.loft({ rings, ruled?, closed? })` | stack polygon cross-sections into a solid — ruled walls between consecutive rings, capped ends (both backends; `closed:true` capless loops are Manifold-only). `ruled:false` (smooth C2 blend) is honoured only by OCCT/STEP export; the Manifold preview always shows faceted straight walls |
237
+ | `k.loft({ rings, ruled?, closed?, shading? })` | stack polygon cross-sections into a solid — ruled walls between consecutive rings, capped ends (both backends; `closed:true` capless loops are Manifold-only). `ruled:false` (smooth C2 blend) is honoured only by OCCT/STEP export; the Manifold preview always shows faceted straight walls. `shading?: "smooth" \| "faceted"` overrides facet/smooth shading inference (default: <32-side rings shade as flat facets, drawing no same-surface lines at all — not even their own cap rims — though cut seams against other solids still draw; ≥32 sides shade smooth) |
231
238
  | `k.sweep({ profile, path, cornerRadius?, closed?, ruled?, smooth? })` | sweep a fixed 2-D profile along a 3-D polyline path — sharp mitered corners (or `cornerRadius` fillets), capped ends (both backends). `closed:true` capless loops and `smooth:true` (OCCT-native swept B-rep, STEP-exact / preview-faceted) are backend-specific, like loft's `closed`/`ruled:false`. `closed:true` loops must be **planar** — RMF frame-transport holonomy can seam-twist a non-planar closed loop where the last station rejoins the first, so only planar closed loops are supported/tested |
232
239
  | `k.sphere({ r\|d })` | sphere centred at the origin; bare `k.sphere(r)` also stays valid |
233
240
  | `k.roundedBox({ size, center?, round })` | box with rounded edges — `round` = number (all edges) or `{ side?, top?, bottom? }` (vertical edges / rims); stays on Manifold (no OCCT routing, unlike `fillet`); `side` must be 0 or ≥ the rim radii (between clamps with a warning); with `side > 0`, `top + bottom` must be strictly `< h` |
@@ -391,6 +398,10 @@ if (p.drain > 0) s = s.cut(k.cylinder({ r: d.drainR, h: p.floor + 4 }).at([0, 0,
391
398
  a cutting tool's label lands on the faces it leaves behind (the hole's wall).
392
399
  - Label **after** shaping compound tools (e.g. after an `intersect` clip) and
393
400
  either before or after transforms — labels ride through `at`/`rotate`/etc.
401
+ Labeling a compound collapses it to ONE shading surface — the majority
402
+ policy of its registered surfaces (by triangle count) applies to the whole
403
+ solid, so a faceted policy also suppresses line-drawing on the compound's
404
+ internal seams.
394
405
  - **Same label merges; distinct siblings need distinct names.** The same label on
395
406
  several solids merges into one feature — label a ring of four bolt holes
396
407
  `"Mounting holes"` and they hover/highlight as one. Conversely, when two similar
@@ -484,6 +495,12 @@ choosing a preset updates both numeric and text fields.
484
495
  Every `key` used must exist in `defaults`. `src/parts/demo.js` is the worked example for
485
496
  everything below.
486
497
 
498
+ A feature's `on` is **required and must be greater than 0** — it is the real value the
499
+ parameter takes when the box is ticked (a diameter, a count), and the panel reads
500
+ `> 0` as "enabled", so there is nothing sensible to fall back to. `partforge lint`
501
+ reports a missing or non-positive one as `features-requires-on`. A `toggles` entry is
502
+ the exception: its `on` is just a flag and defaults to 1.
503
+
487
504
  **Standalone toggles** (a plain on/off checkbox, no accompanying sliders): add a
488
505
  `toggles` array to a preset section — shown below the preset picker, outside the
489
506
  Advanced fold, so it stays visible:
@@ -791,6 +808,27 @@ Copy `demo.html` and change the title, the panel heading, and the `<script src>`
791
808
  workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
792
809
  `"occt"` for STEP — handled for you).
793
810
 
811
+ **View control (the mount handle).** For an embedder driving the view tabs from its own UI
812
+ instead of (or in addition to) the built-in `#part` bar:
813
+
814
+ - `runtime.getView() → string` — the active view name; never null once the runtime is ready
815
+ (mount resolves a default before first build — see "Which view the viewer opens on" above).
816
+ - `runtime.setView(name) → boolean` — switch tabs programmatically, the same path as clicking
817
+ a tab. Returns `false` (and leaves the active tab untouched) for a name the part doesn't
818
+ declare in `views`; `true` otherwise, including when `name` is already active.
819
+ - `await runtime.captureView(viewName?, opts?) → Promise<string | null>` — a JPEG data URL of
820
+ `viewName` rendered offscreen (falling back to the resolved default view — see
821
+ `resolveDefaultView` / `default-view.js` — when `viewName` is omitted or names a view the
822
+ part doesn't declare). Never disturbs the active tab, the live camera, or the on-screen
823
+ scene; `opts` forwards to the underlying render (size, quality, angle). Resolves `null` on
824
+ failure rather than throwing (a build error, a part with no sub-parts in that view, a
825
+ disposed runtime).
826
+
827
+ Pass `onViewChange(name)` to `mount()` to be told the active view: it fires once
828
+ synchronously during mount with the initial resolved view (before `runtime.ready` settles),
829
+ then again on every subsequent change — a tab click or a `setView` call — always with the
830
+ new view name.
831
+
794
832
  **Headless export (the mount handle).** The `#download*` buttons above are the built-in,
795
833
  view-bound export UI. An embedder that wants its own export UI (e.g. a "pick which parts,
796
834
  pick a format" modal) can skip those buttons and drive export off the handle `mount()`
@@ -1083,9 +1121,10 @@ previously didn't; that's the fix working as intended, not a regression.
1083
1121
  `missing-views`, `part-view-unknown` (all errors); `view-unused`,
1084
1122
  `default-view-ambiguous` (warnings).
1085
1123
 
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).
1124
+ **Parameter schema** — `features-requires-sliders`, `features-requires-on`,
1125
+ `control-key-not-in-defaults`, `preset-key-not-in-defaults` (errors);
1126
+ `slider-range-excludes-default`, `unknown-control-field`, `duplicate-control-key`,
1127
+ `default-not-exposed` (warnings).
1089
1128
 
1090
1129
  **Kernel API**, found by executing `build()` against a geometry-free probe —
1091
1130
  `unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
@@ -1376,6 +1415,13 @@ everything else (so sweep-heavy parts, e.g. helical grooves, stay fast). Force i
1376
1415
  `meta.backend: "occt" | "manifold"` if you ever need to. Because an OCCT part is built
1377
1416
  entirely on OCCT, its fillets are exact in the STEP **and** present in the printed STL.
1378
1417
 
1418
+ **Shading intent.** The kernel decides what shades smooth and where edge lines
1419
+ draw — spheres, cylinders and fillets are smooth by construction; boolean cut
1420
+ seams always shade hard and draw a line; a loft's facets shade flat when its
1421
+ rings have fewer than 32 sides (`shading: "smooth"|"faceted"` on `k.loft`
1422
+ overrides the inference either way). If your part previews smooth but would
1423
+ print faceted — or the reverse — set the hint rather than changing facet counts.
1424
+
1379
1425
  > Trade-off: OCCT is much slower on heavy swept geometry (helical grooves), so don't reach for
1380
1426
  > `fillet`/`chamfer` on a sweep-heavy part — design those edges in, or keep the part on Manifold.
1381
1427
 
@@ -318,6 +318,25 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
318
318
  - **Cause:** A track drives a param that feeds real geometry (or a build the pose probe can't trust — a query op or function selector), so every frame is a worker rebuild instead of a pose repair.
319
319
  - **Fix:** Run `npx partforge lint <part>` — the `animation-track-rebuilds` note names the track. Restructure so the param only feeds rigid placement (`place()` or a trailing translate/rotate in `build`), or accept best-effort playback if geometry morphing is the intent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Animations".
320
320
 
321
+ ## phantom-edges-on-curved-surface
322
+
323
+ - **Symptom:** edge lines or hard-shaded patches appear scattered on a smooth
324
+ curved surface (a sphere, fillet, or blend) in the viewer or in `render` PNGs.
325
+ - **Cause:** the mesh reached the viewer without kernel `normals`/`edges`, so a
326
+ consumer fell back to dihedral-angle guessing on coarse preview tessellation.
327
+ - **Fix:** the backend's `toMesh` must return analytic normals and filtered
328
+ feature edges ([KERNEL-CONTRACT.md](KERNEL-CONTRACT.md) "Shading intent") —
329
+ fix the backend or payload plumbing; do not tune viewer angle thresholds.
330
+
331
+ ## faceted-loft-previews-smooth
332
+
333
+ - **Symptom:** an intentionally faceted loft (low-side-count rings) previews
334
+ smooth-shaded, but exports/prints show flat facets.
335
+ - **Cause:** the loft's shading policy resolved to smooth — a `shading:
336
+ "smooth"` hint, `ruled: false`, or rings with 32+ sides.
337
+ - **Fix:** pass `shading: "faceted"` to `k.loft` (or drop the smooth-implying
338
+ option) per [AUTHORING-PARTS.md](AUTHORING-PARTS.md) shading-intent note.
339
+
321
340
  # Hardware library
322
341
 
323
342
  Reserved for `hardware-*` patterns (issue #30). No entries yet.
@@ -258,7 +258,7 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
258
258
  | `boundingBox()` | `{min, max, center, size}`; `center`/`size` are derived by `addSugar` from the backend's `{min, max}`. |
259
259
  | `volume()` | Solid volume in mm³. |
260
260
  | `genus()` / `isEmpty()` | Optional (`SOLID_OPTIONAL_OPS`): mesh-topology queries — through-hole count / no-geometry test. The mesh backend provides them; OCCT has no cheap equivalent. |
261
- | `toMesh({quality?})` | Render mesh: `{positions, normals, indices?, triangles, edges?, featureIds?, features?}`. `indices` optional (a backend may emit soup or indexed); `normals` may be empty (`length 0`) to delegate creasing to the viewer; `edges` (feature-line segments) and the feature fields are optional metadata. |
261
+ | `toMesh({quality?})` | Render mesh: `{positions, normals, indices?, triangles, edges?, featureIds?, features?}`. `indices` optional (a backend may emit soup or indexed); `normals` and `edges` are authoritative shading intent from both backends see [Shading intent](#shading-intent-tomesh-normals-and-edges) below; `featureIds`/`features` are optional metadata. |
262
262
  | `toSTL({quality?})` | `Promise<ArrayBuffer>`, binary STL, outward CCW winding. Stored facet normals may be zero — slicers recompute them (the mesh backend happens to write them). |
263
263
  | `toIndexedMesh({quality?})` | `{positions, indices}` indexed mesh (3MF path); defaults to `"print"` like `toSTL`. Coincident vertices need NOT be welded — the 3MF writer welds, because that format reads topology from the indices rather than re-stitching soup by position the way an STL consumer does. |
264
264
  | `fillet(r)` · `fillet({r, edges?})` / `chamfer(d)` · `chamfer({d, edges?})` / `shell({t, open})` | B-rep class (core throws `KernelCapabilityError`). Scalar `fillet(3)`/`chamfer(1)` acts on all edges; the options form adds an `edges` selector. `shell` hollows inward, keeping outer dimensions; `open` (face selector) is required. |
@@ -267,6 +267,39 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
267
267
  speed and a backend may bake it at kernel creation (Manifold does). A part must never
268
268
  depend on triangle counts, segment counts, or normals being present.
269
269
 
270
+ ### Shading intent (toMesh normals and edges)
271
+
272
+ `toMesh` output is the authoritative statement of how a solid SHADES and which
273
+ edges are FEATURE edges — consumers (viewer, CLI renderer) must draw what they
274
+ are given and must not re-derive either from dihedral angles when the fields
275
+ are present:
276
+
277
+ - `normals` — per-vertex shading normals. Smooth within one surface, hard
278
+ across boolean-cut seams. OCCT ships analytic B-rep normals; Manifold ships
279
+ the policy-aware crease pass (`src/framework/geometry/creased-normals.js`).
280
+ - `edges` — flat feature-edge segment pairs (6 floats per segment). An EMPTY
281
+ array means "this solid has no feature edges"; it is not "unknown". OCCT
282
+ ships true B-rep edges with tangent edges (fillet blends, seam lines)
283
+ filtered out; Manifold ships policy-gated sharp/seam segments.
284
+
285
+ `loft` accepts `shading?: "smooth" | "faceted"` to override facet-vs-smooth
286
+ inference: by default, rings with fewer than 32 sides shade as intentional flat
287
+ facets with no same-surface edge lines, while rings with 32+ sides (and
288
+ `ruled: false` lofts) shade smooth. `shading: "smooth"` forces smooth shading;
289
+ `shading: "faceted"` forces facets; any other non-nullish value throws.
290
+ Thresholds live in `src/framework/geometry/shading-policy.js`.
291
+
292
+ Known limitation: the OCCT backend ignores `shading` — a loft forced to OCCT
293
+ via `meta.backend` draws its facet corner edges as B-rep feature lines. The
294
+ hint is honored on the Manifold path, which is where lofts preview by default.
295
+
296
+ `label()`ing a compound solid (one spanning more than one original surface)
297
+ collapses it to a single shading surface that inherits the majority policy of
298
+ its registered constituent surfaces, weighted by triangle count. A constituent
299
+ with no registered policy of its own (e.g. a plain boolean tool) still votes,
300
+ as SMOOTH — the policy it actually renders with — and an exact tie resolves to
301
+ the no-lines (faceted) policy.
302
+
270
303
  **Selectors** (`fillet`/`chamfer` `edges` selector, `shell` `open` face selector) are
271
304
  declarative objects, criteria AND-combined:
272
305
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.44.0",
3
+ "version": "0.46.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
+ }