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.
- package/README.md +31 -10
- package/bin/cli.js +100 -27
- package/docs/AUTHORING-PARTS.md +126 -14
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +48 -7
- package/skills/partforge/SKILL.md +17 -3
- package/src/app-embed-test.js +1 -1
- package/src/app-hinged-box.js +12 -0
- package/src/framework/animation-controls.js +243 -0
- package/src/framework/animation.js +217 -0
- package/src/framework/app.css +32 -0
- package/src/framework/assembly.js +1 -1
- package/src/framework/backend-select.js +25 -0
- package/src/framework/camera-tween.js +58 -0
- package/src/framework/chrome.css +16 -0
- package/src/framework/controls.js +13 -3
- package/src/framework/cutaway-gizmo-scene.js +244 -0
- package/src/framework/cutaway-gizmo.js +80 -243
- package/src/framework/default-view.js +46 -0
- package/src/framework/download.js +7 -2
- package/src/framework/export-controller.js +13 -2
- package/src/framework/geometry/probe.js +3 -22
- package/src/framework/jobs.js +9 -40
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +404 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +93 -18
- package/src/{testing → framework/oracle}/build.js +1 -1
- package/src/{testing → framework/oracle}/bvh.js +1 -1
- package/src/{testing → framework/oracle}/measure.js +1 -1
- package/src/{testing → framework/oracle}/min-wall.js +1 -1
- package/src/{testing → framework/oracle}/verify.js +3 -3
- package/src/framework/param-deps.js +1 -1
- package/src/framework/part-model.js +48 -0
- package/src/framework/pick-request/client.js +11 -3
- package/src/framework/pick-request/endpoint.js +60 -0
- package/src/framework/pick-request/index.js +6 -0
- package/src/framework/pick-request/server.js +222 -34
- package/src/framework/pick-request/token-store.js +31 -0
- package/src/framework/pose-fast-path.js +12 -1
- package/src/framework/pose-probe-core.js +129 -0
- package/src/framework/pose-probe.js +7 -123
- package/src/framework/regen-loop.js +10 -3
- package/src/framework/safe-name.js +26 -0
- package/src/framework/verify-metrics.js +4 -4
- package/src/framework/view-state.js +25 -21
- package/src/framework/view-tabs.js +22 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer.js +58 -17
- package/src/hinged-box-worker.js +3 -0
- package/src/index.js +1 -1
- package/src/parts/hinged-box.js +94 -0
- package/src/testing/render.js +19 -8
- package/src/testing.js +15 -8
- package/types/derive.d.ts +14 -0
- package/types/geometry.d.ts +117 -0
- package/types/index.d.ts +240 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +381 -0
- package/types/testing.d.ts +362 -0
- package/types/worker.d.ts +21 -0
- /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
- /package/src/{testing → framework/oracle}/cases.js +0 -0
- /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
- /package/src/{testing → framework/oracle}/gaps.js +0 -0
- /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
|
|
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()
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -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 {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
+
}
|