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.
@@ -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 name (an intro cue at t=0), an
314
- animation-level `[[t, angle], …]` list, or per-step names.
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 `"<=[x,y,z]"` /
4351
- `">=[x,y,z]"` — exactly 3 components, each a plain number in mm or `*` to skip an axis.
4352
- A vector is always one-sided: a bare `[40,40,10]` (meaning "exactly this size") is
4353
- rejected, since a mesh's box shifts with tessellation and no tolerance would be honest
4354
- for every part — pick `<=` for a limit or `>=` for a floor. `bounds` takes an object
4355
- with `min` and/or `max`, each such a vector: `bounds: { min: ">=[0,0,0]", max:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.160.0",
3
+ "version": "0.162.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",
@@ -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 is only ever an intro, so it has no sweep of its own.
73
- if (typeof spec.camera === "string") cues = [{ t: 0, view: spec.camera, sweep: null }];
74
- else if (Array.isArray(spec.camera)) {
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, CAMERA_CUE_VIEWS } from "../view-angles.js";
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 badName = (v) => typeof v !== "string" || !CAMERA_CUE_VIEWS.includes(v);
378
- // The seven canonical names plus the view cube's corners and edges.
379
- const angles = `${CANONICAL_VIEWS.join(", ")}, or a view-cube orientation such as top-front-left or top-back-right`;
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 name/cue-list, or per-step names — not both.",
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 (badName(cam)) {
396
+ if (badCue(cam)) {
392
397
  out.push(err("animation-camera-invalid",
393
- `animation "${name}" step ${i} camera "${cam}" is not a camera angle`,
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 (typeof a.camera === "string") {
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 && !badName(c[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 name nor a cue list`,
422
- "Use a canonical angle string, or `[[t, angle], …]` cues.",
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
- "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]` (with `*` to skip an axis).",
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 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]` (with `*` to skip an axis).";
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]"\` or \`">=[x,y,z]"\` (componentwise; * skips an axis).`,
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 [];
@@ -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, { aspect = 1, points } = {}) {
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
- const a = DIRS[view] ?? ORIENTATIONS[view];
100
- if (!a) throw new Error(`unknown canonical view "${view}"`);
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 * 2.6 + 6; // matches viewer.frameTo's framing distance
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
+ }
@@ -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,
@@ -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 "${angle}" (use: ${RENDER_VIEWS.join(", ")}, or a view-cube orientation such as top-front-left)`);
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
- return { angle, png: PNG.sync.write(png) };
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 cue angle: one of the canonical seven or a view-cube orientation.
680
- * `partforge lint` rejects anything else (`animation-camera-invalid`). Cues fire
681
- * during play only; scrubbing never moves the camera.
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 type CameraCue = CanonicalView | ViewCubeOrientation;
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 {