partforge 0.41.0 → 0.44.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.
Files changed (70) hide show
  1. package/README.md +31 -10
  2. package/bin/cli.js +100 -27
  3. package/docs/AUTHORING-PARTS.md +126 -14
  4. package/docs/ERROR-PATTERNS.md +6 -0
  5. package/package.json +48 -7
  6. package/skills/partforge/SKILL.md +17 -3
  7. package/src/app-embed-test.js +1 -1
  8. package/src/app-hinged-box.js +12 -0
  9. package/src/framework/animation-controls.js +243 -0
  10. package/src/framework/animation.js +217 -0
  11. package/src/framework/app.css +32 -0
  12. package/src/framework/assembly.js +1 -1
  13. package/src/framework/backend-select.js +25 -0
  14. package/src/framework/camera-tween.js +58 -0
  15. package/src/framework/chrome.css +16 -0
  16. package/src/framework/controls.js +13 -3
  17. package/src/framework/cutaway-gizmo-scene.js +244 -0
  18. package/src/framework/cutaway-gizmo.js +80 -243
  19. package/src/framework/default-view.js +46 -0
  20. package/src/framework/download.js +7 -2
  21. package/src/framework/export-controller.js +13 -2
  22. package/src/framework/geometry/probe.js +3 -22
  23. package/src/framework/jobs.js +9 -40
  24. package/src/framework/lint/finding.js +4 -0
  25. package/src/framework/lint/index.js +7 -3
  26. package/src/framework/lint/rules-animations.js +404 -0
  27. package/src/framework/lint/rules-place.js +76 -0
  28. package/src/framework/lint/rules-shape.js +12 -0
  29. package/src/framework/lint/rules-verify.js +2 -2
  30. package/src/framework/mount.js +93 -18
  31. package/src/{testing → framework/oracle}/build.js +1 -1
  32. package/src/{testing → framework/oracle}/bvh.js +1 -1
  33. package/src/{testing → framework/oracle}/measure.js +1 -1
  34. package/src/{testing → framework/oracle}/min-wall.js +1 -1
  35. package/src/{testing → framework/oracle}/verify.js +3 -3
  36. package/src/framework/param-deps.js +1 -1
  37. package/src/framework/part-model.js +48 -0
  38. package/src/framework/pick-request/client.js +11 -3
  39. package/src/framework/pick-request/endpoint.js +60 -0
  40. package/src/framework/pick-request/index.js +6 -0
  41. package/src/framework/pick-request/server.js +222 -34
  42. package/src/framework/pick-request/token-store.js +31 -0
  43. package/src/framework/pose-fast-path.js +12 -1
  44. package/src/framework/pose-probe-core.js +129 -0
  45. package/src/framework/pose-probe.js +7 -123
  46. package/src/framework/regen-loop.js +10 -3
  47. package/src/framework/safe-name.js +26 -0
  48. package/src/framework/verify-metrics.js +4 -4
  49. package/src/framework/view-state.js +25 -21
  50. package/src/framework/view-tabs.js +22 -7
  51. package/src/framework/viewer-controls.js +5 -26
  52. package/src/framework/viewer.js +58 -17
  53. package/src/hinged-box-worker.js +3 -0
  54. package/src/index.js +1 -1
  55. package/src/parts/hinged-box.js +94 -0
  56. package/src/testing/render.js +19 -8
  57. package/src/testing.js +15 -8
  58. package/types/derive.d.ts +14 -0
  59. package/types/geometry.d.ts +117 -0
  60. package/types/index.d.ts +240 -0
  61. package/types/kernel.d.ts +409 -0
  62. package/types/lint.d.ts +85 -0
  63. package/types/part.d.ts +381 -0
  64. package/types/testing.d.ts +362 -0
  65. package/types/worker.d.ts +21 -0
  66. /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
  67. /package/src/{testing → framework/oracle}/cases.js +0 -0
  68. /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
  69. /package/src/{testing → framework/oracle}/gaps.js +0 -0
  70. /package/src/{testing → framework/oracle}/mesh.js +0 -0
@@ -1,4 +1,5 @@
1
1
  import { zipSync } from "fflate";
2
+ import { safeName } from "./safe-name.js";
2
3
 
3
4
  // Browser file-download helpers. Pure DOM/Blob utilities with no app state — the
4
5
  // worker produces the bytes; these just hand them to the browser as a download.
