partforge 0.40.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 +46 -6
- package/bin/cli.js +103 -27
- package/docs/AUTHORING-PARTS.md +143 -15
- 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 +23 -42
- 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 +113 -18
- package/src/{testing → framework/oracle}/build.js +1 -1
- package/src/framework/oracle/bvh.js +463 -0
- package/src/{testing → framework/oracle}/gaps.js +6 -3
- package/src/{testing → framework/oracle}/measure.js +34 -4
- package/src/framework/oracle/min-wall.js +98 -0
- package/src/{testing → framework/oracle}/verify.js +61 -5
- 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 +19 -6
- 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 +126 -16
- 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/bvh.js +0 -273
- package/src/testing/min-wall.js +0 -38
- /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}/mesh.js +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Group 6 — the two place() invariants (docs/AUTHORING-PARTS.md "Display vs
|
|
2
|
+
// export placement"), promoted from doc-only conventions to lint because the
|
|
3
|
+
// animation system leans on place() for every pose-only track:
|
|
4
|
+
// 1. Display placement must not depend on the active view (display meshes
|
|
5
|
+
// are cached across views; a view-dependent pose serves stale geometry).
|
|
6
|
+
// 2. Display vs export may differ only by a rigid motion (translate/rotate).
|
|
7
|
+
// Both checks run the geometry-free pose probe; an untrusted probe (query op /
|
|
8
|
+
// function selector in build or place) proves nothing and stays silent — the
|
|
9
|
+
// runtime declines the fast path for those sub-parts anyway.
|
|
10
|
+
import { err } from "./finding.js";
|
|
11
|
+
import { probeSubPartPose } from "../pose-probe-core.js";
|
|
12
|
+
|
|
13
|
+
const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
|
|
14
|
+
|
|
15
|
+
// The sub-part names visible in a view at params p — restated locally (like
|
|
16
|
+
// rules-schema.js restates controls.js's visibility predicates) rather than
|
|
17
|
+
// imported from jobs.js, which would break the lint purity closure.
|
|
18
|
+
function viewNames(part, view, p) {
|
|
19
|
+
return Object.entries(isPlainObject(part?.parts) ? part.parts : {})
|
|
20
|
+
.filter(([, sp]) => Array.isArray(sp?.views) && sp.views.includes(view))
|
|
21
|
+
.filter(([, sp]) => { try { return sp.enabled ? !!sp.enabled(p) : true; } catch { return false; } })
|
|
22
|
+
.map(([name]) => name);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const poseKey = (pose) => JSON.stringify(pose);
|
|
26
|
+
|
|
27
|
+
export const PLACE_RULES = [
|
|
28
|
+
{
|
|
29
|
+
id: "view-dependent-display-place",
|
|
30
|
+
run: ({ part, p, d }) => {
|
|
31
|
+
const out = [];
|
|
32
|
+
const views = Object.keys(isPlainObject(part?.views) ? part.views : {});
|
|
33
|
+
if (views.length < 2) return out;
|
|
34
|
+
for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
|
|
35
|
+
const inViews = views.filter((v) => viewNames(part, v, p).includes(name));
|
|
36
|
+
if (inViews.length < 2) continue;
|
|
37
|
+
const probes = inViews.map((view) => probeSubPartPose(sp, { view, purpose: "display", p, d }));
|
|
38
|
+
if (probes.some((x) => !x.trusted)) continue;
|
|
39
|
+
const first = probes[0];
|
|
40
|
+
const differs = probes.some((x) => x.baseHash !== first.baseHash || poseKey(x.pose) !== poseKey(first.pose));
|
|
41
|
+
if (differs) {
|
|
42
|
+
out.push(err("view-dependent-display-place",
|
|
43
|
+
`sub-part "${name}" display placement differs between views (${inViews.join(", ")})`,
|
|
44
|
+
"Display meshes are built once per sub-part and cached across views, so a view-dependent display pose shows stale geometry after a tab switch. Only `place(..., { purpose: \"export\" })` may vary; keep the display branch view-independent.",
|
|
45
|
+
`parts.${name}.place`,
|
|
46
|
+
"view-dependent-display-place"));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "place-not-rigid",
|
|
54
|
+
run: ({ part, p, d }) => {
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
|
|
57
|
+
if (!sp?.place) continue;
|
|
58
|
+
for (const view of Object.keys(isPlainObject(part?.views) ? part.views : {})) {
|
|
59
|
+
if (!viewNames(part, view, p).includes(name)) continue;
|
|
60
|
+
const display = probeSubPartPose(sp, { view, purpose: "display", p, d });
|
|
61
|
+
const exportP = probeSubPartPose(sp, { view, purpose: "export", p, d });
|
|
62
|
+
if (!display.trusted || !exportP.trusted) continue;
|
|
63
|
+
if (display.baseHash !== exportP.baseHash) {
|
|
64
|
+
out.push(err("place-not-rigid",
|
|
65
|
+
`sub-part "${name}" display and export placements differ by more than a rigid motion (view "${view}")`,
|
|
66
|
+
"place() may move a solid between purposes (translate/rotate) but never reshape it — a geometry op on one branch means the exported part is not the previewed part. Move the op into build().",
|
|
67
|
+
`parts.${name}.place`,
|
|
68
|
+
"place-not-rigid"));
|
|
69
|
+
break; // one finding per sub-part — further views add nothing
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
];
|
|
@@ -83,4 +83,16 @@ export const SHAPE_RULES = [
|
|
|
83
83
|
`views.${v}`));
|
|
84
84
|
},
|
|
85
85
|
},
|
|
86
|
+
{
|
|
87
|
+
id: "default-view-ambiguous",
|
|
88
|
+
run: ({ part }) => {
|
|
89
|
+
if (!isPlainObject(part?.views)) return [];
|
|
90
|
+
const flagged = Object.keys(part.views).filter((v) => part.views[v]?.default === true);
|
|
91
|
+
if (flagged.length < 2) return [];
|
|
92
|
+
return [warn("default-view-ambiguous",
|
|
93
|
+
`${flagged.length} views set \`default: true\`: ${flagged.map((v) => `"${v}"`).join(", ")}`,
|
|
94
|
+
`Only one view can open by default. The viewer takes the first one declared — "${flagged[0]}" — and ignores the rest; remove \`default: true\` from the others.`,
|
|
95
|
+
`views.${flagged[1]}.default`)];
|
|
96
|
+
},
|
|
97
|
+
},
|
|
86
98
|
];
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// Catching them statically removes both the wasted boot and the stdout caveat.
|
|
5
5
|
import { err } from "./finding.js";
|
|
6
6
|
import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
|
|
7
|
-
import { PROFILES } from "
|
|
8
|
-
import { parseAssertion } from "
|
|
7
|
+
import { PROFILES } from "../oracle/dfm-profiles.js";
|
|
8
|
+
import { parseAssertion } from "../oracle/assert-dsl.js";
|
|
9
9
|
import { suggest } from "../geometry/op-options.js";
|
|
10
10
|
|
|
11
11
|
// Resolve `expect` to a plain object. The function form (p, d) => ({…}) is invoked
|