cozyclay 3.1.1 → 3.1.2
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/CHANGELOG.md +39 -0
- package/bin/agent/agent-routes.mjs +4 -4
- package/bin/agent/main-agent.mjs +21 -7
- package/bin/agent/main-prompt.mjs +7 -6
- package/bin/agent/script-tool.mjs +2 -1
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-rebase.mjs +5 -4
- package/bin/agent/studio-task-routes.mjs +1 -1
- package/bin/agent/studio-tasks.mjs +116 -25
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/cozyclay.mjs +4 -2
- package/bin/live-endpoint.mjs +2 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/TaskBoard-GR8xZm5c.js +1 -0
- package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
- package/dist/assets/app-C4QTcDcb.js +5144 -0
- package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
- package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
- package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
- package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
- package/dist/assets/task-board-BqReHzOi.js +15 -0
- package/dist/assets/workflow-C87p6E1E.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +1 -1
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +5 -3
- package/mcp/tool-handlers.mjs +5 -5
- package/package.json +1 -1
- package/src/App.jsx +42 -64
- package/src/bus-task-scopes.js +59 -11
- package/src/bus-task-targets.js +35 -9
- package/src/bus-tasks.js +23 -10
- package/src/command-bus.js +7 -4
- package/src/commands/ai.js +1 -1
- package/src/commands/export.js +1 -1
- package/src/commands/motion3.js +27 -7
- package/src/export-workspace.js +244 -0
- package/src/keyframe-pack.js +5 -1
- package/src/live-control.js +14 -7
- package/src/motion3/compile-job.js +15 -3
- package/src/motion3/compiler/channels.js +74 -24
- package/src/motion3/compiler/compile.js +2 -1
- package/src/motion3/compiler/idle.js +2 -0
- package/src/motion3/compiler/overlap.js +180 -0
- package/src/motion3/compiler/pose.js +6 -4
- package/src/motion3/compiler/validate-common.js +1 -0
- package/src/motion3/compiler/validate-parts.js +5 -1
- package/src/motion3/compiler/validate.js +30 -10
- package/src/motion3/correct.js +236 -0
- package/src/motion3/docs.js +8 -6
- package/src/motion3/face-lean.js +31 -0
- package/src/motion3/fix/index.js +5 -1
- package/src/motion3/fix/reach-body.js +172 -0
- package/src/motion3/hint-check.js +44 -0
- package/src/motion3/json-patch.js +120 -12
- package/src/motion3/lenient.js +46 -4
- package/src/motion3/object-keys.js +17 -5
- package/src/motion3/pipeline.js +46 -9
- package/src/motion3/qa/arm-checks.js +96 -0
- package/src/motion3/qa/asked.js +113 -0
- package/src/motion3/qa/goals.js +37 -8
- package/src/motion3/qa/hints-goal.js +6 -5
- package/src/motion3/qa/index.js +5 -2
- package/src/motion3/qa/key-checks.js +4 -3
- package/src/motion3/qa/motion-life.js +243 -0
- package/src/motion3/qa/spec-checks.js +15 -9
- package/src/motion3/qa/verdict.js +50 -0
- package/src/motion3/refs.js +38 -14
- package/src/motion3/repair-watch.js +132 -0
- package/src/motion3/repair.js +52 -19
- package/src/motion3/report.js +50 -9
- package/src/motion3/schema/topics.js +6 -4
- package/src/motion3/schema/types.js +1 -1
- package/src/motion3/skills/basic.js +15 -8
- package/src/motion3/skills/index.js +52 -18
- package/src/motion3/skills/info.js +1 -1
- package/src/motion3/skills/stance.js +6 -0
- package/src/motion3/skills/walk-fit.js +170 -0
- package/src/panels/PoseImagePanel.jsx +3 -2
- package/src/panels/SculptPanel.jsx +2 -2
- package/src/result-modal.jsx +3 -3
- package/src/shell/ExportFolderPreference.jsx +103 -0
- package/src/shell/PreferencesDialog.jsx +3 -166
- package/src/shell/StatusBar.jsx +1 -1
- package/src/shell/preference-controls.jsx +114 -0
- package/src/shell/preference-pages.jsx +72 -0
- package/src/shell/preferences.css +46 -0
- package/src/shot-prompt.js +29 -9
- package/src/studio-actions.js +2 -2
- package/src/studio-agent-motion.js +19 -9
- package/src/studio-agent-protocol.js +1 -1
- package/src/studio-app-binding.js +3 -2
- package/src/workflow/AgentPanel.jsx +2 -2
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/WorkflowBuilder.jsx +2 -2
- package/src/workflow/keyframe-pack-request.js +3 -10
- package/src/workflow/task-board.css +1 -1
- package/src/workflow/task-board.js +7 -5
- package/tools/loc-baseline.json +1 -2
- package/tools/run-tests.mjs +5 -5
- package/dist/assets/TaskBoard-ngdqiu7u.js +0 -1
- package/dist/assets/app-DmR7jBkz.js +0 -5144
- package/dist/assets/compile-worker-D0pKioa0.js +0 -5
- package/dist/assets/motion3-compile-BB3_1-EB.js +0 -1
- package/dist/assets/scene-asset-sync-CBx7XRti.css +0 -1
- package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +0 -13
- package/dist/assets/task-board-ErQvfoaz.js +0 -14
- package/dist/assets/workflow-zQM-EKW1.js +0 -179
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
// flow is gone by the time the arm is a quarter of the way onto it: an arm's swing and bend by its
|
|
13
13
|
// own armw, the spine (which carries the shoulders) and the head (the hands work near it) by the
|
|
14
14
|
// larger of the two; a reach or a held prop lands as keyed. A hold (two equal keys in a row) stays
|
|
15
|
-
// exactly flat either way.
|
|
15
|
+
// exactly flat either way. Overlap (overlap.js) breaks each change along the chains on top of that: the
|
|
16
|
+
// lower spine, the chest (chest_*: the top spine bone's share, baked beside spine_*), the head, the upper
|
|
17
|
+
// arm, the forearm and the wrist each take a later delay, one side of the body trails the other, and the
|
|
18
|
+
// arm's swing and bend overshoot and settle where they stop.
|
|
19
|
+
import { overlapDelay, sideTrail, settleBump, ikGap, contactGap, OVERLAP_SLOPE } from "./overlap.js";
|
|
16
20
|
|
|
17
21
|
function pchipSlopes(x, y) {
|
|
18
22
|
const n = y.length, m = new Array(n).fill(0);
|
|
@@ -37,18 +41,24 @@ function pchipEval(x, y, m, q) {
|
|
|
37
41
|
return (2 * t3 - 3 * t2 + 1) * y[i] + (t3 - 2 * t2 + t) * h * m[i] + (-2 * t3 + 3 * t2) * y[i + 1] + (t3 - t2) * h * m[i + 1];
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
const FLOW_SMOOTH = /^(spine|head)_(yaw|pitch|roll)$|^(aswing|abend)_[lr]$/;
|
|
44
|
+
const FLOW_SMOOTH = /^(spine|chest|head)_(yaw|pitch|roll)$|^(aswing|abend)_[lr]$/;
|
|
41
45
|
const FLOW_LAG = { spine: 0, head: 2, arms: 3 };
|
|
46
|
+
const ARM = /^(hand|armw|elbow|aswing|abend|wflex|wtwist)_[lr]$/; // one side's arm: trails as one (overlap.js sideTrail)
|
|
42
47
|
const OVER_FRAC = 0.15, OVER_MAX = 6; // a smooth segment's overshoot: at most this share of its change, and degrees
|
|
43
|
-
const ikSides = ch => (/^(spine|head)_/.test(ch) ? ["l", "r"] : /^(aswing|abend)_([lr])$/.test(ch) ? [ch.slice(-1)] : []);
|
|
44
|
-
const lagFamily = ch => (/^
|
|
48
|
+
const ikSides = ch => (/^(spine|chest|head)_/.test(ch) ? ["l", "r"] : /^(aswing|abend|wflex|wtwist)_([lr])$/.test(ch) ? [ch.slice(-1)] : []);
|
|
49
|
+
const lagFamily = ch => (/^(spine|chest)_/.test(ch) ? "spine" : /^head_/.test(ch) ? "head" : /^(aswing|abend)_[lr]$/.test(ch) ? "arms" : null);
|
|
45
50
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
/**
|
|
52
|
+
* spec.flow -> { smooth, lag: { spine, head, arms }, overlap, seed, hands }: undefined is the default, false turns all off.
|
|
53
|
+
* overlap: true (1) | false (0) | a scale of its delays and settle (overlap.js); seed (the spec's label) picks which side trails;
|
|
54
|
+
* hands: the frames each hand works on something (overlap.js handBusy), where a free IK hand's trail stops.
|
|
55
|
+
*/
|
|
56
|
+
export function flowOptions(flow, seed = "", hands = { l: [], r: [] }) {
|
|
57
|
+
if (flow === false) return { smooth: false, lag: { spine: 0, head: 0, arms: 0 }, overlap: 0, seed, hands };
|
|
49
58
|
const f = flow ?? {}, lag = f.lag === false ? 0 : f.lag;
|
|
50
59
|
const each = typeof lag === "number" ? { spine: lag, head: lag, arms: lag } : { ...FLOW_LAG, ...(lag ?? {}) };
|
|
51
|
-
|
|
60
|
+
const overlap = f.overlap === false ? 0 : typeof f.overlap === "number" ? f.overlap : 1;
|
|
61
|
+
return { smooth: f.smooth !== false, lag: each, overlap, seed, hands };
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
/** Catmull-Rom slopes, flat at a hold and at the ends, each segment's overshoot bounded. */
|
|
@@ -78,11 +88,47 @@ export function bakeChannels(keys, frameCount, flow = flowOptions()) {
|
|
|
78
88
|
for (const key of [...keys].sort((a, b) => a.f - b.f)) {
|
|
79
89
|
for (const [ch, value] of Object.entries(key.set ?? {})) {
|
|
80
90
|
if (!byChannel.has(ch)) byChannel.set(ch, []);
|
|
81
|
-
byChannel.get(ch).push({ f: key.f, value, step: key.step?.includes(ch) });
|
|
91
|
+
byChannel.get(ch).push({ f: key.f, value, step: key.step?.includes(ch), src: key.src });
|
|
82
92
|
}
|
|
83
93
|
}
|
|
94
|
+
// the top spine bone's share breaks after the two below it (pose.js), so it is baked as its own channel
|
|
95
|
+
const overlap = flow.overlap ?? 0, entries = [...byChannel];
|
|
96
|
+
if (overlap > 0) for (const [ch, list] of byChannel) if (/^spine_/.test(ch)) entries.push([ch.replace("spine", "chest"), list]);
|
|
97
|
+
// an arm's IK weights first: a link's own delay runs out before an arm goes onto its IK and comes back after it
|
|
98
|
+
// leaves (overlap.js), so the fade below never snaps a delayed move onto its keyed frames. Before them where each
|
|
99
|
+
// hand touches: its anchor and fit weights (never delayed) and its carries and hand goals (flow.hands); a side's
|
|
100
|
+
// trail stops short of those
|
|
101
|
+
const pre = new Map(), near = new Map(), touch = {}, trails = {};
|
|
102
|
+
if (overlap > 0) {
|
|
103
|
+
for (const [ch, list] of entries) if (/^w_.+_[lr]$/.test(ch)) pre.set(ch, bakeOne(ch, list));
|
|
104
|
+
for (const k of ["l", "r"]) touch[k] = contactGap(flow.hands?.[k] ?? [], [...pre].filter(([ch]) => ch.endsWith(`_${k}`)).map(([, b]) => b.frames), frameCount);
|
|
105
|
+
// the whole arm of a side trails as one (overlap.js sideTrail), its FK swing with its IK weight, so a blend between
|
|
106
|
+
// them never sets a late weight over an on-time swing
|
|
107
|
+
for (const k of ["l", "r"]) trails[k] = sideTrail([...byChannel].filter(([ch]) => ARM.test(ch) && ch.endsWith(`_${k}`)).map(([ch, list]) => Object.assign([...list], { weight: /^armw_/.test(ch) })), k, overlap, flow.seed ?? "", touch[k]);
|
|
108
|
+
for (const [ch, list] of entries) if (/^armw_[lr]$/.test(ch)) pre.set(ch, bakeOne(ch, list));
|
|
109
|
+
}
|
|
110
|
+
const gapOf = ch => {
|
|
111
|
+
const sides = ikSides(ch).filter(k => pre.has(`armw_${k}`)), key = sides.join("");
|
|
112
|
+
if (!sides.length) return null;
|
|
113
|
+
if (!near.has(key)) near.set(key, ikGap(sides.map(k => pre.get(`armw_${k}`).frames), frameCount));
|
|
114
|
+
return near.get(key);
|
|
115
|
+
};
|
|
84
116
|
const out = {}, plain = {};
|
|
85
|
-
for (const [ch, list] of
|
|
117
|
+
for (const [ch, list] of entries) {
|
|
118
|
+
const baked = pre.get(ch) ?? bakeOne(ch, list, gapOf(ch));
|
|
119
|
+
out[ch] = baked.frames;
|
|
120
|
+
if (baked.plain) plain[ch] = baked.plain;
|
|
121
|
+
}
|
|
122
|
+
// flow fades out as the arm goes onto its IK (the spine with either arm)
|
|
123
|
+
for (const [ch, keyed] of Object.entries(plain)) {
|
|
124
|
+
const ws = ikSides(ch).map(k => out[`armw_${k}`]).filter(Boolean);
|
|
125
|
+
if (!ws.length) continue;
|
|
126
|
+
out[ch] = out[ch].map((v, f) => { const w = Math.min(1, 4 * Math.max(0, ...ws.map(a => a[f]))); return v + (keyed[f] - v) * w * (2 - w); });
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
|
|
130
|
+
/** One channel's frames (and, for a flowing body channel, its keyed frames: plain); gap: frames to the nearest IK frame. */
|
|
131
|
+
function bakeOne(ch, list, gap = null) {
|
|
86
132
|
// later keys at the same frame win
|
|
87
133
|
const pts = [...new Map(list.map(k => [k.f, k])).values()];
|
|
88
134
|
const shape = shapeOf(pts[0].value);
|
|
@@ -91,15 +137,24 @@ export function bakeChannels(keys, frameCount, flow = flowOptions()) {
|
|
|
91
137
|
: shape === "vec" ? Array.from({ length: comps }, (_, i) => pts.map(p => p.value[i]))
|
|
92
138
|
: [...new Set(pts.flatMap(p => Object.keys(p.value)))].map(name => ({ name, y: pts.map(p => p.value[name] ?? 0) }));
|
|
93
139
|
const x = pts.map(p => p.f), smooth = flow.smooth && FLOW_SMOOTH.test(ch), lag = flow.lag[lagFamily(ch)] ?? 0;
|
|
94
|
-
|
|
95
|
-
const
|
|
140
|
+
const delay = shape === "num" ? overlapDelay(ch, pts, overlap, flow.seed ?? "", gap) : null;
|
|
141
|
+
const trail = ARM.test(ch) ? trails[ch.slice(-1)] : null, most = lag + (delay?.max ?? 0);
|
|
142
|
+
const delayAt = f => (delay ? Math.min(delay.at(f), gap ? OVERLAP_SLOPE * gap[Math.round(f)] : Infinity) : 0);
|
|
143
|
+
const end = (f, d) => Math.min(1, (frameCount - 1 - f) / (2 * d));
|
|
144
|
+
// the trail eases out over the last 2 * (its most) frames, so the take ends on the last value (the side's trail over
|
|
145
|
+
// its own: a short ease there would quicken the arm's last move); the side's trail rides the keyed curve too (plain),
|
|
146
|
+
// which an arm on its IK goes back to
|
|
147
|
+
const keyedAt = f => (trail ? f - trail.at(f) * end(f, trail.max) : f);
|
|
148
|
+
const at = f => keyedAt(f) - (most > 0 ? (lag + delayAt(f)) * end(f, most) : 0);
|
|
149
|
+
// a settle near an arm's IK would be cut by the fade below: none within its span and a few frames
|
|
150
|
+
const clear = (a, b) => { if (!gap) return true; for (let f = Math.max(0, Math.floor(a)); f <= Math.min(frameCount - 1, Math.ceil(b + most)); f++) if (gap[f] < 2) return false; return true; };
|
|
96
151
|
const evalSeries = (y, flowing = true) => {
|
|
97
|
-
const m = smooth && flowing ? smoothSlopes(x, y) : pchipSlopes(x, y);
|
|
152
|
+
const m = smooth && flowing ? smoothSlopes(x, y) : pchipSlopes(x, y), bump = flowing && shape === "num" ? settleBump(ch, pts, y, overlap, frameCount - 1, clear) : null;
|
|
98
153
|
return q => {
|
|
99
|
-
const f = flowing ? at(q) : q;
|
|
154
|
+
const f = flowing ? at(q) : keyedAt(q);
|
|
100
155
|
// a step key holds the previous value right up to its frame
|
|
101
156
|
for (let i = 1; i < pts.length; i++) if (pts[i].step && f >= x[i - 1] && f < x[i]) return y[i - 1];
|
|
102
|
-
return pchipEval(x, y, m, f);
|
|
157
|
+
return pchipEval(x, y, m, f) + (bump ? bump(f) : 0);
|
|
103
158
|
};
|
|
104
159
|
};
|
|
105
160
|
const frames = new Array(frameCount);
|
|
@@ -109,16 +164,11 @@ export function bakeChannels(keys, frameCount, flow = flowOptions()) {
|
|
|
109
164
|
const es = series.map(({ name, y }) => [name, evalSeries(y)]);
|
|
110
165
|
for (let f = 0; f < frameCount; f++) frames[f] = Object.fromEntries(es.map(([n, e]) => [n, e(f)]));
|
|
111
166
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
for (const [ch, keyed] of Object.entries(plain)) {
|
|
117
|
-
const ws = ikSides(ch).map(k => out[`armw_${k}`]).filter(Boolean);
|
|
118
|
-
if (!ws.length) continue;
|
|
119
|
-
out[ch] = out[ch].map((v, f) => { const w = Math.min(1, 4 * Math.max(0, ...ws.map(a => a[f]))); return v + (keyed[f] - v) * w * (2 - w); });
|
|
167
|
+
if (!(shape === "num" && (smooth || most > 0) && ikSides(ch).length)) return { frames };
|
|
168
|
+
// an arm's own channels keep the side's trail on its IK too (plain), only the links' own delays and settle fade
|
|
169
|
+
const e = evalSeries(series[0], false);
|
|
170
|
+
return { frames, plain: Array.from({ length: frameCount }, (_, f) => e(f)) };
|
|
120
171
|
}
|
|
121
|
-
return out;
|
|
122
172
|
}
|
|
123
173
|
|
|
124
174
|
/** Frame f's channel dict. */
|
|
@@ -26,6 +26,7 @@ import { pose, SIDES } from "./pose.js";
|
|
|
26
26
|
import { graspAnchor } from "./socket-grip.js";
|
|
27
27
|
import { gripHand } from "./grip.js";
|
|
28
28
|
import { bakeChannels, channelsAt, flowOptions } from "./channels.js";
|
|
29
|
+
import { handBusy } from "./overlap.js";
|
|
29
30
|
import { idleLayer, withIdle, busyHands } from "./idle.js";
|
|
30
31
|
import { resolveAnchor, measureProps, armReach } from "./anchors.js";
|
|
31
32
|
import { expandGenerators } from "./walk.js";
|
|
@@ -208,7 +209,7 @@ export function compileMotion(spec, { sk, scene }, opts = {}) {
|
|
|
208
209
|
const engine = {
|
|
209
210
|
rebake(keys) {
|
|
210
211
|
out.keys = keys;
|
|
211
|
-
out.baked = bakeChannels(keys, frameCount, flowOptions(spec.flow));
|
|
212
|
+
out.baked = bakeChannels(keys, frameCount, flowOptions(spec.flow, spec.label ?? "", handBusy(spec)));
|
|
212
213
|
ctx.socketTurns = new Map();
|
|
213
214
|
ctx.graspPlans = new Map();
|
|
214
215
|
steadyHead(out.baked, keys, frameCount);
|
|
@@ -109,5 +109,7 @@ export function withIdle(c, layer, f) {
|
|
|
109
109
|
if (!d) return c;
|
|
110
110
|
const out = { ...c };
|
|
111
111
|
for (const [ch, v] of Object.entries(d)) out[ch] = Array.isArray(v) ? (Array.isArray(c[ch]) ? c[ch].map((x, i) => x + v[i]) : c[ch]) : (Number(c[ch]) || 0) + v;
|
|
112
|
+
// the top spine bone's own share (chest_*, channels.js overlap) breathes and sways with the rest of the spine
|
|
113
|
+
for (const ch of ["chest_pitch", "chest_roll"]) if (c[ch] !== undefined && d[ch.replace("chest", "spine")] !== undefined) out[ch] = c[ch] + d[ch.replace("chest", "spine")];
|
|
112
114
|
return out;
|
|
113
115
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Overlap (spec.flow.overlap, on by default): a body does not start or stop all at once. A change breaks
|
|
2
|
+
// along the chains one link after another: the lower spine, then the chest, then the head; the upper arm,
|
|
3
|
+
// then the forearm, then the hand; each link starts later and settles later than the one it hangs from.
|
|
4
|
+
// The agent keys five channels on one frame and both arms on the same frames (the user's tutorial take:
|
|
5
|
+
// 7 of 7 parts within +-1 frame, observations.life sync 0.81), and a constant trail per family moves the
|
|
6
|
+
// whole curve of a family at once, so every link of it still starts on one frame. Here each link takes its
|
|
7
|
+
// own delay at each of its keys (LINK frames at 24 fps, less for a short move: LINK * h / FULL for a move
|
|
8
|
+
// of h frames, so a quick flick is not smeared and a key pose is reached long before the next key), one
|
|
9
|
+
// side of the body trails the other by SIDE frames chosen per key frame (a hash of the spec's label and the
|
|
10
|
+
// key frame, so the same spec bakes the same take and both arms keyed on one frame part on different ones),
|
|
11
|
+
// and the arm's swing and bend, the distal swing, overshoot a little where they stop and settle back
|
|
12
|
+
// (SETTLE: a bounded hump, then exactly the keyed value, so a hold stays flat after it). Only the body's
|
|
13
|
+
// own swing moves: the channels that place it (feet, root, yaw, hand targets and weights, poles, fingers,
|
|
14
|
+
// objects) are untouched, so every contact lands as keyed; an arm on its IK loses it with the rest of
|
|
15
|
+
// flow (channels.js), and a skill that times its own motion (a walk's rhythm, a flip, a hit) keeps its keys
|
|
16
|
+
// on time: the delay is for the agent's keys (and standIdle's pose), easing in and out around the rest.
|
|
17
|
+
// frames for a FULL-frame move; the arm's swing and bend also trail the body by flow.lag.arms (3), the wrist (no flow of
|
|
18
|
+
// its own) here: after the forearm
|
|
19
|
+
// its own) here: after the forearm. A free IK hand (hand target, IK weight and pole together: hand) takes no delay of its
|
|
20
|
+
// own, only its side's trail, so both hands keyed on one frame part
|
|
21
|
+
const LINK = { spine: 1, chest: 2.5, head: 3.5, aswing: 3, abend: 5, wrist: 9.5, hand: 0 };
|
|
22
|
+
/** A hand keeps its keyed time within CONTACT frames of a contact: an anchor or fit weight on, a carry, a hand goal (handBusy). */
|
|
23
|
+
const CONTACT = 6, SNAP = 6;
|
|
24
|
+
const FULL = 24, SIDE = [1.5, 3];
|
|
25
|
+
/** How fast a delay may change, frames a frame: under 1 time still runs forward; it also runs out this fast before an arm's IK. */
|
|
26
|
+
export const OVERLAP_SLOPE = 0.5;
|
|
27
|
+
const MOVE_SLOPE = 0.2, TRAIL_SLOPE = 0.15;
|
|
28
|
+
const SETTLE = { frames: 8, min: 6, frac: 0.08, max: 3 };
|
|
29
|
+
const SETTLES = /^(aswing|abend|wflex)_[lr]$/;
|
|
30
|
+
// a skill times its own motion (a flip, a hit, a walk: walk.js tags its steps); standIdle only sets the pose the agent's keys start from
|
|
31
|
+
const OWN_TIMING = /^use\[\d+\] (?!standIdle$)|\bwalk(To)? (start|step \d+|end)$/;
|
|
32
|
+
|
|
33
|
+
const ownTiming = p => OWN_TIMING.test(p?.src ?? "");
|
|
34
|
+
|
|
35
|
+
/** The chain link a channel moves, or null (a placement channel, or one overlap leaves alone). */
|
|
36
|
+
function linkOf(ch) {
|
|
37
|
+
const m = /^(spine|chest|head)_(yaw|pitch|roll)$|^(aswing|abend|wflex|wtwist)_[lr]$|^(hand|armw|elbow)_[lr]$/.exec(ch);
|
|
38
|
+
if (!m) return null;
|
|
39
|
+
return m[1] ?? (m[4] ? "hand" : m[3] === "wflex" || m[3] === "wtwist" ? "wrist" : m[3]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** text -> a number in [-1, 1) (FNV-1a hash, then one mulberry32 step; gait-rhythm.js noiseOf). */
|
|
43
|
+
function unit(text) {
|
|
44
|
+
let h = 2166136261;
|
|
45
|
+
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
|
|
46
|
+
let t = Math.imul((h >>> 0) ^ ((h >>> 0) >>> 15), (h >>> 0) | 1);
|
|
47
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
48
|
+
return (((t ^ (t >>> 14)) >>> 0) / 4294967296) * 2 - 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A delay a key at a time, eased between keys: { max, at(q) }, or null when it is 0 throughout. */
|
|
52
|
+
function delayCurve(x, lam, slope) {
|
|
53
|
+
const n = x.length;
|
|
54
|
+
for (let i = 1; i < n; i++) lam[i] = Math.min(lam[i], lam[i - 1] + slope(i - 1));
|
|
55
|
+
for (let i = n - 2; i >= 0; i--) lam[i] = Math.min(lam[i], lam[i + 1] + slope(i));
|
|
56
|
+
if (!lam.some(v => v > 0)) return null;
|
|
57
|
+
return {
|
|
58
|
+
max: Math.max(...lam),
|
|
59
|
+
at(q) {
|
|
60
|
+
if (q <= x[0]) return lam[0];
|
|
61
|
+
if (q >= x[n - 1]) return lam[n - 1];
|
|
62
|
+
let i = 0;
|
|
63
|
+
while (q > x[i + 1]) i++;
|
|
64
|
+
return lam[i] + ((lam[i + 1] - lam[i]) * (q - x[i])) / (x[i + 1] - x[i]);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The delay (frames) of a link, on top of its side's trail (sideTrail), at each frame, or null. pts: the channel's
|
|
71
|
+
* keys ({ f, value, src }); scale: flow.overlap (1 the default). A delay is set at each key (the move that leaves it:
|
|
72
|
+
* LINK for a FULL-frame move, less for a shorter one), 0 at (and into) a skill's own keys and at keys next to an arm's
|
|
73
|
+
* IK (gap: frames to the nearest IK frame per frame, ikGap), and eased between keys no steeper than OVERLAP_SLOPE
|
|
74
|
+
* frames a frame on a hold and MOVE_SLOPE across a move, so time still runs forward and a hold keeps its length.
|
|
75
|
+
*/
|
|
76
|
+
export function overlapDelay(ch, pts, scale, seed, gap = null) {
|
|
77
|
+
const link = linkOf(ch);
|
|
78
|
+
if (!link || !LINK[link] || !(scale > 0) || pts.length < 2) return null;
|
|
79
|
+
const x = pts.map(p => p.f), n = x.length;
|
|
80
|
+
const lam = x.map((f, i) => {
|
|
81
|
+
// a move on, into or out of an arm's IK keeps its keyed time (gap: frames to its IK, ikGap); the delay comes and goes on the holds around it
|
|
82
|
+
if (ownTiming(pts[i]) || ownTiming(pts[i + 1]) || (gap && [x[i - 1], f, x[i + 1]].some(g => g !== undefined && gap[g] < 2))) return 0;
|
|
83
|
+
return LINK[link] * Math.min(1, (i < n - 1 ? x[i + 1] - f : FULL) / FULL) * scale;
|
|
84
|
+
});
|
|
85
|
+
// a delay changes on a hold, where nothing shows it; across a move only by MOVE_SLOPE (its speed within about 20%)
|
|
86
|
+
return delayCurve(x, lam, i => (pts[i].value === pts[i + 1].value ? OVERLAP_SLOPE : MOVE_SLOPE) * (x[i + 1] - x[i]));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One side's trail: the whole arm of that side (its swing, bend and wrist, and as a free IK hand its target, IK weight
|
|
91
|
+
* and pole) runs this many frames late, so both arms keyed on one frame part, and the arm's FK and IK stay in step
|
|
92
|
+
* while it blends between them. keys: the keys of that arm's channels ({ f, src }). At each key frame the side
|
|
93
|
+
* that trails (a hash of seed and frame: both sides agree) takes SIDE[0]..SIDE[1] frames, no more than a third of the
|
|
94
|
+
* moves either side of the key; none at a snap (moves under SNAP frames: a trail changing across one bends it), at a
|
|
95
|
+
* skill's own keys, or within CONTACT frames of the hand's contacts (gap: contactGap). channels: each of the arm's
|
|
96
|
+
* channels' keys ({ f, value, src }), the IK weight's list marked weight: true.
|
|
97
|
+
*/
|
|
98
|
+
export function sideTrail(channels, side, scale, seed, gap = null) {
|
|
99
|
+
// one point a frame, a skill's own if any key on it is; the spans where some channel of the arm moves
|
|
100
|
+
const byFrame = new Map(), moves = [], same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
101
|
+
for (const list of channels) {
|
|
102
|
+
for (const p of list) if (!byFrame.has(p.f) || ownTiming(p)) byFrame.set(p.f, p);
|
|
103
|
+
for (let i = 1; i < list.length; i++) if (!same(list[i - 1].value, list[i].value)) moves.push([list[i - 1].f, list[i].f, list.weight]);
|
|
104
|
+
}
|
|
105
|
+
const keyFrames = [...byFrame.values()].sort((a, b) => a.f - b.f);
|
|
106
|
+
if (!(scale > 0) || keyFrames.length < 2) return null;
|
|
107
|
+
const x = keyFrames.map(p => p.f), n = x.length;
|
|
108
|
+
const lam = x.map((f, i) => {
|
|
109
|
+
// the take starts on its keys
|
|
110
|
+
if (f === 0 || ownTiming(keyFrames[i]) || ownTiming(keyFrames[i + 1]) || (gap && [x[i - 1], f, x[i + 1]].some(g => g !== undefined && gap[g] < 2))) return 0;
|
|
111
|
+
const u = unit(`${seed}|${f}`), short = Math.min(i < n - 1 ? x[i + 1] - f : FULL, i > 0 ? f - x[i - 1] : FULL);
|
|
112
|
+
if ((u < 0 ? "l" : "r") !== side || short < SNAP) return 0;
|
|
113
|
+
return Math.min(SIDE[0] + (SIDE[1] - SIDE[0]) * Math.abs(u), short / 3) * Math.min(1, scale);
|
|
114
|
+
});
|
|
115
|
+
// the trail changes freely while the whole arm holds; across a move by TRAIL_SLOPE (its speed within about 15%), and
|
|
116
|
+
// never across a change of the IK weight: the blend of FK and IK there turns a changing trail into a pop of the hand
|
|
117
|
+
const across = i => moves.filter(([a, b]) => a < x[i + 1] && b > x[i]), h = i => x[i + 1] - x[i];
|
|
118
|
+
return delayCurve(x, lam, i => (!across(i).length ? OVERLAP_SLOPE : across(i).some(m => m[2]) ? 0 : TRAIL_SLOPE) * h(i));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const hump = u => (u <= 0 || u >= 1 ? 0 : 64 * (u * (1 - u)) ** 3);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The distal swing's follow-through (the arm's swing and bend, the wrist's flex): a bump (degrees, by curve time) added where a move stops (a key whose
|
|
125
|
+
* next key holds its value, or the last key): it peaks SETTLE.frac of the move (SETTLE.max deg at most) past
|
|
126
|
+
* the key a quarter of the way in and is gone SETTLE.frames later (less, and smaller, for a short move or hold;
|
|
127
|
+
* none under SETTLE.min frames), with no
|
|
128
|
+
* step in speed or acceleration at either end, and never past the take's end frame. abend never goes past
|
|
129
|
+
* straight; a skill's own stop has none; clear(a, b) false leaves a bump over frames a..b out (an arm going onto its IK). null when there is none.
|
|
130
|
+
*/
|
|
131
|
+
export function settleBump(ch, pts, y, scale, end, clear = () => true) {
|
|
132
|
+
if (!SETTLES.test(ch) || !(scale > 0)) return null;
|
|
133
|
+
const bumps = [], x = pts.map(p => p.f);
|
|
134
|
+
for (let i = 1; i < x.length; i++) {
|
|
135
|
+
const d = y[i] - y[i - 1], next = x[i + 1];
|
|
136
|
+
if (Math.abs(d) < 1 || ownTiming(pts[i]) || ownTiming(pts[i - 1]) || (next !== undefined && y[i + 1] !== y[i])) continue;
|
|
137
|
+
const S = Math.min(SETTLE.frames, 0.5 * (x[i] - x[i - 1]), next === undefined ? Infinity : 0.5 * (next - x[i]));
|
|
138
|
+
// a shorter settle is a smaller one: its acceleration goes as A / S^2
|
|
139
|
+
let A = Math.sign(d) * Math.min(SETTLE.max * (S / SETTLE.frames) ** 2, SETTLE.frac * Math.abs(d)) * Math.min(1, scale);
|
|
140
|
+
if (/^abend/.test(ch) && y[i] + A < 0) A = Math.min(0, -y[i]);
|
|
141
|
+
// the take ends on its last value: a bump that would run past the end is left out
|
|
142
|
+
if (S >= SETTLE.min && Math.abs(A) > 1e-3 && x[i] + 0.75 * S <= end && clear(x[i] - S / 4, x[i] + 0.75 * S)) bumps.push([x[i] - S / 4, S, A]);
|
|
143
|
+
}
|
|
144
|
+
if (!bumps.length) return null;
|
|
145
|
+
return t => { let v = 0; for (const [a, S, A] of bumps) v += A * hump((t - a) / S); return v; };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Per frame, how many frames away the nearest frame is on which any of these IK weights is on (Infinity: none). */
|
|
149
|
+
export function ikGap(weights, frameCount) {
|
|
150
|
+
const gap = new Array(frameCount).fill(Infinity);
|
|
151
|
+
for (let f = 0, last = -Infinity; f < frameCount; f++) { if (weights.some(w => w[f] > 1e-3)) last = f; gap[f] = f - last; }
|
|
152
|
+
for (let f = frameCount - 1, next = Infinity; f >= 0; f--) { if (weights.some(w => w[f] > 1e-3)) next = f; gap[f] = Math.min(gap[f], next - f); }
|
|
153
|
+
return gap;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Per side, the frames its hand works on something (a carry from its grab to its release, a near, above or still goal
|
|
158
|
+
* that names a hand over its frames), from the spec; channels.js adds the frames an anchor or fit weight of that hand is on. { l, r }: [[a, b]].
|
|
159
|
+
*/
|
|
160
|
+
export function handBusy(spec) {
|
|
161
|
+
const out = { l: [], r: [] }, end = Math.round(spec?.frames ?? 0);
|
|
162
|
+
for (const o of Object.values(spec?.objects ?? {})) {
|
|
163
|
+
const k = spec.props?.[o?.prop]?.hand;
|
|
164
|
+
if (Number.isFinite(o?.grab) && out[k]) out[k].push([o.grab, Number.isFinite(o.release) ? o.release : end]);
|
|
165
|
+
}
|
|
166
|
+
for (const g of spec?.goals ?? []) {
|
|
167
|
+
const at = Array.isArray(g?.at) ? g.at : null;
|
|
168
|
+
// a goal that puts a hand somewhere (near, above, still), not one that only measures it (speed, moved)
|
|
169
|
+
if (at?.length && ["near", "above", "still"].some(k => g[k] !== undefined) && /hand/i.test(JSON.stringify(g))) for (const k of ["l", "r"]) out[k].push([at[0], at[at.length - 1]]);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Per frame, how far a hand is from its contacts (handBusy spans and its own anchor weights), less margin (CONTACT): under 2 holds the keyed time. */
|
|
175
|
+
export function contactGap(spans, weights, frameCount, margin = CONTACT) {
|
|
176
|
+
const busy = new Uint8Array(frameCount);
|
|
177
|
+
for (const [a, b] of spans) for (let f = Math.max(0, Math.floor(a)); f <= Math.min(frameCount - 1, Math.ceil(b)); f++) busy[f] = 1;
|
|
178
|
+
for (const w of weights) for (let f = 0; f < frameCount; f++) if (w[f] > 1e-3) busy[f] = 1;
|
|
179
|
+
return ikGap([busy], frameCount).map(g => Math.max(0, g - margin));
|
|
180
|
+
}
|
|
@@ -19,9 +19,8 @@ const KNEE_REST = [0, 0, 1], LEG = ["UpLeg", "Leg", "Foot"];
|
|
|
19
19
|
const v3 = (x, d = [0, 0, 0]) => (Array.isArray(x) || ArrayBuffer.isView(x) ? [x[0], x[1], x[2]] : d);
|
|
20
20
|
const num = (x, d = 0) => (typeof x === "number" && Number.isFinite(x) ? x : d);
|
|
21
21
|
|
|
22
|
-
/** Split a body-frame rotation over a chain of bones, each turning about its own joint. */
|
|
23
|
-
function spread(sk, s, bones, yaw, pitch, roll) {
|
|
24
|
-
const k = bones.length;
|
|
22
|
+
/** Split a body-frame rotation over a chain of bones (k shares), each turning about its own joint. */
|
|
23
|
+
function spread(sk, s, bones, yaw, pitch, roll, k = bones.length) {
|
|
25
24
|
for (const name of bones) {
|
|
26
25
|
const b = sk.index[name];
|
|
27
26
|
rotateSubtree(sk, s, b, s.P[b].slice(), mul(ry(yaw / k), mul(rx(pitch / k), rz(roll / k))));
|
|
@@ -117,7 +116,10 @@ export function pose(sk, c, hooks = {}) {
|
|
|
117
116
|
if (sw) rotateSubtree(sk, s, a, s.P[a].slice(), rx(-sw));
|
|
118
117
|
}
|
|
119
118
|
spread(sk, s, ["Neck", "Head"], num(c.head_yaw), num(c.head_pitch), num(c.head_roll));
|
|
120
|
-
|
|
119
|
+
// the top spine bone turns its share of chest_* when flow bakes one (channels.js overlap: it breaks after the two below)
|
|
120
|
+
const sy = num(c.spine_yaw), sp = num(c.spine_pitch), sr = num(c.spine_roll);
|
|
121
|
+
spread(sk, s, ["Spine", "Spine1"], sy, sp, sr, 3);
|
|
122
|
+
spread(sk, s, ["Spine2"], num(c.chest_yaw, sy), num(c.chest_pitch, sp), num(c.chest_roll, sr), 3);
|
|
121
123
|
// Root: turn about the hips joint, then put the hips at `root`.
|
|
122
124
|
const hips = sk.index.Hips, yaw = num(c.yaw);
|
|
123
125
|
rotateSubtree(sk, s, hips, s.P[hips].slice(), mul(ry(yaw), mul(rx(num(c.pitch)), rz(num(c.roll)))));
|
|
@@ -19,6 +19,7 @@ export function objectOk(err, scene, at, id, { groups = false } = {}) {
|
|
|
19
19
|
const parts = partsOf(scene, id);
|
|
20
20
|
if (parts.length && groups) return true;
|
|
21
21
|
if (parts.length) err(`${at} names "${id}", a group (Empty) with no body of its own; its parts are ${parts.join(", ")}. Name one part`);
|
|
22
|
+
else if (scene?.others?.[id]) err(`${at} names "${id}", a ${scene.others[id].renderer ?? "record"} with no body (no size to stand on, hold or hit); name a scene body${didYouMean(String(id), ids)}`);
|
|
22
23
|
else err(`${at} names object "${id}", which is not in the scene${didYouMean(String(id), [...new Set([...ids, ...ids.flatMap(k => scene.objects[k].ancestors ?? []), ...Object.keys(scene.others ?? {})])])}`);
|
|
23
24
|
return false;
|
|
24
25
|
}
|
|
@@ -56,7 +56,11 @@ function validateProps(spec, err, { sk, unknown = err }) {
|
|
|
56
56
|
if (!SIDE.has(fit?.side)) err(`${at}.side must be "l" or "r", got ${fmt(fit?.side)}`);
|
|
57
57
|
if (!spec.anchors?.[fit?.anchor]) err(`${at}.anchor "${fit?.anchor}" is not in spec.anchors${didYouMean(String(fit?.anchor), Object.keys(spec.anchors ?? {}))}`);
|
|
58
58
|
const e = fit?.effector;
|
|
59
|
-
if (e?.prop !== undefined) {
|
|
59
|
+
if (e?.prop !== undefined) {
|
|
60
|
+
const p = spec.props?.[e.prop];
|
|
61
|
+
if (!p) err(`${at}.effector.prop "${e.prop}" is not in spec.props${didYouMean(String(e.prop), Object.keys(spec.props ?? {}))}`);
|
|
62
|
+
else if (e.point !== "origin" && !p.points?.[e.point]) err(`${at}.effector ${e.prop}.${e.point} is not a point of spec.props.${e.prop} (points: ${[...Object.keys(p.points ?? {}), "origin"].join(", ")})`);
|
|
63
|
+
}
|
|
60
64
|
else if (Array.isArray(e?.joints)) e.joints.forEach(j => boneOk(err, sk, `${at}.effector`, j));
|
|
61
65
|
else err(`${at}.effector must be { prop, point } or { joints: [bone, ...] }`);
|
|
62
66
|
if (fit?.offset !== undefined && !isVec3(fit.offset)) err(`${at}.offset must be [x, y, z]`);
|
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
// Strict motion spec validation with exact messages, run before every compile through the
|
|
2
2
|
// pipeline (after skills expand, with open parameters at a trial value). Every problem found is
|
|
3
|
-
// listed at once, one per line
|
|
3
|
+
// listed at once, one per line (problems that differ only in their first index share a line), so the
|
|
4
|
+
// agent fixes them in one edit.
|
|
4
5
|
import { VEC_CHANNELS, POLE_CHANNELS, NUM_CHANNELS, MAP_CHANNELS, FINGER_PRESETS, rangeOf, CONVENTIONS, didYouMean, suggestField, isVec3, fmt } from "./vocab.js";
|
|
5
6
|
import { validateParts } from "./validate-parts.js";
|
|
6
7
|
import { SPEC_FIELDS, KEY_FIELDS } from "../schema/fields.js";
|
|
7
|
-
const MAX_ERRORS = 12;
|
|
8
|
+
const MAX_ERRORS = 12, MAX_FOUND = 60;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Problems that differ only in their first index (and its key frame) said once: "objects.box.keys[0].rot must be
|
|
12
|
+
* ..." and keys[3] with the same words become "objects.box.keys[0,3].rot must be ...", so every problem fits one answer.
|
|
13
|
+
*/
|
|
14
|
+
function mergeProblems(lines) {
|
|
15
|
+
const groups = new Map();
|
|
16
|
+
for (const line of lines) {
|
|
17
|
+
const m = /\[(\d+)\]( \(f(\d+)\))?/.exec(line), key = m ? `${line.slice(0, m.index)}[#]${m[2] ? " (f#)" : ""}${line.slice(m.index + m[0].length)}` : line;
|
|
18
|
+
if (!groups.has(key)) groups.set(key, { line, idx: [], fs: [] });
|
|
19
|
+
if (m) { groups.get(key).idx.push(m[1]); if (m[3]) groups.get(key).fs.push(`f${m[3]}`); }
|
|
20
|
+
}
|
|
21
|
+
return [...groups.entries()].map(([key, g]) => (g.idx.length > 1 ? key.replace("[#]", `[${g.idx.join(",")}]`).replace(" (f#)", ` (${g.fs.join(", ")})`) : g.line));
|
|
22
|
+
}
|
|
8
23
|
|
|
9
24
|
|
|
10
25
|
/** Channels other than the body's own: hinge channels, visible and level channels (C15), channels a check names, *_vis switches, spec.channels. */
|
|
11
|
-
function customChannels(spec) {
|
|
26
|
+
export function customChannels(spec) {
|
|
12
27
|
const out = new Set(spec.channels ?? []);
|
|
13
28
|
for (const o of Object.values(spec.objects ?? {})) for (const ch of [o?.channel, o?.visible, o?.level]) if (typeof ch === "string" && ch) out.add(ch);
|
|
14
29
|
const c = spec.checks ?? {};
|
|
@@ -88,15 +103,18 @@ function validateKeys(spec, err, frames, unknown, past = "") {
|
|
|
88
103
|
}
|
|
89
104
|
|
|
90
105
|
/**
|
|
91
|
-
* Throws MOTION_SPEC_INVALID listing every problem; scene objects are
|
|
106
|
+
* Throws MOTION_SPEC_INVALID listing every problem (problems: ones found before, listed first); scene objects are
|
|
107
|
+
* checked when given.
|
|
92
108
|
* lenient (a saved take compiled again, take-source.js) ignores unknown fields.
|
|
93
109
|
*/
|
|
94
|
-
/** spec.flow: false, or { smooth?: boolean, lag?: false | 0..6 frames | { spine?, head?, arms? } } (channels.js). */
|
|
110
|
+
/** spec.flow: false, or { smooth?: boolean, lag?: false | 0..6 frames | { spine?, head?, arms? }, overlap?: boolean | 0..2 } (channels.js, overlap.js). */
|
|
95
111
|
function validateFlow(flow, err, unknown) {
|
|
96
112
|
if (flow === undefined || flow === false) return;
|
|
97
|
-
if (!flow || typeof flow !== "object" || Array.isArray(flow)) return err(`spec.flow must be false or { smooth?, lag? }, got ${fmt(flow)}`);
|
|
98
|
-
for (const k of Object.keys(flow)) if (
|
|
113
|
+
if (!flow || typeof flow !== "object" || Array.isArray(flow)) return err(`spec.flow must be false or { smooth?, lag?, overlap? }, got ${fmt(flow)}`);
|
|
114
|
+
for (const k of Object.keys(flow)) if (!["smooth", "lag", "overlap"].includes(k)) unknown(`spec.flow has unknown field "${k}" (fields: smooth, lag, overlap)`);
|
|
99
115
|
if (flow.smooth !== undefined && typeof flow.smooth !== "boolean") err(`spec.flow.smooth must be true or false, got ${fmt(flow.smooth)}`);
|
|
116
|
+
const o = flow.overlap;
|
|
117
|
+
if (o !== undefined && typeof o !== "boolean" && !(Number.isFinite(o) && o >= 0 && o <= 2)) err(`spec.flow.overlap must be true, false or a scale 0..2, got ${fmt(o)}`);
|
|
100
118
|
const frames = v => Number.isFinite(v) && v >= 0 && v <= 6, lag = flow.lag, LAG = "false, 0..6 frames or { spine?, head?, arms? }";
|
|
101
119
|
if (lag === undefined || lag === false || frames(lag)) return;
|
|
102
120
|
if (!lag || typeof lag !== "object" || Array.isArray(lag)) return err(`spec.flow.lag must be ${LAG}, got ${fmt(lag)}`);
|
|
@@ -106,8 +124,8 @@ function validateFlow(flow, err, unknown) {
|
|
|
106
124
|
}
|
|
107
125
|
}
|
|
108
126
|
|
|
109
|
-
export function validateSpec(spec, { sk, scene, lenient = false } = {}) {
|
|
110
|
-
const errors = [], err = m => { if (errors.length <
|
|
127
|
+
export function validateSpec(spec, { sk, scene, lenient = false, problems = [] } = {}) {
|
|
128
|
+
const errors = [...problems], err = m => { if (errors.length < MAX_FOUND) errors.push(m); }, unknown = lenient ? () => {} : err;
|
|
111
129
|
if (!spec || typeof spec !== "object") throw new Error("MOTION_SPEC_INVALID: spec must be an object");
|
|
112
130
|
if (spec.schema !== "cozyclay.motion-spec.v1") err(`spec.schema must be "cozyclay.motion-spec.v1", got ${fmt(spec.schema)}`);
|
|
113
131
|
for (const k of Object.keys(spec)) if (!SPEC_FIELDS.includes(k)) unknown(`spec.${k} is not a spec field${didYouMean(k, SPEC_FIELDS)} (fields: ${SPEC_FIELDS.join(", ")})`);
|
|
@@ -118,5 +136,7 @@ export function validateSpec(spec, { sk, scene, lenient = false } = {}) {
|
|
|
118
136
|
const past = Number.isInteger(frames) ? pastEndAdvice(spec, frames) : "";
|
|
119
137
|
validateKeys(spec, err, Number.isInteger(frames) ? frames : 20000, unknown, past);
|
|
120
138
|
validateParts(spec, err, { sk, scene, frames: Number.isInteger(frames) ? frames : 20000, lenient, unknown, past });
|
|
121
|
-
if (errors.length)
|
|
139
|
+
if (!errors.length) return;
|
|
140
|
+
const lines = mergeProblems(errors), more = lines.length > MAX_ERRORS || errors.length >= MAX_FOUND;
|
|
141
|
+
throw new Error(`MOTION_SPEC_INVALID: ${lines.slice(0, MAX_ERRORS).join("\n")}${more ? `\n(${lines.length > MAX_ERRORS ? `${lines.length - MAX_ERRORS}` : "more"}${errors.length >= MAX_FOUND ? "+" : ""} more problems not listed)` : ""}`);
|
|
122
142
|
}
|