partforge 0.41.0 → 0.45.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 +138 -27
- package/docs/AUTHORING-PARTS.md +164 -17
- 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 +254 -0
- package/src/framework/animation.js +271 -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/capture-build.js +59 -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 +30 -40
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +441 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-schema.js +22 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +147 -20
- 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 +35 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer-lighting.js +8 -1
- package/src/framework/viewer.js +139 -20
- package/src/framework/worker.js +5 -1
- 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 +259 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +409 -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
|
@@ -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,441 @@
|
|
|
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
|
+
const steps = rawSteps(a);
|
|
88
|
+
const trackful = (s) => isPlainObject(s.tracks) && Object.keys(s.tracks).length > 0;
|
|
89
|
+
if (!steps.some(trackful)) {
|
|
90
|
+
out.push(err("animation-tracks-or-steps",
|
|
91
|
+
`animation "${name}" animates nothing`,
|
|
92
|
+
"At least one step needs a non-empty `tracks` object mapping a param key to keyframes.",
|
|
93
|
+
hasSteps ? `animations.${name}.steps` : `animations.${name}.tracks`));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
steps.forEach((s, i) => {
|
|
97
|
+
if (trackful(s)) return;
|
|
98
|
+
// A camera-only step is legal: it holds the pose and just moves the
|
|
99
|
+
// camera — an establishing shot before the motion starts. The runtime
|
|
100
|
+
// emits its cue and evaluate() holds the surrounding values, so lint
|
|
101
|
+
// must not reject what plays correctly.
|
|
102
|
+
if (hasSteps && s.camera != null) return;
|
|
103
|
+
out.push(err("animation-tracks-or-steps",
|
|
104
|
+
`animation "${name}"${hasSteps ? ` step ${i}` : ""} has no tracks`,
|
|
105
|
+
"Every step needs a non-empty `tracks` object mapping a param key to keyframes — or, for a step that only moves the camera, a `camera` angle.",
|
|
106
|
+
hasSteps ? `animations.${name}.steps[${i}].tracks` : `animations.${name}.tracks`));
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "animation-unknown-param",
|
|
114
|
+
run: ({ part }) => {
|
|
115
|
+
if (!isPlainObject(part?.defaults)) return [];
|
|
116
|
+
const known = new Set(Object.keys(part.defaults));
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const [name, a] of animEntries(part)) {
|
|
119
|
+
rawSteps(a).forEach((s, i) => {
|
|
120
|
+
for (const key of Object.keys(isPlainObject(s.tracks) ? s.tracks : {})) {
|
|
121
|
+
if (!known.has(key)) {
|
|
122
|
+
out.push(err("animation-unknown-param",
|
|
123
|
+
`animation "${name}" tracks "${key}", which is not in \`defaults\``,
|
|
124
|
+
`Animations drive existing params — add "${key}" to \`defaults\` (and a control for it), or correct the key.`,
|
|
125
|
+
`animations.${name}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: "animation-param-not-numeric",
|
|
135
|
+
run: ({ part }) => {
|
|
136
|
+
if (!isPlainObject(part?.defaults)) return [];
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const [name, a] of animEntries(part)) {
|
|
139
|
+
rawSteps(a).forEach((s, i) => {
|
|
140
|
+
for (const key of Object.keys(isPlainObject(s.tracks) ? s.tracks : {})) {
|
|
141
|
+
if (key in part.defaults && typeof part.defaults[key] !== "number") {
|
|
142
|
+
out.push(err("animation-param-not-numeric",
|
|
143
|
+
`animation "${name}" tracks "${key}", whose default is not a number`,
|
|
144
|
+
"v1 animations interpolate numeric params only — text/choice params cannot be keyframed.",
|
|
145
|
+
`animations.${name}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: "animation-keyframes-invalid",
|
|
155
|
+
run: ({ part }) => {
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const [name, a] of animEntries(part)) {
|
|
158
|
+
rawSteps(a).forEach((s, i) => {
|
|
159
|
+
for (const [key, kf] of Object.entries(isPlainObject(s.tracks) ? s.tracks : {})) {
|
|
160
|
+
if (!validKeyframes(kf)) {
|
|
161
|
+
out.push(err("animation-keyframes-invalid",
|
|
162
|
+
`animation "${name}" track "${key}" has invalid keyframes`,
|
|
163
|
+
"Keyframes are `[[t, value], …]` with finite numbers, at least two entries, `t` strictly ascending from exactly 0 to exactly 1.",
|
|
164
|
+
`animations.${name}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "animation-value-out-of-range",
|
|
174
|
+
run: ({ part }) => {
|
|
175
|
+
const ranges = paramRanges(part);
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const [name, a] of animEntries(part)) {
|
|
178
|
+
rawSteps(a).forEach((s, i) => {
|
|
179
|
+
for (const [key, kf] of Object.entries(isPlainObject(s.tracks) ? s.tracks : {})) {
|
|
180
|
+
const r = ranges.get(key);
|
|
181
|
+
if (!r || !validKeyframes(kf)) continue;
|
|
182
|
+
for (const [, v] of kf) {
|
|
183
|
+
if ((typeof r.min === "number" && v < r.min) || (typeof r.max === "number" && v > r.max)) {
|
|
184
|
+
out.push(err("animation-value-out-of-range",
|
|
185
|
+
`animation "${name}" track "${key}" keyframe value ${v}, outside the control's range ${r.min ?? "-∞"}..${r.max ?? "∞"}`,
|
|
186
|
+
"Keyframe values are applied as-is (the engine does not clamp) — widen the control's range or move the keyframe inside it.",
|
|
187
|
+
`animations.${name}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
|
|
188
|
+
break; // one finding per track
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: "animation-duration-invalid",
|
|
199
|
+
run: ({ part }) => {
|
|
200
|
+
const out = [];
|
|
201
|
+
for (const [name, a] of animEntries(part)) {
|
|
202
|
+
rawSteps(a).forEach((s, i) => {
|
|
203
|
+
if (!(typeof s.duration === "number" && Number.isFinite(s.duration) && s.duration > 0)) {
|
|
204
|
+
out.push(err("animation-duration-invalid",
|
|
205
|
+
`animation "${name}"${a.steps ? ` step ${i}` : ""} has no positive \`duration\``,
|
|
206
|
+
"Every animation (or step) needs a finite `duration` in seconds, greater than 0.",
|
|
207
|
+
`animations.${name}${a.steps ? `.steps[${i}]` : ""}.duration`));
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: "animation-loop-invalid",
|
|
216
|
+
run: ({ part }) => {
|
|
217
|
+
const out = [];
|
|
218
|
+
for (const [name, a] of animEntries(part)) {
|
|
219
|
+
if (a.loop === undefined) continue;
|
|
220
|
+
// Type first, like `autoplay`. The runtime fails closed (normalizeAnimation
|
|
221
|
+
// reads `spec.loop === true`), so a truthy non-boolean does NOT loop — it
|
|
222
|
+
// silently means `false`. That is the safe default but not an obvious one,
|
|
223
|
+
// so the author has to hear about it here rather than wonder why `loop: 1`
|
|
224
|
+
// does nothing.
|
|
225
|
+
if (typeof a.loop !== "boolean") {
|
|
226
|
+
out.push(err("animation-loop-invalid",
|
|
227
|
+
`animation "${name}" has a non-boolean \`loop\``,
|
|
228
|
+
"`loop` must be `true` or `false`. Any other truthy value still loops at runtime, so it cannot be left to mean something else.",
|
|
229
|
+
`animations.${name}.loop`));
|
|
230
|
+
continue; // one error per field: the check below assumes a real boolean
|
|
231
|
+
}
|
|
232
|
+
if (a.loop && Array.isArray(a.steps) && a.steps.length > 1) {
|
|
233
|
+
out.push(err("animation-loop-invalid",
|
|
234
|
+
`animation "${name}" sets \`loop: true\` on a multi-step animation`,
|
|
235
|
+
"Loop is for continuous single-phase motion (gears). A stepped sequence replays via the transport instead — drop `loop` or collapse to one step.",
|
|
236
|
+
`animations.${name}.loop`));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: "animation-step-label-duplicate",
|
|
244
|
+
run: ({ part }) => {
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const [name, a] of animEntries(part)) {
|
|
247
|
+
if (!Array.isArray(a.steps)) continue;
|
|
248
|
+
const seen = new Set();
|
|
249
|
+
a.steps.forEach((s, i) => {
|
|
250
|
+
const label = s?.label;
|
|
251
|
+
if (typeof label !== "string") return;
|
|
252
|
+
if (seen.has(label)) {
|
|
253
|
+
out.push(err("animation-step-label-duplicate",
|
|
254
|
+
`animation "${name}" repeats the step label "${label}"`,
|
|
255
|
+
"Step labels identify steps in the transport UI and the CLI's `--step <label>` — make each unique.",
|
|
256
|
+
`animations.${name}.steps[${i}].label`));
|
|
257
|
+
}
|
|
258
|
+
seen.add(label);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
id: "animation-easing-unknown",
|
|
266
|
+
run: ({ part }) => {
|
|
267
|
+
const out = [];
|
|
268
|
+
const check = (easing, path) => {
|
|
269
|
+
// Own-key test, not `in`: `"toString" in EASINGS` is true, so `in` would
|
|
270
|
+
// wave through every Object.prototype member. The runtime (easingFor)
|
|
271
|
+
// applies the same test and falls back to the default, so these names are
|
|
272
|
+
// caught here rather than silently mis-animating or throwing mid-frame.
|
|
273
|
+
if (easing !== undefined && !Object.hasOwn(EASINGS, easing)) {
|
|
274
|
+
out.push(err("animation-easing-unknown",
|
|
275
|
+
`unknown easing "${easing}"`,
|
|
276
|
+
`Use one of: ${Object.keys(EASINGS).join(", ")}.`,
|
|
277
|
+
path));
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
for (const [name, a] of animEntries(part)) {
|
|
281
|
+
check(a.easing, `animations.${name}.easing`);
|
|
282
|
+
if (Array.isArray(a.steps)) a.steps.forEach((s, i) => check(s?.easing, `animations.${name}.steps[${i}].easing`));
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
id: "animation-camera-invalid",
|
|
289
|
+
run: ({ part }) => {
|
|
290
|
+
const out = [];
|
|
291
|
+
const badName = (v) => typeof v !== "string" || !CANONICAL_VIEWS.includes(v);
|
|
292
|
+
for (const [name, a] of animEntries(part)) {
|
|
293
|
+
const stepCameras = Array.isArray(a.steps)
|
|
294
|
+
? a.steps.map((s, i) => [s?.camera, i]).filter(([c]) => c !== undefined && c !== null)
|
|
295
|
+
: [];
|
|
296
|
+
if (a.camera != null && stepCameras.length) {
|
|
297
|
+
out.push(err("animation-camera-invalid",
|
|
298
|
+
`animation "${name}" mixes an animation-level \`camera\` with per-step cameras`,
|
|
299
|
+
"One camera mechanism per animation: either the animation-level name/cue-list, or per-step names — not both.",
|
|
300
|
+
`animations.${name}.camera`));
|
|
301
|
+
}
|
|
302
|
+
for (const [cam, i] of stepCameras) {
|
|
303
|
+
if (badName(cam)) {
|
|
304
|
+
out.push(err("animation-camera-invalid",
|
|
305
|
+
`animation "${name}" step ${i} camera "${cam}" is not a canonical angle`,
|
|
306
|
+
`Camera cues use the canonical angles: ${CANONICAL_VIEWS.join(", ")}.`,
|
|
307
|
+
`animations.${name}.steps[${i}].camera`));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// An explicit `camera: null` is "no camera", which is how
|
|
311
|
+
// normalizeAnimation reads it — not a malformed value to report.
|
|
312
|
+
if (a.camera == null) continue;
|
|
313
|
+
if (typeof a.camera === "string") {
|
|
314
|
+
if (badName(a.camera)) {
|
|
315
|
+
out.push(err("animation-camera-invalid",
|
|
316
|
+
`animation "${name}" camera "${a.camera}" is not a canonical angle`,
|
|
317
|
+
`Camera cues use the canonical angles: ${CANONICAL_VIEWS.join(", ")}.`,
|
|
318
|
+
`animations.${name}.camera`));
|
|
319
|
+
}
|
|
320
|
+
} else if (Array.isArray(a.camera)) {
|
|
321
|
+
const cues = a.camera;
|
|
322
|
+
const wellFormed = cues.length > 0 && cues.every((c) =>
|
|
323
|
+
Array.isArray(c) && c.length === 2 && Number.isFinite(c[0]) && c[0] >= 0 && c[0] <= 1 && !badName(c[1]));
|
|
324
|
+
const sorted = cues.every((c, i) => i === 0 || (Array.isArray(c) && Array.isArray(cues[i - 1]) && c[0] > cues[i - 1][0]));
|
|
325
|
+
if (!wellFormed || !sorted) {
|
|
326
|
+
out.push(err("animation-camera-invalid",
|
|
327
|
+
`animation "${name}" has an invalid camera cue list`,
|
|
328
|
+
`Cues are \`[[t, angle], …]\` with t strictly ascending in 0..1 and angles from: ${CANONICAL_VIEWS.join(", ")}.`,
|
|
329
|
+
`animations.${name}.camera`));
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
out.push(err("animation-camera-invalid",
|
|
333
|
+
`animation "${name}" \`camera\` is neither an angle name nor a cue list`,
|
|
334
|
+
"Use a canonical angle string, or `[[t, angle], …]` cues.",
|
|
335
|
+
`animations.${name}.camera`));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
id: "animation-description-invalid",
|
|
343
|
+
run: ({ part }) => animEntries(part)
|
|
344
|
+
.filter(([, a]) => a.description !== undefined && typeof a.description !== "string")
|
|
345
|
+
.map(([name]) => err("animation-description-invalid",
|
|
346
|
+
`animation "${name}" \`description\` is not a string`,
|
|
347
|
+
"The description is CommonMark shown behind the ⓘ glyph — supply a string or omit it.",
|
|
348
|
+
`animations.${name}.description`)),
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
// note tier: performance shape, not correctness. A track whose param feeds
|
|
352
|
+
// real geometry still plays — just best-effort at worker cadence instead
|
|
353
|
+
// of frame rate — and the authoring agent should know which it wrote.
|
|
354
|
+
id: "animation-track-rebuilds",
|
|
355
|
+
run: ({ part, p }) => {
|
|
356
|
+
const out = [];
|
|
357
|
+
for (const [name, a] of animEntries(part)) {
|
|
358
|
+
const steps = rawSteps(a);
|
|
359
|
+
// value range per key: the min and max across every keyframe value the
|
|
360
|
+
// key ever takes, over every step that tracks it — not just the first
|
|
361
|
+
// and last keyframe, so an out-and-back track (e.g. a hinge cycle that
|
|
362
|
+
// returns to its start) still compares two genuinely different values.
|
|
363
|
+
const valueRange = new Map();
|
|
364
|
+
for (const s of steps) {
|
|
365
|
+
for (const [key, kf] of Object.entries(isPlainObject(s.tracks) ? s.tracks : {})) {
|
|
366
|
+
if (!validKeyframes(kf)) continue; // keyframes rule already reported it
|
|
367
|
+
for (const [, v] of kf) {
|
|
368
|
+
if (!valueRange.has(key)) valueRange.set(key, [v, v]);
|
|
369
|
+
else {
|
|
370
|
+
const range = valueRange.get(key);
|
|
371
|
+
if (v < range[0]) range[0] = v;
|
|
372
|
+
if (v > range[1]) range[1] = v;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
for (const [key, [v0, v1]] of valueRange) {
|
|
378
|
+
if (typeof part?.defaults?.[key] !== "number") continue; // other rules own that
|
|
379
|
+
const cls = classifyTrack(part, p, key, v0, v1);
|
|
380
|
+
if (cls === "pose") continue;
|
|
381
|
+
out.push(note("animation-track-rebuilds",
|
|
382
|
+
cls === "rebuild"
|
|
383
|
+
? `animation "${name}" track "${key}" rebuilds geometry — playback is best-effort, not frame-rate`
|
|
384
|
+
: `animation "${name}" track "${key}" cannot use the pose fast path (untrusted probe) — playback is best-effort`,
|
|
385
|
+
"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.",
|
|
386
|
+
`animations.${name}`));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
id: "animation-autoplay-invalid",
|
|
394
|
+
run: ({ part }) => {
|
|
395
|
+
const out = [];
|
|
396
|
+
let first = null;
|
|
397
|
+
for (const [name, a] of animEntries(part)) {
|
|
398
|
+
if (a.autoplay !== undefined && typeof a.autoplay !== "boolean") {
|
|
399
|
+
out.push(err("animation-autoplay-invalid",
|
|
400
|
+
`animation "${name}" \`autoplay\` is not a boolean`,
|
|
401
|
+
"Use `autoplay: true` on the one animation that should start on its own.",
|
|
402
|
+
`animations.${name}.autoplay`));
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (a.autoplay !== true) continue;
|
|
406
|
+
if (first == null) { first = name; continue; }
|
|
407
|
+
out.push(err("animation-autoplay-invalid",
|
|
408
|
+
`animations "${first}" and "${name}" both declare \`autoplay\``,
|
|
409
|
+
"Only one animation can auto-start — remove `autoplay` from all but one.",
|
|
410
|
+
`animations.${name}.autoplay`));
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
];
|
|
416
|
+
|
|
417
|
+
// Classify one animated param by probing every sub-part it can show, at the
|
|
418
|
+
// track's two endpoint values: identical trusted baseHashes at both ends →
|
|
419
|
+
// the param only re-poses ("pose"); differing hashes → real geometry
|
|
420
|
+
// ("rebuild"); any untrusted probe → "untrusted" (the fast path will decline
|
|
421
|
+
// it at runtime too). Mirrors the runtime trust model in pose-probe-core.js.
|
|
422
|
+
function classifyTrack(part, p, key, v0, v1) {
|
|
423
|
+
let result = "pose";
|
|
424
|
+
for (const view of Object.keys(isPlainObject(part?.views) ? part.views : {})) {
|
|
425
|
+
for (const sp of Object.values(isPlainObject(part?.parts) ? part.parts : {})) {
|
|
426
|
+
if (!Array.isArray(sp?.views) || !sp.views.includes(view)) continue;
|
|
427
|
+
const probes = [];
|
|
428
|
+
for (const v of [v0, v1]) {
|
|
429
|
+
const pv = { ...p, [key]: v };
|
|
430
|
+
let dv;
|
|
431
|
+
try { dv = resolveDerived(part, pv) ?? {}; } catch { return "untrusted"; }
|
|
432
|
+
try { if (sp.enabled && !sp.enabled(pv)) { probes.push(null); continue; } } catch { return "untrusted"; }
|
|
433
|
+
probes.push(probeSubPartPose(sp, { view, purpose: "display", p: pv, d: dv }));
|
|
434
|
+
}
|
|
435
|
+
if (probes.some((x) => x && !x.trusted)) return "untrusted";
|
|
436
|
+
const [a, b] = probes;
|
|
437
|
+
if (a && b && a.baseHash !== b.baseHash) result = "rebuild";
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
@@ -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,6 +83,28 @@ export const SCHEMA_RULES = [
|
|
|
83
83
|
return out;
|
|
84
84
|
},
|
|
85
85
|
},
|
|
86
|
+
{
|
|
87
|
+
id: "features-requires-on",
|
|
88
|
+
run: ({ part }) => {
|
|
89
|
+
const out = [];
|
|
90
|
+
sections(part).forEach((sec, si) => {
|
|
91
|
+
if (!sectionRenders(sec)) return;
|
|
92
|
+
arr(sec?.features).forEach((f, i) => {
|
|
93
|
+
// The panel treats "enabled" as `params[key] > 0`, so `on` has to be a
|
|
94
|
+
// positive number: a missing one writes undefined (NaN in the build),
|
|
95
|
+
// and 0 or a negative writes a value the panel reads straight back as
|
|
96
|
+
// "still off", leaving a checkbox that won't stay ticked.
|
|
97
|
+
if (f && !f.hidden && !(typeof f.on === "number" && f.on > 0)) {
|
|
98
|
+
out.push(err("features-requires-on",
|
|
99
|
+
`section "${sec.id ?? si}" feature ${i}${f.key ? ` ("${f.key}")` : ""} has no positive numeric \`on\` value`,
|
|
100
|
+
"Ticking a feature's checkbox writes `on` into the feature's own parameter, so a missing one writes `undefined` and the build reads it as NaN. Unlike a `toggles` entry — a plain flag that falls back to 1 — a feature's `on` is the real value the parameter takes when enabled (a diameter, a count), so there is no safe default to guess. It must be greater than 0, because the panel reads `> 0` as \"enabled\". Give the feature the value it should switch on to.",
|
|
101
|
+
`parameters[${si}].features[${i}]`));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
return out;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
86
108
|
{
|
|
87
109
|
id: "control-key-not-in-defaults",
|
|
88
110
|
run: ({ part }) => {
|
|
@@ -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
|