@@ -23,9 +24,13 @@ export function triggerDownload(data, filename, mime, sink) {
23
24
  // Download a set of built parts: a single part downloads directly; multiple parts
24
25
  // are bundled into one flat, store-only (level 0) zip named `zipName`. `sink`, if
25
26
  // given, is forwarded to triggerDownload so it receives the final bytes.
27
+ //
28
+ // Sub-part names come from the part's `export.name` — untrusted data — and become
29
+ // zip entry names, so they go through safeName(): the zip must stay flat, and an
30
+ // entry like "../evil.stl" would escape the target directory in naive extractors.
26
31
  export function downloadParts({ parts, ext, mime }, zipName, sink) {
27
- if (parts.length === 1) return triggerDownload(parts[0].data, `${parts[0].name}.${ext}`, mime, sink);
32
+ if (parts.length === 1) return triggerDownload(parts[0].data, `${safeName(parts[0].name)}.${ext}`, mime, sink);
28
33
  const entries = {};
29
- for (const p of parts) entries[`${p.name}.${ext}`] = new Uint8Array(p.data);
34
+ for (const p of parts) entries[`${safeName(p.name)}.${ext}`] = new Uint8Array(p.data);
30
35
  triggerDownload(zipSync(entries, { level: 0 }), zipName, "application/zip", sink);
31
36
  }
@@ -3,6 +3,17 @@
3
3
  // replies (progress/download/error) back to the Promise that started them.
4
4
  // Pure — no DOM, no worker; `send` and the sink are injected.
5
5
  import { triggerDownload, downloadParts } from "./download.js";
6
+ import { safeName } from "./safe-name.js";
7
+
8
+ // The one place the "which backend does this export format need" policy lives.
9
+ // STEP is always OCCT — only OCCT (OpenCASCADE) emits exact B-rep; Manifold's mesh
10
+ // CSG has no STEP writer. Every other format is free to use whichever backend the
11
+ // caller is already using for preview. Both the UI export buttons (mount.js) and
12
+ // the headless exportParts() API route through this so the rule is never encoded
13
+ // twice.
14
+ export function backendForFormat(format, defaultBackend) {
15
+ return format === "step" ? "occt" : defaultBackend();
16
+ }
6
17
 
7
18
  export function createExportController({ send, currentView, title, defaultBackend = () => "manifold", currentParams = () => ({}) }) {
8
19
  const pending = new Map(); // jobId -> { resolve, reject, onProgress }
@@ -11,7 +22,7 @@ export function createExportController({ send, currentView, title, defaultBacken
11
22
  function exportParts({ parts, format, quality = "print", onProgress } = {}) {
12
23
  const jobId = nextId++;
13
24
  const type = `export-${format}`;
14
- const backend = format === "step" ? "occt" : defaultBackend();
25
+ const backend = backendForFormat(format, defaultBackend);
15
26
  return new Promise((resolve, reject) => {
16
27
  pending.set(jobId, { resolve, reject, onProgress });
17
28
  send({ type, jobId, parts, view: currentView(), params: currentParams(), name: title(), quality }, backend);
@@ -32,7 +43,7 @@ export function createExportController({ send, currentView, title, defaultBacken
32
43
  }
33
44
  if (m.type === "download-parts") {
34
45
  pending.delete(m.jobId);
35
- const zipName = `${title() ?? "parts"}.zip`.toLowerCase().replace(/\s+/g, "-");
46
+ const zipName = `${safeName(title(), "parts")}.zip`; // title() is the part's meta.title — untrusted
36
47
  downloadParts(m, zipName, sink);
37
48
  entry.resolve();
38
49
  return true;
@@ -1,7 +1,7 @@
1
1
  // Geometry-free build execution. Two consumers share one Proxy implementation:
2
2
  //
3
- // • createProbeKernel() — records op NAMES so detectBackend() can route a part to
4
- // OCCT when it uses fillet/chamfer/shell.
3
+ // • createProbeKernel() — records op NAMES so ../backend-select.js's
4
+ // detectBackend() can route a part to OCCT when it uses fillet/chamfer/shell.
5
5
  // • createValidatingProbe() — additionally checks op names against the kernel
6
6
  // contract's op lists and routes options-form calls through the same op-options
7
7
  // normalizers the real backends use, so partforge/lint can catch a bad call in
@@ -14,13 +14,10 @@
14
14
  // validating probe DOES need an allowlist, so it takes one from kernel.js's op
15
15
  // lists, which test/kernel-contract.test.js pins to both backend implementations.
16
16
  import {
17
- OCCT_ONLY_OPS, KERNEL_OPS, KERNEL_OPTIONAL_OPS,
17
+ KERNEL_OPS, KERNEL_OPTIONAL_OPS,
18
18
  SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS,
19
19
  } from "./kernel.js";
20
20
  import { KERNEL_OP_SPECS, SOLID_OP_SPECS, isPlainOptions } from "./op-options.js";
21
- import { resolveDerived } from "../derive.js";
22
-
23
- const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
24
21
 
25
22
  // The probe returns ONE chainable handle for every non-query op, so it cannot tell a
26
23
  // Solid from a Shape2D — k.box() and k.shape2d() yield the same object. The solid-scope
@@ -145,19 +142,3 @@ export function runValidatingProbe(part, p, d, { maxOps = MAX_PROBE_OPS } = {})
145
142
  }
146
143
  return { calls: probe.calls, issues: probe.issues, used: probe.used, throws, runaway };
147
144
  }
148
-
149
- export function detectBackend(part, params = {}) {
150
- if (part.meta?.backend) return part.meta.backend;
151
- const p = { ...part.defaults, ...params };
152
- let d = {};
153
- // A throwing derive must not escape here — this runs on the main thread mid
154
- // regen (after the busy spinner goes up). Probe with an empty `d`; the worker
155
- // build hits the same throw and posts a proper error for the UI.
156
- try { d = resolveDerived(part, p); } catch { /* fall through with d = {} */ }
157
- const { kernel, used } = createProbeKernel();
158
- for (const name of Object.keys(part.parts)) {
159
- try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
160
- }
161
- for (const op of used) if (OCCT_ONLY.has(op)) return "occt";
162
- return "manifold";
163
- }
@@ -1,45 +1,14 @@
1
+ // The worker's job protocol. The pure part model it runs over — viewSubParts /
2
+ // exportSubParts / resolveParams / buildPosed — lives in part-model.js, a leaf, so
3
+ // the oracle and the collision check can share it without importing this async,
4
+ // kernel-bound module back.
1
5
  import { meshTo3MF } from "./geometry/threemf.js";
2
6
  import { exportablePartNames } from "./export-select.js";
3
- import { resolveDerived } from "./derive.js";
4
7
  import { resolveFonts } from "./fonts.js";
5
- import { measure } from "../testing/measure.js";
6
- import { verify } from "../testing/verify.js";
7
-
8
- // Names of the sub-parts a view shows: declared in the view and enabled for these
9
- // params. Order follows Object.keys(part.parts) (definition order).
10
- export function viewSubParts(part, view, params) {
11
- return Object.keys(part.parts).filter((name) => {
12
- const sp = part.parts[name];
13
- const inView = sp.views.includes(view);
14
- const on = sp.enabled ? !!sp.enabled(params) : true;
15
- return inView && on;
16
- });
17
- }
18
-
19
- // Sub-parts to include in an EXPORT of this view: the visible sub-parts, minus any
20
- // flagged `exportable: false` (reference/preview-only parts — motor ghosts, bearing
21
- // placeholders, etc.). They still show in the viewer; they're just never written to
22
- // an STL/STEP/3MF file, so the user never has to toggle them off before exporting.
23
- export function exportSubParts(part, view, params) {
24
- return viewSubParts(part, view, params).filter((name) => part.parts[name].exportable !== false);
25
- }
26
-
27
- // Resolve a part's effective params + derived values for a build: the user's params
28
- // layered over the part defaults, and derive() run once over the result.
29
- export function resolveParams(part, params) {
30
- const p = { ...part.defaults, ...params };
31
- return { p, d: resolveDerived(part, p) };
32
- }
33
-
34
- // Build one sub-part and apply its optional place() for the given purpose/view.
35
- // `p`/`d` come from resolveParams(). This is the SINGLE definition of "a posed
36
- // sub-part solid" — the worker, the collision check, and the test harness all call
37
- // it, so display/export poses can never drift between the app and its tests.
38
- export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress } = {}) {
39
- const sp = part.parts[name];
40
- const solid = sp.build(kernel, p, d, onProgress);
41
- return sp.place ? sp.place(solid, { view, purpose, p, d }) : solid;
42
- }
8
+ import { safeName } from "./safe-name.js";
9
+ import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
10
+ import { measure } from "./oracle/measure.js";
11
+ import { verify } from "./oracle/verify.js";
43
12
 
44
13
  // Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
45
14
  // Backend-agnostic and part-agnostic: every part specific comes through `part`.
@@ -82,7 +51,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
82
51
  msg.parts
83
52
  ? exportablePartNames(part, p).filter((name) => msg.parts.includes(name))
84
53
  : exportSubParts(part, msg.view, p);
85
- const fileBase = msg.name ?? msg.view; // STEP/3MF single-file name base
54
+ const fileBase = safeName(msg.name ?? msg.view); // STEP/3MF single-file name base (part-derived → untrusted)
86
55
 
87
56
  if (msg.type === "generate") {
88
57
  const t0 = Date.now();
@@ -18,3 +18,7 @@ const make = (severity) => (rule, message, hint, path = "", pattern) => ({
18
18
  export const err = make("error");
19
19
  // warning → suspicious or lossy, but the part behaves as authored.
20
20
  export const warn = make("warning");
21
+ // note → neither broken nor suspicious; informational context an authoring
22
+ // agent should see (e.g. "this animated track rebuilds geometry"). Notes never
23
+ // gate measure or --strict.
24
+ export const note = make("note");
@@ -13,8 +13,10 @@ import { SCHEMA_RULES } from "./rules-schema.js";
13
13
  import { runValidatingProbe } from "../geometry/probe.js";
14
14
  import { BUILD_RULES } from "./rules-build.js";
15
15
  import { VERIFY_RULES, resolveExpect } from "./rules-verify.js";
16
+ import { ANIMATION_RULES } from "./rules-animations.js";
17
+ import { PLACE_RULES } from "./rules-place.js";
16
18
 
17
- export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES];
19
+ export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES];
18
20
 
19
21
  // Every rule runs inside a guard. lintPart is called on a user-facing hosted path
20
22
  // (partforge-cloud's sandbox), and a linter that takes down the preview it exists to
@@ -71,7 +73,7 @@ export function lintContext(part, params) {
71
73
  * Lint a PartDefinition. Never throws.
72
74
  * @param {object} part the default-exported PartDefinition
73
75
  * @param {{params?: object}} [opts] params layered over part.defaults for the probe pass
74
- * @returns {{ok: boolean, errors: object[], warnings: object[]}}
76
+ * @returns {{ok: boolean, errors: object[], warnings: object[], notes: object[]}}
75
77
  */
76
78
  export function lintPart(part, opts) {
77
79
  // `opts` is defaulted here, not via `= {}` on the parameter, because a default
@@ -95,6 +97,7 @@ export function lintPart(part, opts) {
95
97
  "This part is too malformed for lint to analyze safely — make sure `defaults`, `params`, and `verify`/`derive` are plain, side-effect-free data rather than throwing getters or hostile Proxies.",
96
98
  "")],
97
99
  warnings: [],
100
+ notes: [],
98
101
  };
99
102
  }
100
103
  const findings = runRules(RULES, ctx);
@@ -110,5 +113,6 @@ export function lintPart(part, opts) {
110
113
  }
111
114
  const errors = findings.filter((f) => f.severity === "error");
112
115
  const warnings = findings.filter((f) => f.severity === "warning");
113
- return { ok: errors.length === 0, errors, warnings };
116
+ const notes = findings.filter((f) => f.severity === "note");
117
+ return { ok: errors.length === 0, errors, warnings, notes };
114
118
  }
@@ -0,0 +1,404 @@
1
+ // Group 5 — the `animations` block (spec 2026-08-02-model-animation-design.md).
2
+ // Everything but the last rule is static data validation: the block is pure
3
+ // keyframe data by design, so lint can hold every track to the schema without
4
+ // executing author code. `animation-track-rebuilds` is the exception — it runs
5
+ // the geometry-free pose probe to classify each track as pose-only or
6
+ // geometry-rebuilding, and reports the latter at the note tier.
7
+ import { err, note } from "./finding.js";
8
+ import { EASINGS } from "../animation.js";
9
+ import { CANONICAL_VIEWS } from "../view-angles.js";
10
+ import { probeSubPartPose } from "../pose-probe-core.js";
11
+ import { resolveDerived } from "../derive.js";
12
+
13
+ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
14
+
15
+ // [name, spec] pairs, only when the block is well-shaped enough to walk.
16
+ const animEntries = (part) =>
17
+ isPlainObject(part?.animations)
18
+ ? Object.entries(part.animations).filter(([, a]) => isPlainObject(a))
19
+ : [];
20
+
21
+ // Steps in normalized-adjacent form for rule walks (does NOT validate — each
22
+ // rule checks its own slice). A bare-tracks animation is one anonymous step.
23
+ const rawSteps = (a) => (Array.isArray(a.steps) ? a.steps.filter(isPlainObject) : [{ ...a, label: null }]);
24
+
25
+ // The control descriptor ranges, for value-in-range checks. Mirrors
26
+ // rules-schema.js's collectDescriptors walk (not shared: each group owns its
27
+ // own walk by design — see lint/index.js header).
28
+ function paramRanges(part) {
29
+ const ranges = new Map();
30
+ const secs = Array.isArray(part?.parameters) ? part.parameters : [];
31
+ const add = (d) => {
32
+ if (d && typeof d.key === "string" && !ranges.has(d.key)) ranges.set(d.key, { min: d.min, max: d.max });
33
+ };
34
+ for (const sec of secs) {
35
+ for (const d of Array.isArray(sec?.advanced) ? sec.advanced : []) add(d);
36
+ for (const f of Array.isArray(sec?.features) ? sec.features : []) {
37
+ for (const s of Array.isArray(f?.sliders) ? f.sliders : []) add(s);
38
+ }
39
+ }
40
+ return ranges;
41
+ }
42
+
43
+ const validKeyframes = (kf) =>
44
+ Array.isArray(kf) && kf.length >= 2
45
+ && kf.every((e) => Array.isArray(e) && e.length === 2 && Number.isFinite(e[0]) && Number.isFinite(e[1]))
46
+ && kf[0][0] === 0 && kf[kf.length - 1][0] === 1
47
+ && kf.every((e, i) => i === 0 || e[0] > kf[i - 1][0]);
48
+
49
+ export const ANIMATION_RULES = [
50
+ {
51
+ id: "animations-not-object",
52
+ run: ({ part }) => {
53
+ if (part?.animations === undefined) return [];
54
+ if (!isPlainObject(part.animations)) {
55
+ return [err("animations-not-object", "`animations` is not a plain object",
56
+ "Declare animations as `animations: { <name>: { duration, tracks } }` — see docs/AUTHORING-PARTS.md \"Animations\".",
57
+ "animations")];
58
+ }
59
+ return Object.entries(part.animations)
60
+ .filter(([, a]) => !isPlainObject(a))
61
+ .map(([name]) => err("animations-not-object", `animation "${name}" is not a plain object`,
62
+ "Each animations entry must be an object with `duration` + `tracks`, or `steps`.",
63
+ `animations.${name}`));
64
+ },
65
+ },
66
+ {
67
+ id: "animation-tracks-or-steps",
68
+ run: ({ part }) => {
69
+ const out = [];
70
+ for (const [name, a] of animEntries(part)) {
71
+ const hasTracks = a.tracks !== undefined;
72
+ const hasSteps = a.steps !== undefined;
73
+ if (hasTracks === hasSteps) {
74
+ out.push(err("animation-tracks-or-steps",
75
+ `animation "${name}" must have exactly one of \`tracks\` or \`steps\``,
76
+ "A single-phase animation declares `tracks` directly; a stepped one declares `steps: [{ label, duration, tracks }]`. Never both, never neither.",
77
+ `animations.${name}`));
78
+ continue;
79
+ }
80
+ if (hasSteps && (!Array.isArray(a.steps) || a.steps.length === 0 || !a.steps.every(isPlainObject))) {
81
+ out.push(err("animation-tracks-or-steps",
82
+ `animation "${name}" has an empty or malformed \`steps\` array`,
83
+ "`steps` must be a non-empty array of `{ label, duration, tracks }` objects.",
84
+ `animations.${name}.steps`));
85
+ continue;
86
+ }
87
+ rawSteps(a).forEach((s, i) => {
88
+ const path = hasSteps ? `animations.${name}.steps[${i}].tracks` : `animations.${name}.tracks`;
89
+ if (!isPlainObject(s.tracks) || Object.keys(s.tracks).length === 0) {
90
+ out.push(err("animation-tracks-or-steps",
91
+ `animation "${name}"${hasSteps ? ` step ${i}` : ""} has no tracks`,
92
+ "Every step needs a non-empty `tracks` object mapping a param key to keyframes.",
93
+ path));
94
+ }
95
+ });
96
+ }
97
+ return out;
98
+ },
99
+ },
100
+ {
101
+ id: "animation-unknown-param",
102
+ run: ({ part }) => {
103
+ if (!isPlainObject(part?.defaults)) return [];
104
+ const known = new Set(Object.keys(part.defaults));
105
+ const out = [];
106
+ for (const [name, a] of animEntries(part)) {
107
+ rawSteps(a).forEach((s, i) => {
108
+ for (const key of Object.keys(isPlainObject(s.tracks) ? s.tracks : {})) {
109
+ if (!known.has(key)) {
110
+ out.push(err("animation-unknown-param",
111
+ `animation "${name}" tracks "${key}", which is not in \`defaults\``,
112
+ `Animations drive existing params — add "${key}" to \`defaults\` (and a control for it), or correct the key.`,
113
+ `animations.${name}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
114
+ }
115
+ }
116
+ });
117
+ }
118
+ return out;
119
+ },
120
+ },
121
+ {
122
+ id: "animation-param-not-numeric",
123
+ run: ({ part }) => {
124
+ if (!isPlainObject(part?.defaults)) return [];
125
+ const out = [];
126
+ for (const [name, a] of animEntries(part)) {
127
+ rawSteps(a).forEach((s, i) => {
128
+ for (const key of Object.keys(isPlainObject(s.tracks) ? s.tracks : {})) {
129
+ if (key in part.defaults && typeof part.defaults[key] !== "number") {
130
+ out.push(err("animation-param-not-numeric",
131
+ `animation "${name}" tracks "${key}", whose default is not a number`,
132
+ "v1 animations interpolate numeric params only — text/choice params cannot be keyframed.",
133
+ `animations.${name}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
134
+ }
135
+ }
136
+ });
137
+ }
138
+ return out;
139
+ },
140
+ },
141
+ {
142
+ id: "animation-keyframes-invalid",
143
+ run: ({ part }) => {
144
+ const out = [];
145
+ for (const [name, a] of animEntries(part)) {
146
+ rawSteps(a).forEach((s, i) => {
147
+ for (const [key, kf] of Object.entries(isPlainObject(s.tracks) ? s.tracks : {})) {
148
+ if (!validKeyframes(kf)) {
149
+ out.push(err("animation-keyframes-invalid",
150
+ `animation "${name}" track "${key}" has invalid keyframes`,
151
+ "Keyframes are `[[t, value], …]` with finite numbers, at least two entries, `t` strictly ascending from exactly 0 to exactly 1.",
152
+ `animations.${name}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
153
+ }
154
+ }
155
+ });
156
+ }
157
+ return out;
158
+ },
159
+ },
160
+ {
161
+ id: "animation-value-out-of-range",
162
+ run: ({ part }) => {
163
+ const ranges = paramRanges(part);
164
+ const out = [];
165
+ for (const [name, a] of animEntries(part)) {
166
+ rawSteps(a).forEach((s, i) => {
167
+ for (const [key, kf] of Object.entries(isPlainObject(s.tracks) ? s.tracks : {})) {
168
+ const r = ranges.get(key);
169
+ if (!r || !validKeyframes(kf)) continue;
170
+ for (const [, v] of kf) {
171
+ if ((typeof r.min === "number" && v < r.min) || (typeof r.max === "number" && v > r.max)) {
172
+ out.push(err("animation-value-out-of-range",
173
+ `animation "${name}" track "${key}" keyframe value ${v}, outside the control's range ${r.min ?? "-∞"}..${r.max ?? "∞"}`,
174
+ "Keyframe values are applied as-is (the engine does not clamp) — widen the control's range or move the keyframe inside it.",
175
+ `animations.${name}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
176
+ break; // one finding per track
177
+ }
178
+ }
179
+ }
180
+ });
181
+ }
182
+ return out;
183
+ },
184
+ },
185
+ {
186
+ id: "animation-duration-invalid",
187
+ run: ({ part }) => {
188
+ const out = [];
189
+ for (const [name, a] of animEntries(part)) {
190
+ rawSteps(a).forEach((s, i) => {
191
+ if (!(typeof s.duration === "number" && Number.isFinite(s.duration) && s.duration > 0)) {
192
+ out.push(err("animation-duration-invalid",
193
+ `animation "${name}"${a.steps ? ` step ${i}` : ""} has no positive \`duration\``,
194
+ "Every animation (or step) needs a finite `duration` in seconds, greater than 0.",
195
+ `animations.${name}${a.steps ? `.steps[${i}]` : ""}.duration`));
196
+ }
197
+ });
198
+ }
199
+ return out;
200
+ },
201
+ },
202
+ {
203
+ id: "animation-loop-invalid",
204
+ run: ({ part }) => animEntries(part)
205
+ .filter(([, a]) => a.loop === true && Array.isArray(a.steps) && a.steps.length > 1)
206
+ .map(([name]) => err("animation-loop-invalid",
207
+ `animation "${name}" sets \`loop: true\` on a multi-step animation`,
208
+ "Loop is for continuous single-phase motion (gears). A stepped sequence replays via the transport instead — drop `loop` or collapse to one step.",
209
+ `animations.${name}.loop`)),
210
+ },
211
+ {
212
+ id: "animation-step-label-duplicate",
213
+ run: ({ part }) => {
214
+ const out = [];
215
+ for (const [name, a] of animEntries(part)) {
216
+ if (!Array.isArray(a.steps)) continue;
217
+ const seen = new Set();
218
+ a.steps.forEach((s, i) => {
219
+ const label = s?.label;
220
+ if (typeof label !== "string") return;
221
+ if (seen.has(label)) {
222
+ out.push(err("animation-step-label-duplicate",
223
+ `animation "${name}" repeats the step label "${label}"`,
224
+ "Step labels identify steps in the transport UI and the CLI's `--step <label>` — make each unique.",
225
+ `animations.${name}.steps[${i}].label`));
226
+ }
227
+ seen.add(label);
228
+ });
229
+ }
230
+ return out;
231
+ },
232
+ },
233
+ {
234
+ id: "animation-easing-unknown",
235
+ run: ({ part }) => {
236
+ const out = [];
237
+ const check = (easing, path) => {
238
+ if (easing !== undefined && !(easing in EASINGS)) {
239
+ out.push(err("animation-easing-unknown",
240
+ `unknown easing "${easing}"`,
241
+ `Use one of: ${Object.keys(EASINGS).join(", ")}.`,
242
+ path));
243
+ }
244
+ };
245
+ for (const [name, a] of animEntries(part)) {
246
+ check(a.easing, `animations.${name}.easing`);
247
+ if (Array.isArray(a.steps)) a.steps.forEach((s, i) => check(s?.easing, `animations.${name}.steps[${i}].easing`));
248
+ }
249
+ return out;
250
+ },
251
+ },
252
+ {
253
+ id: "animation-camera-invalid",
254
+ run: ({ part }) => {
255
+ const out = [];
256
+ const badName = (v) => typeof v !== "string" || !CANONICAL_VIEWS.includes(v);
257
+ for (const [name, a] of animEntries(part)) {
258
+ const stepCameras = Array.isArray(a.steps)
259
+ ? a.steps.map((s, i) => [s?.camera, i]).filter(([c]) => c !== undefined && c !== null)
260
+ : [];
261
+ if (a.camera !== undefined && stepCameras.length) {
262
+ out.push(err("animation-camera-invalid",
263
+ `animation "${name}" mixes an animation-level \`camera\` with per-step cameras`,
264
+ "One camera mechanism per animation: either the animation-level name/cue-list, or per-step names — not both.",
265
+ `animations.${name}.camera`));
266
+ }
267
+ for (const [cam, i] of stepCameras) {
268
+ if (badName(cam)) {
269
+ out.push(err("animation-camera-invalid",
270
+ `animation "${name}" step ${i} camera "${cam}" is not a canonical angle`,
271
+ `Camera cues use the canonical angles: ${CANONICAL_VIEWS.join(", ")}.`,
272
+ `animations.${name}.steps[${i}].camera`));
273
+ }
274
+ }
275
+ if (a.camera === undefined) continue;
276
+ if (typeof a.camera === "string") {
277
+ if (badName(a.camera)) {
278
+ out.push(err("animation-camera-invalid",
279
+ `animation "${name}" camera "${a.camera}" is not a canonical angle`,
280
+ `Camera cues use the canonical angles: ${CANONICAL_VIEWS.join(", ")}.`,
281
+ `animations.${name}.camera`));
282
+ }
283
+ } else if (Array.isArray(a.camera)) {
284
+ const cues = a.camera;
285
+ const wellFormed = cues.length > 0 && cues.every((c) =>
286
+ Array.isArray(c) && c.length === 2 && Number.isFinite(c[0]) && c[0] >= 0 && c[0] <= 1 && !badName(c[1]));
287
+ const sorted = cues.every((c, i) => i === 0 || (Array.isArray(c) && Array.isArray(cues[i - 1]) && c[0] > cues[i - 1][0]));
288
+ if (!wellFormed || !sorted) {
289
+ out.push(err("animation-camera-invalid",
290
+ `animation "${name}" has an invalid camera cue list`,
291
+ `Cues are \`[[t, angle], …]\` with t strictly ascending in 0..1 and angles from: ${CANONICAL_VIEWS.join(", ")}.`,
292
+ `animations.${name}.camera`));
293
+ }
294
+ } else {
295
+ out.push(err("animation-camera-invalid",
296
+ `animation "${name}" \`camera\` is neither an angle name nor a cue list`,
297
+ "Use a canonical angle string, or `[[t, angle], …]` cues.",
298
+ `animations.${name}.camera`));
299
+ }
300
+ }
301
+ return out;
302
+ },
303
+ },
304
+ {
305
+ id: "animation-description-invalid",
306
+ run: ({ part }) => animEntries(part)
307
+ .filter(([, a]) => a.description !== undefined && typeof a.description !== "string")
308
+ .map(([name]) => err("animation-description-invalid",
309
+ `animation "${name}" \`description\` is not a string`,
310
+ "The description is CommonMark shown behind the ⓘ glyph — supply a string or omit it.",
311
+ `animations.${name}.description`)),
312
+ },
313
+ {
314
+ // note tier: performance shape, not correctness. A track whose param feeds
315
+ // real geometry still plays — just best-effort at worker cadence instead
316
+ // of frame rate — and the authoring agent should know which it wrote.
317
+ id: "animation-track-rebuilds",
318
+ run: ({ part, p }) => {
319
+ const out = [];
320
+ for (const [name, a] of animEntries(part)) {
321
+ const steps = rawSteps(a);
322
+ // value range per key: the min and max across every keyframe value the
323
+ // key ever takes, over every step that tracks it — not just the first
324
+ // and last keyframe, so an out-and-back track (e.g. a hinge cycle that
325
+ // returns to its start) still compares two genuinely different values.
326
+ const valueRange = new Map();
327
+ for (const s of steps) {
328
+ for (const [key, kf] of Object.entries(isPlainObject(s.tracks) ? s.tracks : {})) {
329
+ if (!validKeyframes(kf)) continue; // keyframes rule already reported it
330
+ for (const [, v] of kf) {
331
+ if (!valueRange.has(key)) valueRange.set(key, [v, v]);
332
+ else {
333
+ const range = valueRange.get(key);
334
+ if (v < range[0]) range[0] = v;
335
+ if (v > range[1]) range[1] = v;
336
+ }
337
+ }
338
+ }
339
+ }
340
+ for (const [key, [v0, v1]] of valueRange) {
341
+ if (typeof part?.defaults?.[key] !== "number") continue; // other rules own that
342
+ const cls = classifyTrack(part, p, key, v0, v1);
343
+ if (cls === "pose") continue;
344
+ out.push(note("animation-track-rebuilds",
345
+ cls === "rebuild"
346
+ ? `animation "${name}" track "${key}" rebuilds geometry — playback is best-effort, not frame-rate`
347
+ : `animation "${name}" track "${key}" cannot use the pose fast path (untrusted probe) — playback is best-effort`,
348
+ "Frame-rate playback needs the param to feed only rigid placement (translate/rotate in `place()` or at the end of `build`). If that's the intent, restructure so the param never feeds a geometry op, a query, or a function selector; if geometry morphing is the intent, this is expected.",
349
+ `animations.${name}`));
350
+ }
351
+ }
352
+ return out;
353
+ },
354
+ },
355
+ {
356
+ id: "animation-autoplay-invalid",
357
+ run: ({ part }) => {
358
+ const out = [];
359
+ let first = null;
360
+ for (const [name, a] of animEntries(part)) {
361
+ if (a.autoplay !== undefined && typeof a.autoplay !== "boolean") {
362
+ out.push(err("animation-autoplay-invalid",
363
+ `animation "${name}" \`autoplay\` is not a boolean`,
364
+ "Use `autoplay: true` on the one animation that should start on its own.",
365
+ `animations.${name}.autoplay`));
366
+ continue;
367
+ }
368
+ if (a.autoplay !== true) continue;
369
+ if (first == null) { first = name; continue; }
370
+ out.push(err("animation-autoplay-invalid",
371
+ `animations "${first}" and "${name}" both declare \`autoplay\``,
372
+ "Only one animation can auto-start — remove `autoplay` from all but one.",
373
+ `animations.${name}.autoplay`));
374
+ }
375
+ return out;
376
+ },
377
+ },
378
+ ];
379
+
380
+ // Classify one animated param by probing every sub-part it can show, at the
381
+ // track's two endpoint values: identical trusted baseHashes at both ends →
382
+ // the param only re-poses ("pose"); differing hashes → real geometry
383
+ // ("rebuild"); any untrusted probe → "untrusted" (the fast path will decline
384
+ // it at runtime too). Mirrors the runtime trust model in pose-probe-core.js.
385
+ function classifyTrack(part, p, key, v0, v1) {
386
+ let result = "pose";
387
+ for (const view of Object.keys(isPlainObject(part?.views) ? part.views : {})) {
388
+ for (const sp of Object.values(isPlainObject(part?.parts) ? part.parts : {})) {
389
+ if (!Array.isArray(sp?.views) || !sp.views.includes(view)) continue;
390
+ const probes = [];
391
+ for (const v of [v0, v1]) {
392
+ const pv = { ...p, [key]: v };
393
+ let dv;
394
+ try { dv = resolveDerived(part, pv) ?? {}; } catch { return "untrusted"; }
395
+ try { if (sp.enabled && !sp.enabled(pv)) { probes.push(null); continue; } } catch { return "untrusted"; }
396
+ probes.push(probeSubPartPose(sp, { view, purpose: "display", p: pv, d: dv }));
397
+ }
398
+ if (probes.some((x) => x && !x.trusted)) return "untrusted";
399
+ const [a, b] = probes;
400
+ if (a && b && a.baseHash !== b.baseHash) result = "rebuild";
401
+ }
402
+ }
403
+ return result;
404
+ }