partforge 0.160.0 → 0.162.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +40 -8
- package/package.json +1 -1
- package/src/framework/animation.js +5 -3
- package/src/framework/lint/rules-animations.js +23 -18
- package/src/framework/lint/rules-verify.js +20 -4
- package/src/framework/mount.js +4 -0
- package/src/framework/oracle/assert-dsl.js +45 -1
- package/src/framework/style-camera.js +14 -2
- package/src/framework/verify-metrics.js +1 -1
- package/src/framework/view-angles.js +111 -4
- package/src/framework/viewer.js +17 -1
- package/src/testing/render.js +14 -3
- package/types/index.d.ts +10 -1
- package/types/part.d.ts +20 -4
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -310,10 +310,38 @@ Rules (all lint-enforced):
|
|
|
310
310
|
left right`) or any view-cube orientation — an edge or corner named
|
|
311
311
|
vertical, then depth, then side (`top-front-left`, `top-back-right`,
|
|
312
312
|
`front-left`, `bottom-back-right`, …; `top-front-right` is `iso`). One
|
|
313
|
-
mechanism per animation: an animation-level
|
|
314
|
-
animation-level `[[t, angle], …]` list, or per-step
|
|
313
|
+
mechanism per animation: an animation-level angle (an intro cue at t=0), an
|
|
314
|
+
animation-level `[[t, angle], …]` list, or per-step angles.
|
|
315
315
|
Cues fire during play only — scrubbing never moves the camera — and a user
|
|
316
316
|
orbit disarms the remaining cues for that run.
|
|
317
|
+
- **Any angle in between is an orbit**: wherever a cue takes a name it also
|
|
318
|
+
takes `{ azimuth, elevation, distance }`, in degrees and in the part's own
|
|
319
|
+
frame. `azimuth` 0 looks from the front (−Y) and turns toward the right (+X)
|
|
320
|
+
at 90, the back at 180 and the left at −90; `elevation` is degrees above the
|
|
321
|
+
horizontal, −90..90. `iso` is `{ azimuth: 45, elevation: 35.26 }` and
|
|
322
|
+
`front-right` is `{ azimuth: 45, elevation: 0 }`, so "about half as far down
|
|
323
|
+
as level from iso" is `{ azimuth: 45, elevation: 17 }`. `distance` is
|
|
324
|
+
optional: a multiple of the distance a named angle frames the part from (1,
|
|
325
|
+
the default, is the same framing; 1.5 pulls back; 0.6 moves in), 0.1..10, so
|
|
326
|
+
it keeps meaning the same thing when params change the part's size. The
|
|
327
|
+
camera always aims at the part's centre — a cue orbits and zooms, it never
|
|
328
|
+
pans. Lint (`animation-camera-invalid`) names what is wrong with a malformed
|
|
329
|
+
orbit: a missing or non-numeric angle, an elevation past ±90, a distance out
|
|
330
|
+
of range, or an unknown key.
|
|
331
|
+
|
|
332
|
+
```js
|
|
333
|
+
steps: [
|
|
334
|
+
{ label: "Assembled", camera: "iso", duration: 0.8 },
|
|
335
|
+
{ label: "Expand", camera: { azimuth: 45, elevation: 17, distance: 1.3 }, duration: 2.5,
|
|
336
|
+
tracks: { explode: [[0, 0], [1, 1]] } },
|
|
337
|
+
]
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
A host can turn the view the user has on screen into one of these with
|
|
341
|
+
`runtime.getCameraCue()` — `{ cue, panned }`, where `cue` is the orbit that
|
|
342
|
+
reproduces the current angle and distance and `panned` is how far the user's
|
|
343
|
+
orbit target sits from the part's centre (a fraction of the framing radius;
|
|
344
|
+
a pan is the one thing a cue cannot carry).
|
|
317
345
|
- **A camera move takes as long as its cue asks**: a per-step `camera` sweeps
|
|
318
346
|
across that step's whole `duration`, and a listed cue sweeps until the next
|
|
319
347
|
cue (or the end). The move that settles the camera BEFORE playback starts —
|
|
@@ -4347,12 +4375,16 @@ unverified.
|
|
|
4347
4375
|
|
|
4348
4376
|
**Assertion DSL:** a bare number means equality (`holes: 1`); `">=n"`, `"<=n"`, `">n"`,
|
|
4349
4377
|
`"<n"`, or a range `"a..b"`; an optional unit suffix `mm`/`cm`/`mm3`/`cm3`; and for
|
|
4350
|
-
`bbox`, `centerOfMass` and each side of `bounds`, a componentwise vector
|
|
4351
|
-
`"
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
with
|
|
4378
|
+
`bbox`, `centerOfMass` and each side of `bounds`, a componentwise vector — one-sided,
|
|
4379
|
+
`"<=[x,y,z]"` / `">=[x,y,z]"`, or two-sided with a range per axis,
|
|
4380
|
+
`"[28..32, 18..22, 9..11]"` (the bare comma form `"28..32,18..22,9..11"` is accepted
|
|
4381
|
+
too) — exactly 3 components, each a plain number in mm (or a range of them) or `*` to
|
|
4382
|
+
skip an axis. A bare `[40,40,10]` (meaning "exactly this size") is rejected, since a
|
|
4383
|
+
mesh's box shifts with tessellation and no tolerance would be honest for every part —
|
|
4384
|
+
pick `<=` for a limit, `>=` for a floor, or say what tolerance you mean with a range
|
|
4385
|
+
per axis (`"[39.5..40.5, 39.5..40.5, 9.8..10.2]"`; a zero-width `40..40` is an exact
|
|
4386
|
+
check with the parser's 1e-6 slack, and will fail on a chorded curve). `bounds` takes an
|
|
4387
|
+
object with `min` and/or `max`, each such a vector: `bounds: { min: ">=[0,0,0]", max:
|
|
4356
4388
|
"<=[220,220,250]" }`. The parser is strict — a malformed assertion fails loudly.
|
|
4357
4389
|
|
|
4358
4390
|
**A part whose typeface is a parameter needs band assertions, not points.** Glyph
|
package/package.json
CHANGED
|
@@ -69,12 +69,14 @@ export function normalizeAnimation(name, spec) {
|
|
|
69
69
|
// params move — ignores it and uses the driver's short fixed tween, since it
|
|
70
70
|
// gates playback and a long one would read as a stall.
|
|
71
71
|
let cues;
|
|
72
|
-
// A lone angle name
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
// A lone angle (a name, or an { azimuth, elevation } orbit) is only ever an
|
|
73
|
+
// intro, so it has no sweep of its own.
|
|
74
|
+
if (Array.isArray(spec.camera)) {
|
|
75
75
|
cues = spec.camera.map(([t, view], i, all) => ({
|
|
76
76
|
t, view, sweep: ((all[i + 1]?.[0] ?? 1) - t) * totalDuration,
|
|
77
77
|
}));
|
|
78
|
+
} else if (spec.camera != null) {
|
|
79
|
+
cues = [{ t: 0, view: spec.camera, sweep: null }];
|
|
78
80
|
} else {
|
|
79
81
|
cues = steps.flatMap((s, i) => (s.camera ? [{ t: stepStarts[i], view: s.camera, sweep: s.duration }] : []));
|
|
80
82
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// letting the author wonder why nothing plays.
|
|
13
13
|
import { err, note } from "./finding.js";
|
|
14
14
|
import { EASINGS } from "../animation.js";
|
|
15
|
-
import { CANONICAL_VIEWS,
|
|
15
|
+
import { CANONICAL_VIEWS, cameraOrbitProblem, isCameraCue } from "../view-angles.js";
|
|
16
16
|
import { inView } from "../sub-part-views.js";
|
|
17
17
|
import { probeSubPartPose } from "../pose-probe-core.js";
|
|
18
18
|
import { resolveDerivedAttributed } from "../derive.js";
|
|
@@ -374,9 +374,14 @@ export const ANIMATION_RULES = [
|
|
|
374
374
|
id: "animation-camera-invalid",
|
|
375
375
|
run: ({ part }) => {
|
|
376
376
|
const out = [];
|
|
377
|
-
const
|
|
378
|
-
// The seven canonical names plus the view cube's corners and edges.
|
|
379
|
-
const angles = `${CANONICAL_VIEWS.join(", ")},
|
|
377
|
+
const badCue = (v) => !isCameraCue(v);
|
|
378
|
+
// The seven canonical names plus the view cube's corners and edges, or a free-form orbit.
|
|
379
|
+
const angles = `${CANONICAL_VIEWS.join(", ")}, a view-cube orientation such as top-front-left or top-back-right, or an orbit { azimuth, elevation, distance? } in degrees`;
|
|
380
|
+
// What to say about one bad cue: an orbit-shaped value gets its own
|
|
381
|
+
// problem named; anything else is an unknown angle.
|
|
382
|
+
const describe = (v) => (v !== null && typeof v === "object" && !Array.isArray(v)
|
|
383
|
+
? `camera orbit ${cameraOrbitProblem(v)}`
|
|
384
|
+
: `camera ${JSON.stringify(v)} is not a camera angle`);
|
|
380
385
|
for (const { name, a, base } of animEntries(part)) {
|
|
381
386
|
const stepCameras = Array.isArray(a.steps)
|
|
382
387
|
? a.steps.map((s, i) => [s?.camera, i]).filter(([c]) => c !== undefined && c !== null)
|
|
@@ -384,13 +389,13 @@ export const ANIMATION_RULES = [
|
|
|
384
389
|
if (a.camera != null && stepCameras.length) {
|
|
385
390
|
out.push(err("animation-camera-invalid",
|
|
386
391
|
`animation "${name}" mixes an animation-level \`camera\` with per-step cameras`,
|
|
387
|
-
"One camera mechanism per animation: either the animation-level
|
|
392
|
+
"One camera mechanism per animation: either the animation-level angle/cue-list, or per-step angles — not both.",
|
|
388
393
|
`${base}.camera`));
|
|
389
394
|
}
|
|
390
395
|
for (const [cam, i] of stepCameras) {
|
|
391
|
-
if (
|
|
396
|
+
if (badCue(cam)) {
|
|
392
397
|
out.push(err("animation-camera-invalid",
|
|
393
|
-
`animation "${name}" step ${i}
|
|
398
|
+
`animation "${name}" step ${i} ${describe(cam)}`,
|
|
394
399
|
`Camera cues use ${angles}.`,
|
|
395
400
|
`${base}.steps[${i}].camera`));
|
|
396
401
|
}
|
|
@@ -398,17 +403,10 @@ export const ANIMATION_RULES = [
|
|
|
398
403
|
// An explicit `camera: null` is "no camera", which is how
|
|
399
404
|
// normalizeAnimation reads it — not a malformed value to report.
|
|
400
405
|
if (a.camera == null) continue;
|
|
401
|
-
if (
|
|
402
|
-
if (badName(a.camera)) {
|
|
403
|
-
out.push(err("animation-camera-invalid",
|
|
404
|
-
`animation "${name}" camera "${a.camera}" is not a camera angle`,
|
|
405
|
-
`Camera cues use ${angles}.`,
|
|
406
|
-
`${base}.camera`));
|
|
407
|
-
}
|
|
408
|
-
} else if (Array.isArray(a.camera)) {
|
|
406
|
+
if (Array.isArray(a.camera)) {
|
|
409
407
|
const cues = a.camera;
|
|
410
408
|
const wellFormed = cues.length > 0 && cues.every((c) =>
|
|
411
|
-
Array.isArray(c) && c.length === 2 && Number.isFinite(c[0]) && c[0] >= 0 && c[0] <= 1 && !
|
|
409
|
+
Array.isArray(c) && c.length === 2 && Number.isFinite(c[0]) && c[0] >= 0 && c[0] <= 1 && !badCue(c[1]));
|
|
412
410
|
const sorted = cues.every((c, i) => i === 0 || (Array.isArray(c) && Array.isArray(cues[i - 1]) && c[0] > cues[i - 1][0]));
|
|
413
411
|
if (!wellFormed || !sorted) {
|
|
414
412
|
out.push(err("animation-camera-invalid",
|
|
@@ -416,10 +414,17 @@ export const ANIMATION_RULES = [
|
|
|
416
414
|
`Cues are \`[[t, angle], …]\` with t strictly ascending in 0..1 and angles from: ${angles}.`,
|
|
417
415
|
`${base}.camera`));
|
|
418
416
|
}
|
|
417
|
+
} else if (typeof a.camera === "string" || typeof a.camera === "object") {
|
|
418
|
+
if (badCue(a.camera)) {
|
|
419
|
+
out.push(err("animation-camera-invalid",
|
|
420
|
+
`animation "${name}" ${describe(a.camera)}`,
|
|
421
|
+
`Camera cues use ${angles}.`,
|
|
422
|
+
`${base}.camera`));
|
|
423
|
+
}
|
|
419
424
|
} else {
|
|
420
425
|
out.push(err("animation-camera-invalid",
|
|
421
|
-
`animation "${name}" \`camera\` is neither an angle
|
|
422
|
-
"Use
|
|
426
|
+
`animation "${name}" \`camera\` is neither an angle nor a cue list`,
|
|
427
|
+
"Use an angle name, an { azimuth, elevation, distance? } orbit, or `[[t, angle], …]` cues.",
|
|
423
428
|
`${base}.camera`));
|
|
424
429
|
}
|
|
425
430
|
}
|
|
@@ -160,6 +160,8 @@ function checkHeld(held, parts, path) {
|
|
|
160
160
|
// `verify-bad-expr`, not `verify-bad-pair-check`: a malformed key is a pair-naming
|
|
161
161
|
// problem, but a malformed value is an assertion-DSL problem, same as any other
|
|
162
162
|
// metric's expectation.
|
|
163
|
+
const DSL_HINT = "Use the assertion DSL: a bare value for equality, a comparison like `>=3`, a range like `2..5`, or a componentwise vector like `<=[60,60,60]` or `[28..32,18..22,9..11]` (with `*` to skip an axis).";
|
|
164
|
+
|
|
163
165
|
function checkClearanceExprs(clearance, path) {
|
|
164
166
|
if (clearance === undefined || clearance === null || typeof clearance !== "object") return [];
|
|
165
167
|
const out = [];
|
|
@@ -168,7 +170,7 @@ function checkClearanceExprs(clearance, path) {
|
|
|
168
170
|
catch (e) {
|
|
169
171
|
out.push(err("verify-bad-expr",
|
|
170
172
|
`the expectation for _view.clearance["${key}"] is not a valid assertion: ${e?.message || String(e)}`,
|
|
171
|
-
|
|
173
|
+
DSL_HINT,
|
|
172
174
|
`${path}[${JSON.stringify(key)}]`));
|
|
173
175
|
}
|
|
174
176
|
}
|
|
@@ -218,7 +220,7 @@ function checkProcessSpec(spec, path, valid, seen, depth = 0) {
|
|
|
218
220
|
// One expectation's well-formedness against its registry entry: it parses, and it has the form the
|
|
219
221
|
// metric reads — a range for `wall`, a componentwise vector for bbox/centerOfMass/the
|
|
220
222
|
// sides of bounds. A bare vector gets the two legal spellings back, in its own numbers.
|
|
221
|
-
const
|
|
223
|
+
const VECTOR_OPS = new Set(["vle", "vge", "vrange"]);
|
|
222
224
|
const capitalize = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
|
223
225
|
|
|
224
226
|
function checkExpr(target, metric, spec, reg) {
|
|
@@ -246,10 +248,24 @@ function checkExpr(target, metric, spec, reg) {
|
|
|
246
248
|
`Write ${metric} as \`"<min>..<max>"\` with min <= max, in mm.`,
|
|
247
249
|
path)];
|
|
248
250
|
}
|
|
249
|
-
if (form === "vector" && parsed.op !== "vle" && parsed.op !== "vge") {
|
|
251
|
+
if (form === "vector" && parsed.op !== "vle" && parsed.op !== "vge" && parsed.op !== "vrange") {
|
|
250
252
|
return [err("verify-bad-expr",
|
|
251
253
|
`the expectation for ${target}.${metric} must be a componentwise vector, got ${describe(expr)} — ${metric} is an [x,y,z] reading`,
|
|
252
|
-
`Write ${metric} as \`"<=[x,y,z]"
|
|
254
|
+
`Write ${metric} as \`"<=[x,y,z]"\`, \`">=[x,y,z]"\` or \`"[a..b,c..d,e..f]"\` (componentwise; * skips an axis).`,
|
|
255
|
+
path)];
|
|
256
|
+
}
|
|
257
|
+
// A vector on a scalar metric (`volume: "<=[1,2,3]"`) is not a parse error, and
|
|
258
|
+
// the evaluator would index a number and pass it vacuously — so it is caught here.
|
|
259
|
+
if (form !== "vector" && VECTOR_OPS.has(parsed.op)) {
|
|
260
|
+
return [err("verify-bad-expr",
|
|
261
|
+
`the expectation for ${target}.${metric} must be a single value, got ${describe(expr)} — ${metric} is one number, not an [x,y,z] reading`,
|
|
262
|
+
DSL_HINT,
|
|
263
|
+
path)];
|
|
264
|
+
}
|
|
265
|
+
if (parsed.op === "vrange" && parsed.vec.some((c) => c !== null && c[0] > c[1])) {
|
|
266
|
+
return [err("verify-bad-expr",
|
|
267
|
+
`the expectation for ${target}.${metric} must be a range vector with min <= max on every axis, got "${expr}"`,
|
|
268
|
+
`Write each axis of ${metric} as \`<min>..<max>\` with min <= max, in mm (or \`*\` to skip it).`,
|
|
253
269
|
path)];
|
|
254
270
|
}
|
|
255
271
|
return [];
|
package/src/framework/mount.js
CHANGED
|
@@ -121,6 +121,10 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
121
121
|
// Read at teardown time, so it is the LIVE pose — unlike the camera the
|
|
122
122
|
// viewer persists for a page reload, which only records the end of a drag
|
|
123
123
|
// and so misses a view-cube click, Reframe, or an animation cue.
|
|
124
|
+
// The user's current view as an animation camera cue — `{ cue, panned }`,
|
|
125
|
+
// or null with nothing on screen — so a host can turn "the way I'm looking
|
|
126
|
+
// now" into a `camera` value an author (or an agent) writes into a part.
|
|
127
|
+
getCameraCue: () => viewer.getCameraCue?.() ?? null,
|
|
124
128
|
getViewerState: () => ({
|
|
125
129
|
camera: viewer.getCameraState(),
|
|
126
130
|
projection: viewer.getProjection?.() ?? "perspective",
|
|
@@ -25,7 +25,8 @@ const reVec = /^(>=|<=)\s*\[\s*(.+?)\s*\]$/;
|
|
|
25
25
|
// own numbers, which is a one-step fix. Returns that hint, or null when `expr` is
|
|
26
26
|
// not a bare vector. An exact vector is deliberately not offered: a mesh's box is
|
|
27
27
|
// tessellation-dependent (a curved face chords inside its true extent), so no
|
|
28
|
-
// tolerance would be honest for every part
|
|
28
|
+
// tolerance would be honest for every part — a writer who wants both sides
|
|
29
|
+
// declares the tolerance themselves with the per-axis range form below.
|
|
29
30
|
const reBareVec = /^(?:==?)?\s*\[(.*)\]$/;
|
|
30
31
|
const VEC_TAIL = "(componentwise; * skips an axis)";
|
|
31
32
|
|
|
@@ -36,6 +37,10 @@ export function bareVectorHint(expr) {
|
|
|
36
37
|
const m = expr.trim().match(reBareVec);
|
|
37
38
|
if (!m) return null;
|
|
38
39
|
comps = m[1].split(",").map((t) => t.trim());
|
|
40
|
+
// A component with `..` in it is a range-vector attempt (below), not a bare
|
|
41
|
+
// vector: its own parse error names the right fix, and the two-spelling hint
|
|
42
|
+
// here would send the writer back to a one-sided form.
|
|
43
|
+
if (comps.some((c) => c.includes(".."))) return null;
|
|
39
44
|
} else return null;
|
|
40
45
|
comps = comps.map((c) => c.replace(/\s*mm$/, ""));
|
|
41
46
|
const ok = comps.length === 3 && comps.every((c) => c === "*" || (c !== "" && Number.isFinite(Number(c))));
|
|
@@ -46,6 +51,33 @@ export function bareVectorHint(expr) {
|
|
|
46
51
|
|
|
47
52
|
const show = (expr) => (typeof expr === "string" ? `"${expr}"` : JSON.stringify(expr));
|
|
48
53
|
|
|
54
|
+
// The two-sided vector form: a RANGE per axis, `"[28..32, 18..22, 9..11]"`, with `*`
|
|
55
|
+
// skipping an axis. The brackets are optional because the bare comma form
|
|
56
|
+
// (`"28..32,18..22,9..11"`) is what a model produces when it reaches for this —
|
|
57
|
+
// three trials wrote it before the form existed and were told "unrecognized form".
|
|
58
|
+
// Components are plain numbers in mm, like the one-sided vectors: a unit suffix
|
|
59
|
+
// is refused rather than scaled. Returns null when `s` is not a range-vector
|
|
60
|
+
// attempt at all (no `..` inside a bracket pair or comma list), so the other
|
|
61
|
+
// forms get their turn; a malformed attempt throws its own error.
|
|
62
|
+
const reRangeComp = new RegExp(`^(${NUM})\\s*\\.\\.\\s*(${NUM})$`);
|
|
63
|
+
const RANGE_VEC_SHAPE = "exactly 3 components, each a range a..b in mm or *";
|
|
64
|
+
|
|
65
|
+
function rangeVector(s) {
|
|
66
|
+
const bracketed = s.match(/^\[(.*)\]$/);
|
|
67
|
+
const inner = bracketed ? bracketed[1] : s;
|
|
68
|
+
if (!inner.includes("..") || !inner.includes(",")) return null;
|
|
69
|
+
if (!bracketed && /[\[\]]/.test(s)) return null; // one bracket of a pair — not this form
|
|
70
|
+
const parts = inner.split(",").map((t) => t.trim());
|
|
71
|
+
if (parts.length !== 3) throw new Error(`assertion: a range vector takes ${RANGE_VEC_SHAPE}: "${s}"`);
|
|
72
|
+
const vec = parts.map((t) => {
|
|
73
|
+
if (t === "*") return null;
|
|
74
|
+
const m = t.match(reRangeComp);
|
|
75
|
+
if (!m) throw new Error(`assertion: a range vector takes ${RANGE_VEC_SHAPE}: "${s}"`);
|
|
76
|
+
return [toBase(m[1], undefined), toBase(m[2], undefined)];
|
|
77
|
+
});
|
|
78
|
+
return { op: "vrange", vec };
|
|
79
|
+
}
|
|
80
|
+
|
|
49
81
|
export function parseAssertion(expr) {
|
|
50
82
|
if (typeof expr === "number" || typeof expr === "boolean") return { op: "eq", value: expr };
|
|
51
83
|
const bare = bareVectorHint(expr);
|
|
@@ -53,6 +85,8 @@ export function parseAssertion(expr) {
|
|
|
53
85
|
if (typeof expr !== "string") throw new Error(`assertion: unsupported value ${JSON.stringify(expr)}`);
|
|
54
86
|
const s = expr.trim();
|
|
55
87
|
|
|
88
|
+
const vr = rangeVector(s);
|
|
89
|
+
if (vr) return vr;
|
|
56
90
|
const vec = s.match(reVec);
|
|
57
91
|
if (vec) {
|
|
58
92
|
const parts = vec[2].split(",").map((t) => t.trim());
|
|
@@ -76,6 +110,7 @@ export function parseAssertion(expr) {
|
|
|
76
110
|
const EPS = 1e-6;
|
|
77
111
|
const approxEq = (a, b) => Math.abs(a - b) <= EPS + EPS * Math.abs(b);
|
|
78
112
|
const fmtVec = (v) => "[" + v.map((x) => (x === null ? "*" : x)).join(",") + "]";
|
|
113
|
+
const fmtRangeVec = (v) => "[" + v.map((c) => (c === null ? "*" : `${c[0]}..${c[1]}`)).join(",") + "]";
|
|
79
114
|
|
|
80
115
|
export function evaluateAssertion(parsed, actual) {
|
|
81
116
|
switch (parsed.op) {
|
|
@@ -102,6 +137,15 @@ export function evaluateAssertion(parsed, actual) {
|
|
|
102
137
|
}
|
|
103
138
|
return { pass, message: `${fmtVec(actual)} ${ge ? ">=" : "<="} ${fmtVec(parsed.vec)}` };
|
|
104
139
|
}
|
|
140
|
+
case "vrange": {
|
|
141
|
+
let pass = true;
|
|
142
|
+
for (let i = 0; i < 3; i++) {
|
|
143
|
+
const band = parsed.vec[i];
|
|
144
|
+
if (band === null) continue;
|
|
145
|
+
if (actual[i] < band[0] - EPS || actual[i] > band[1] + EPS) pass = false;
|
|
146
|
+
}
|
|
147
|
+
return { pass, message: `${fmtVec(actual)} ${pass ? "in" : "out of"} ${fmtRangeVec(parsed.vec)}` };
|
|
148
|
+
}
|
|
105
149
|
default: throw new Error(`assertion: unknown op "${parsed.op}"`);
|
|
106
150
|
}
|
|
107
151
|
}
|
|
@@ -117,7 +117,19 @@ export function fitPoseToPoints(view, points, { fov, aspect = 1, fill }) {
|
|
|
117
117
|
|
|
118
118
|
// `points` (flat world xyz, optional) lets a "fit" style frame the geometry
|
|
119
119
|
// itself (fitPoseToPoints); without them it fits the box's eight corners.
|
|
120
|
-
export function stylePose(style, view, box,
|
|
120
|
+
export function stylePose(style, view, box, opts = {}) {
|
|
121
|
+
const out = framedPose(style, view, box, opts);
|
|
122
|
+
// An orbit cue's `distance` multiplies whatever framing the style chose — the
|
|
123
|
+
// canonical distance already carries it (cameraPoseForView), a fit does not.
|
|
124
|
+
const k = typeof view === "object" && view !== null ? view.distance ?? 1 : 1;
|
|
125
|
+
if (k === 1 || out.fitted === false) return strip(out);
|
|
126
|
+
const { position, target } = out.pose;
|
|
127
|
+
out.pose = { ...out.pose, position: [0, 1, 2].map((i) => target[i] + (position[i] - target[i]) * k) };
|
|
128
|
+
return strip(out);
|
|
129
|
+
}
|
|
130
|
+
const strip = ({ pose, fov, sceneBounds }) => ({ pose, fov, sceneBounds });
|
|
131
|
+
|
|
132
|
+
function framedPose(style, view, box, { aspect = 1, points } = {}) {
|
|
121
133
|
const fov = style.camera.fov;
|
|
122
134
|
const min = box?.min ?? [0, 0, 0], max = box?.max ?? [0, 0, 0];
|
|
123
135
|
const center = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2];
|
|
@@ -130,7 +142,7 @@ export function stylePose(style, view, box, { aspect = 1, points } = {}) {
|
|
|
130
142
|
|
|
131
143
|
if (style.camera.framing === "canonical" || diag === 0) {
|
|
132
144
|
const radius = Math.max(size[0], size[1], size[2]) / 2 || DEFAULT_RADIUS;
|
|
133
|
-
return { pose: cameraPoseForView(view, { center, radius }), fov, sceneBounds };
|
|
145
|
+
return { pose: cameraPoseForView(view, { center, radius }), fov, sceneBounds, fitted: false };
|
|
134
146
|
}
|
|
135
147
|
|
|
136
148
|
const fitted = points?.length >= 3 && fitPoseToPoints(view, points, { fov, aspect, fill: style.camera.fill });
|
|
@@ -130,7 +130,7 @@ export const SUBPART_METRICS = {
|
|
|
130
130
|
hint: "the rebuild's symmetric difference vs its reference import is too large — compare the ghost overlay, then adjust the governing dimensions toward the measured reference" },
|
|
131
131
|
refVolumeDeltaPct: { kind: "gate", extract: (s) => s.deviation?.volumeDeltaPct ?? null,
|
|
132
132
|
hint: "rebuild volume differs from the reference import by more than the allowed percentage — a feature is missing, doubled, or mis-scaled vs the reference" },
|
|
133
|
-
refBboxDelta: { kind: "gate", extract: (s) => s.deviation?.bboxDelta ?? null,
|
|
133
|
+
refBboxDelta: { kind: "gate", form: "vector", extract: (s) => s.deviation?.bboxDelta ?? null,
|
|
134
134
|
hint: "the rebuild's bounding-box corners drift from the reference import — check overall dimensions and that the rebuild is aligned to the reference's coordinates" },
|
|
135
135
|
// Sheet parts (sheetPart(), partforge/geometry): each process's own checks. verify
|
|
136
136
|
// volunteers them on every sheet sub-part; an author may declare them like any other.
|
|
@@ -93,16 +93,123 @@ const norm = (v) => {
|
|
|
93
93
|
return [v[0] / l, v[1] / l, v[2] / l];
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
+
// A free-form camera cue: `{ azimuth, elevation, distance? }`, for the angles no
|
|
97
|
+
// name reaches ("halfway between iso and level"). Degrees, in the MODEL frame
|
|
98
|
+
// the face names already use: azimuth 0 looks from the front (model -Y) and
|
|
99
|
+
// turns toward the right (model +X) at 90, back at 180, left at -90 (or 270);
|
|
100
|
+
// elevation is above the horizontal, 90 straight down from the top. So iso is
|
|
101
|
+
// { azimuth: 45, elevation: 35.26 } and front-right is { azimuth: 45,
|
|
102
|
+
// elevation: 0 }. `distance` multiplies the framing distance a named angle
|
|
103
|
+
// would use (1, the default, frames the part exactly as a name does; 2 is
|
|
104
|
+
// twice as far out), so a cue keeps meaning the same thing as the part's
|
|
105
|
+
// params change its size. The camera always aims at the part's centre: a cue
|
|
106
|
+
// orbits, it never pans.
|
|
107
|
+
export const CAMERA_ORBIT_KEYS = ["azimuth", "elevation", "distance"];
|
|
108
|
+
export const CAMERA_ORBIT_DISTANCE = { min: 0.1, max: 10 };
|
|
109
|
+
|
|
110
|
+
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
|
|
111
|
+
&& (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
112
|
+
|
|
113
|
+
// Why `v` is not a valid orbit cue, as a short phrase; null when it is.
|
|
114
|
+
export function cameraOrbitProblem(v) {
|
|
115
|
+
if (!isPlainObject(v)) return "is not an { azimuth, elevation } object";
|
|
116
|
+
const extra = Object.keys(v).filter((k) => !CAMERA_ORBIT_KEYS.includes(k));
|
|
117
|
+
if (extra.length) return `has unknown key${extra.length > 1 ? "s" : ""} ${extra.map((k) => `\`${k}\``).join(", ")}`;
|
|
118
|
+
if (!Number.isFinite(v.azimuth)) return "needs a finite numeric `azimuth` (degrees)";
|
|
119
|
+
if (!Number.isFinite(v.elevation)) return "needs a finite numeric `elevation` (degrees)";
|
|
120
|
+
if (v.elevation < -90 || v.elevation > 90) return "has an `elevation` outside -90..90";
|
|
121
|
+
if (v.distance !== undefined) {
|
|
122
|
+
const { min, max } = CAMERA_ORBIT_DISTANCE;
|
|
123
|
+
if (!Number.isFinite(v.distance) || v.distance < min || v.distance > max) {
|
|
124
|
+
return `has a \`distance\` outside ${min}..${max} (a multiple of the default framing distance)`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Every value a camera cue may be: an angle name from CAMERA_CUE_VIEWS, or an orbit.
|
|
131
|
+
export function isCameraCue(v) {
|
|
132
|
+
if (typeof v === "string") return CAMERA_CUE_VIEWS.includes(v);
|
|
133
|
+
return cameraOrbitProblem(v) === null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A filename- and log-safe label for a cue: the name itself, or e.g.
|
|
137
|
+
// "az30-el20" / "az30-el20-d1.5" for an orbit.
|
|
138
|
+
export function cameraCueLabel(cue) {
|
|
139
|
+
if (typeof cue === "string") return cue;
|
|
140
|
+
const n = (x) => String(Math.round(x * 100) / 100).replace("-", "m");
|
|
141
|
+
return `az${n(cue.azimuth)}-el${n(cue.elevation)}${cue.distance !== undefined && cue.distance !== 1 ? `-d${n(cue.distance)}` : ""}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const RAD = Math.PI / 180;
|
|
145
|
+
|
|
146
|
+
// World (Y-up) direction and up for an orbit. Model (x, y, z) -> world (x, z, -y)
|
|
147
|
+
// as everywhere else in this file, so model (sin az·cos el, -cos az·cos el, sin el)
|
|
148
|
+
// lands at world (sin az·cos el, sin el, cos az·cos el).
|
|
149
|
+
function orbitAngle({ azimuth, elevation }) {
|
|
150
|
+
const az = azimuth * RAD, el = elevation * RAD;
|
|
151
|
+
const h = Math.cos(el);
|
|
152
|
+
const dir = [Math.sin(az) * h, Math.sin(el), Math.cos(az) * h];
|
|
153
|
+
// Straight down (or up) a +Y up is degenerate; use the screen-up a camera
|
|
154
|
+
// approaching that pole from this azimuth would have, which at azimuth 0 is
|
|
155
|
+
// exactly the `up` the top/bottom names use.
|
|
156
|
+
let up = [0, 1, 0];
|
|
157
|
+
if (Math.abs(h) < 1e-9) {
|
|
158
|
+
const s = elevation > 0 ? -1 : 1;
|
|
159
|
+
up = [s * Math.sin(az), 0, s * Math.cos(az)];
|
|
160
|
+
}
|
|
161
|
+
return { dir, up };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const framingDistance = (radius) => radius * 2.6 + 6; // matches viewer.frameTo's framing distance
|
|
165
|
+
|
|
96
166
|
export function cameraPoseForView(view, { center, radius }) {
|
|
97
167
|
// DIRS first so the seven canonical names keep their exact existing poses;
|
|
98
|
-
// ORIENTATIONS covers the other nineteen the cube can reach.
|
|
99
|
-
|
|
100
|
-
if (
|
|
168
|
+
// ORIENTATIONS covers the other nineteen the cube can reach; an object is an orbit.
|
|
169
|
+
let a, scale = 1;
|
|
170
|
+
if (typeof view === "string") {
|
|
171
|
+
a = DIRS[view] ?? ORIENTATIONS[view];
|
|
172
|
+
if (!a) throw new Error(`unknown canonical view "${view}"`);
|
|
173
|
+
} else {
|
|
174
|
+
const problem = cameraOrbitProblem(view);
|
|
175
|
+
if (problem) throw new Error(`camera cue ${problem}`);
|
|
176
|
+
a = orbitAngle(view);
|
|
177
|
+
scale = view.distance ?? 1;
|
|
178
|
+
}
|
|
101
179
|
const d = norm(a.dir);
|
|
102
|
-
const dist = radius *
|
|
180
|
+
const dist = framingDistance(radius) * scale;
|
|
103
181
|
return {
|
|
104
182
|
position: [center[0] + d[0] * dist, center[1] + d[1] * dist, center[2] + d[2] * dist],
|
|
105
183
|
up: a.up,
|
|
106
184
|
target: [...center],
|
|
107
185
|
};
|
|
108
186
|
}
|
|
187
|
+
|
|
188
|
+
// The inverse of an orbit cue: the cue that reproduces a live camera's angle and
|
|
189
|
+
// distance, so a host (or the agent behind it) can say "keep the view the user
|
|
190
|
+
// has now". `position`/`target` are the viewer's world (Y-up) camera state, and
|
|
191
|
+
// `radius` the same framing radius cameraPoseForView takes for this scene.
|
|
192
|
+
// Rounded to 0.1° and 0.01× — finer than the eye can tell — so the result reads
|
|
193
|
+
// like something an author would write. A pan is not representable (cues always
|
|
194
|
+
// aim at the centre), so it is reported rather than silently dropped. Null for a
|
|
195
|
+
// camera sitting on its target.
|
|
196
|
+
export function cameraCueForPose({ position, target }, { center, radius }) {
|
|
197
|
+
const off = [position[0] - target[0], position[1] - target[1], position[2] - target[2]];
|
|
198
|
+
const len = Math.hypot(off[0], off[1], off[2]);
|
|
199
|
+
if (!(len > 1e-9)) return null;
|
|
200
|
+
const [x, y, z] = [off[0] / len, off[1] / len, off[2] / len];
|
|
201
|
+
const round = (v, step) => Math.round(v / step) * step;
|
|
202
|
+
const fix = (v, places) => Number(v.toFixed(places)) || 0; // || 0 folds -0
|
|
203
|
+
const elevation = fix(round(Math.asin(Math.max(-1, Math.min(1, y))) / RAD, 0.1), 1);
|
|
204
|
+
// Azimuth is meaningless at a pole; 0 matches the top/bottom names' roll.
|
|
205
|
+
const azimuth = Math.abs(elevation) >= 90 ? 0 : fix(round(Math.atan2(x, z) / RAD, 0.1), 1);
|
|
206
|
+
const { min, max } = CAMERA_ORBIT_DISTANCE;
|
|
207
|
+
const distance = fix(Math.min(max, Math.max(min, round(len / framingDistance(radius), 0.01))), 2);
|
|
208
|
+
const panned = Math.hypot(target[0] - center[0], target[1] - center[1], target[2] - center[2]);
|
|
209
|
+
return {
|
|
210
|
+
cue: distance === 1 ? { azimuth, elevation } : { azimuth, elevation, distance },
|
|
211
|
+
// How far the user's orbit target sits from the part centre the cue aims at,
|
|
212
|
+
// as a fraction of the framing radius. 0 when they never panned.
|
|
213
|
+
panned: radius > 0 ? fix(panned / radius, 2) : 0,
|
|
214
|
+
};
|
|
215
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -25,7 +25,7 @@ import { orthoFrustum, perspectiveDistance, isFaceAligned, FACE_ALIGN_COS } from
|
|
|
25
25
|
import { depthRangeFor } from "./depth-range.js";
|
|
26
26
|
import { addViewerLights, captureLightPoses, createCaptureLights, createHemisphereLight } from "./viewer-lighting.js";
|
|
27
27
|
import { makeCaptureCamera, recenteredView, captureDepthRange } from "./capture-frame.js";
|
|
28
|
-
import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
|
|
28
|
+
import { CANONICAL_VIEWS, cameraPoseForView, cameraCueForPose } from "./view-angles.js";
|
|
29
29
|
|
|
30
30
|
import { srgbEncodeInPlace, RENDER_STYLES, getRenderStyle, CAD_LIGHT_THEME, CAD_DARK_THEME } from "./renderStyles.js";
|
|
31
31
|
import { stylePose, modelToWorld } from "./style-camera.js";
|
|
@@ -1361,6 +1361,21 @@ export function createViewer(container, part) {
|
|
|
1361
1361
|
{ duration, onComplete: finish },
|
|
1362
1362
|
);
|
|
1363
1363
|
}
|
|
1364
|
+
// The user's current view as an animation camera cue (view-angles.js
|
|
1365
|
+
// cameraCueForPose): `{ cue: { azimuth, elevation, distance? }, panned }`,
|
|
1366
|
+
// measured against the same bounds and radius tweenCameraTo frames a cue
|
|
1367
|
+
// with, so playing the returned cue lands where the user is looking now
|
|
1368
|
+
// (bar any pan, which `panned` reports). getCameraState supplies the pose,
|
|
1369
|
+
// so an orthographic view reads at its perspective-equivalent distance.
|
|
1370
|
+
// Null with nothing on screen.
|
|
1371
|
+
function getCameraCue() {
|
|
1372
|
+
const box = getVisibleWorldBounds();
|
|
1373
|
+
if (!box || box.isEmpty()) return null;
|
|
1374
|
+
const center = box.getCenter(new THREE.Vector3()).toArray();
|
|
1375
|
+
const size = box.getSize(new THREE.Vector3());
|
|
1376
|
+
const { pos, target } = getCameraState();
|
|
1377
|
+
return cameraCueForPose({ position: pos, target }, { center, radius: Math.max(size.x, size.y, size.z) || 12 });
|
|
1378
|
+
}
|
|
1364
1379
|
// Cancelling has to put damping back: the tween's own completion path is the
|
|
1365
1380
|
// only other place that does, and it never runs for a cancelled tween.
|
|
1366
1381
|
const cancelCameraTween = () => { camTween.cancel(); restoreDamping(); };
|
|
@@ -3100,6 +3115,7 @@ export function createViewer(container, part) {
|
|
|
3100
3115
|
onFrame,
|
|
3101
3116
|
onAssemblyChange: (cb) => { assemblyListeners.add(cb); return () => assemblyListeners.delete(cb); },
|
|
3102
3117
|
tweenCameraTo,
|
|
3118
|
+
getCameraCue,
|
|
3103
3119
|
holdGround,
|
|
3104
3120
|
cancelCameraTween,
|
|
3105
3121
|
orbitBy,
|
package/src/testing/render.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { join, resolve, sep } from "node:path";
|
|
3
3
|
import { safeName } from "../framework/safe-name.js";
|
|
4
|
-
import { ORIENTATIONS } from "../framework/view-angles.js";
|
|
4
|
+
import { ORIENTATIONS, cameraOrbitProblem, cameraPoseForView, cameraCueLabel } from "../framework/view-angles.js";
|
|
5
5
|
import { buildView } from "../framework/oracle/build.js";
|
|
6
6
|
import { renderStyled } from "../framework/softrender/index.js";
|
|
7
7
|
import { getRenderStyle } from "../framework/renderStyles.js";
|
|
@@ -28,7 +28,16 @@ export const RENDER_VIEWS = Object.keys(RENDER_ANGLES);
|
|
|
28
28
|
// The view cube's other orientations (animation camera cues may name them, and
|
|
29
29
|
// a still defaults to its cue's angle), mapped from the viewer's Y-up world
|
|
30
30
|
// back into model space: world (x, y, z) -> model (x, -z, y).
|
|
31
|
+
// An { azimuth, elevation, distance? } orbit cue resolves too, through the
|
|
32
|
+
// viewer's own cameraPoseForView so the two cannot disagree about an angle.
|
|
31
33
|
export function renderAngle(name) {
|
|
34
|
+
if (typeof name === "object" && name !== null) {
|
|
35
|
+
if (cameraOrbitProblem(name)) return null;
|
|
36
|
+
const pose = cameraPoseForView(name, { center: [0, 0, 0], radius: 1 });
|
|
37
|
+
const toModelDir = ([x, y, z]) => [x, -z, y];
|
|
38
|
+
return { dir: toModelDir(pose.position), up: toModelDir(pose.up) };
|
|
39
|
+
}
|
|
40
|
+
if (typeof name !== "string") return null;
|
|
32
41
|
if (Object.hasOwn(RENDER_ANGLES, name)) return RENDER_ANGLES[name];
|
|
33
42
|
const o = Object.hasOwn(ORIENTATIONS, name) ? ORIENTATIONS[name] : null;
|
|
34
43
|
if (!o) return null;
|
|
@@ -60,7 +69,7 @@ export async function renderViewImages(kernel, part, view = Object.keys(part.vie
|
|
|
60
69
|
// An explicit size is the caller's choice and is honoured as given.
|
|
61
70
|
if (!size) size = resolved.camera.aspect ? [640, Math.round(640 / resolved.camera.aspect)] : [800, 600];
|
|
62
71
|
for (const angle of views) {
|
|
63
|
-
if (!renderAngle(angle)) throw new Error(`unknown angle
|
|
72
|
+
if (!renderAngle(angle)) throw new Error(`unknown angle ${JSON.stringify(angle)} (use: ${RENDER_VIEWS.join(", ")}, a view-cube orientation such as top-front-left, or an { azimuth, elevation, distance? } orbit)`);
|
|
64
73
|
}
|
|
65
74
|
// Own-key lookups only — a part named "constructor" must not inherit a value
|
|
66
75
|
// off Object.prototype and vanish from the render.
|
|
@@ -76,7 +85,9 @@ export async function renderViewImages(kernel, part, view = Object.keys(part.vie
|
|
|
76
85
|
const img = renderStyled(subparts, { view: angle, style, size, supersample, edges });
|
|
77
86
|
const png = new PNG({ width: img.width, height: img.height });
|
|
78
87
|
png.data.set(img.rgba);
|
|
79
|
-
|
|
88
|
+
// `angle` is always a string: an orbit cue comes back as its label (az30-el20),
|
|
89
|
+
// which is also what names its file.
|
|
90
|
+
return { angle: cameraCueLabel(angle), png: PNG.sync.write(png) };
|
|
80
91
|
});
|
|
81
92
|
}
|
|
82
93
|
|
package/types/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// their geometry helpers from "partforge/geometry".
|
|
6
6
|
|
|
7
7
|
import type { BackendName } from "./kernel.js";
|
|
8
|
-
import type { CanonicalView, ParamValue, PartDefinition } from "./part.js";
|
|
8
|
+
import type { CameraOrbit, CanonicalView, ParamValue, PartDefinition } from "./part.js";
|
|
9
9
|
|
|
10
10
|
export * from "./kernel.js";
|
|
11
11
|
export * from "./part.js";
|
|
@@ -669,6 +669,15 @@ export interface PartRuntime {
|
|
|
669
669
|
* view-cube click, Reframe, or an animation cue).
|
|
670
670
|
*/
|
|
671
671
|
getViewerState(): ViewerState;
|
|
672
|
+
/**
|
|
673
|
+
* The user's current view as an animation camera cue, for "keep the view I
|
|
674
|
+
* have now": write `cue` as a `camera` value and playing it lands on this
|
|
675
|
+
* angle and distance. Cues always aim at the part's centre, so a pan cannot
|
|
676
|
+
* be carried — `panned` says how far the user's orbit target sits from that
|
|
677
|
+
* centre, as a fraction of the framing radius (0 when they never panned).
|
|
678
|
+
* Null with nothing on screen. Safe after `dispose()`.
|
|
679
|
+
*/
|
|
680
|
+
getCameraCue(): { cue: CameraOrbit; panned: number } | null;
|
|
672
681
|
/**
|
|
673
682
|
* True while the `preview` GLB is on screen: from the moment it decodes until a
|
|
674
683
|
* build delivers the whole view (a build error leaves it on). Picking, hover
|
package/types/part.d.ts
CHANGED
|
@@ -676,11 +676,27 @@ export type ViewCubeOrientation =
|
|
|
676
676
|
| "bottom-front-left" | "bottom-front-right" | "bottom-back-left" | "bottom-back-right";
|
|
677
677
|
|
|
678
678
|
/**
|
|
679
|
-
* A camera
|
|
680
|
-
*
|
|
681
|
-
*
|
|
679
|
+
* A free-form camera angle, for the ones no name reaches. Degrees, in the
|
|
680
|
+
* part's own frame: `azimuth` 0 looks from the front (-Y) and turns toward the
|
|
681
|
+
* right (+X) at 90, back at 180, left at -90; `elevation` is above the
|
|
682
|
+
* horizontal, -90..90. `iso` is `{ azimuth: 45, elevation: 35.26 }` and
|
|
683
|
+
* `front-right` is `{ azimuth: 45, elevation: 0 }`. `distance` multiplies the
|
|
684
|
+
* framing distance a named angle uses (default 1; 0.1..10). The camera always
|
|
685
|
+
* aims at the part's centre.
|
|
682
686
|
*/
|
|
683
|
-
export
|
|
687
|
+
export interface CameraOrbit {
|
|
688
|
+
azimuth: number;
|
|
689
|
+
elevation: number;
|
|
690
|
+
distance?: number;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* A camera cue: one of the canonical seven, a view-cube orientation, or a
|
|
695
|
+
* free-form `CameraOrbit`. `partforge lint` rejects anything else
|
|
696
|
+
* (`animation-camera-invalid`). Cues fire during play only; scrubbing never
|
|
697
|
+
* moves the camera.
|
|
698
|
+
*/
|
|
699
|
+
export type CameraCue = CanonicalView | ViewCubeOrientation | CameraOrbit;
|
|
684
700
|
|
|
685
701
|
/** One step of a multi-step animation. Steps play in order; prev/next navigate them. */
|
|
686
702
|
export interface AnimationStep {
|