cozyclay 3.1.0 → 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 +65 -0
- package/bin/agent/agent-routes.mjs +12 -11
- package/bin/agent/main-agent.mjs +21 -7
- package/bin/agent/main-prompt.mjs +7 -6
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +4 -1
- package/bin/agent/sculpt-sketch.mjs +190 -0
- package/bin/agent/session-store.mjs +2 -1
- package/bin/agent/sketch-code.mjs +151 -0
- package/bin/agent/sketch-sandbox.mjs +102 -0
- 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/agent/turn-frames.mjs +4 -0
- package/bin/cozyclay.mjs +4 -2
- package/bin/live-endpoint.mjs +2 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
- 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 +7 -7
- package/package.json +3 -1
- package/src/App.jsx +44 -66
- 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/elements.js +4 -3
- package/src/commands/export.js +1 -1
- package/src/commands/motion3.js +27 -7
- package/src/commands/objects.js +1 -1
- package/src/export-workspace.js +244 -0
- package/src/keyframe-pack.js +5 -1
- package/src/live-control.js +14 -7
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- 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/object-look.js +9 -5
- package/src/panels/PoseImagePanel.jsx +3 -2
- package/src/panels/SculptPanel.jsx +2 -2
- package/src/primitive-look.js +38 -0
- package/src/props.jsx +20 -28
- package/src/render-passes.js +7 -1
- package/src/result-modal.jsx +3 -3
- package/src/scene-objects.js +2 -2
- package/src/sculpt-colours.js +65 -0
- package/src/sculpt-compare.js +13 -50
- package/src/sculpt-csg.js +112 -0
- package/src/sculpt-curve.js +123 -0
- package/src/sculpt-cutters.js +43 -0
- package/src/sculpt-detail.js +207 -0
- package/src/sculpt-geometry-loft-convex.js +100 -0
- package/src/sculpt-geometry-shapes.js +250 -0
- package/src/sculpt-geometry.js +33 -15
- package/src/sculpt-loft-rings.js +225 -0
- package/src/sculpt-noise.js +65 -0
- package/src/sculpt-part-fields.js +27 -0
- package/src/sculpt-recipe-read.js +65 -0
- package/src/sculpt-recipe.js +33 -75
- package/src/sculpt-run.js +1 -1
- package/src/sculpt-scatter-items.js +183 -0
- package/src/sculpt-scatter-shape.js +57 -0
- package/src/sculpt-scatter.js +124 -0
- package/src/sculpt-shapes-curved.js +211 -0
- package/src/sculpt-shapes-loft-convex.js +218 -0
- package/src/sculpt-shapes.js +138 -0
- package/src/sculpt-sketch-state.js +76 -0
- package/src/sculpt-sketch.jsx +124 -0
- package/src/sculpt-surface-fields.js +65 -0
- 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/studio-elements.js +5 -1
- package/src/workflow/AgentPanel.jsx +6 -6
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/WorkflowBuilder.jsx +2 -2
- package/src/workflow/agent-client.js +14 -11
- package/src/workflow/agent-plan.css +93 -0
- package/src/workflow/agent-plan.js +59 -0
- 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 +8 -8
- package/dist/assets/app-B2BpfCl2.js +0 -5088
- package/dist/assets/compile-worker-D0pKioa0.js +0 -5
- package/dist/assets/motion3-compile-B887n77J.js +0 -1
- package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
- package/dist/assets/task-board-BKaM7d-9.js +0 -14
- package/dist/assets/workflow-B8Dhzexj.js +0 -179
|
@@ -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
|
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// C21. Corrections: slips in a sent spec that have one reading, fixed before the spec is stored, each named
|
|
2
|
+
// in output.adjusted ("corrected: ...") so the compile goes on instead of being refused. A name is corrected
|
|
3
|
+
// only when exactly one known name matches it once case, "_", "-" and spaces are ignored (spinePitch ->
|
|
4
|
+
// spine_pitch, walk_to -> walkTo), when it is a plural or a singular of one (goal -> goals), or by the short
|
|
5
|
+
// alias lists below (arm_swing_l -> aswing_l, pos -> p on a world anchor), and the right name is not there
|
|
6
|
+
// already. Anything with two readings stays for the validator to refuse with every problem listed.
|
|
7
|
+
// spec fields, use[i] skill names and fields, use[i].args names (an arg of the skill written beside args
|
|
8
|
+
// moves into it, args.f0 moves out), key channels and finger presets, object key and fall fields, goal,
|
|
9
|
+
// fit and check entry fields, anchor fields
|
|
10
|
+
// generators[i] with to, arrive, a single heading or type "walkTo" -> a walkTo use entry (the walk that
|
|
11
|
+
// heads for a point), placed in f0 order among the use entries
|
|
12
|
+
// objects.<id> written as { free: {...} } (or carry, hinge, follow) -> the record itself
|
|
13
|
+
// fitWalks: a walkTo with to and steps gets the stride, or the step count and cadence, that ends the steps at
|
|
14
|
+
// to in a natural gait (skills/basic.js fitTo), stored with the spec; one it cannot fit is refused with what the
|
|
15
|
+
// walk would do and needs
|
|
16
|
+
import { SPEC_FIELDS, USE_FIELDS, OBJECT_KEY_FIELDS, FALL_FIELDS, GOAL_FIELDS, FIT_FIELDS, ANCHOR_FIELDS, CHECK_ENTRY_FIELDS, OBJECT_FIELDS } from "./schema/fields.js";
|
|
17
|
+
import { VEC_CHANNELS, NUM_CHANNELS, MAP_CHANNELS, FINGER_PRESETS } from "./compiler/vocab.js";
|
|
18
|
+
import { SKILL_INFO } from "./skills/info.js";
|
|
19
|
+
import { SKILL_NAMES, expandUse } from "./skills/index.js";
|
|
20
|
+
import { customChannels } from "./compiler/validate.js";
|
|
21
|
+
|
|
22
|
+
const plain = v => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
23
|
+
const squash = s => String(s).toLowerCase().replace(/[\s_-]/g, "");
|
|
24
|
+
/** Sided names: left / right / L / R at the end read as _l / _r. */
|
|
25
|
+
const sided = s => String(s).replace(/[_-]?(left|Left|LEFT)$/, "_l").replace(/[_-]?(right|Right|RIGHT)$/, "_r").replace(/[_-]?L$/, "_l").replace(/[_-]?R$/, "_r");
|
|
26
|
+
const CHANNEL_ALIASES = { arm_swing: "aswing", armswing: "aswing", elbow_bend: "abend", arm_bend: "abend", wrist_twist: "wtwist", wrist_flex: "wflex",
|
|
27
|
+
foot_yaw: "fyaw", foot_pitch: "fpitch", arm_weight: "armw", ik_weight: "armw", hips: "root", pelvis: "root", finger: "fingers", hand_pole: "elbow", knee_pole: "knee" };
|
|
28
|
+
const FIELD_ALIASES = {
|
|
29
|
+
objectKey: { position: "pos", rotation: "rot", quat: "rot", quaternion: "rot", frame: "f" },
|
|
30
|
+
anchor: { pos: "p", position: "p", point: "p", id: "object", joint: "bone" },
|
|
31
|
+
use: { frame: "f0", f: "f0" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The one name of `names` that `name` stands for, or null: alias, case and separators, plural or singular. */
|
|
35
|
+
function nameFor(name, names, aliases = {}) {
|
|
36
|
+
if (names.includes(name)) return name;
|
|
37
|
+
const alias = aliases[name] ?? aliases[squash(name)];
|
|
38
|
+
if (alias && names.includes(alias)) return alias;
|
|
39
|
+
const hits = new Set(names.filter(n => squash(n) === squash(name) || n === `${name}s` || `${n}s` === name));
|
|
40
|
+
return hits.size === 1 ? [...hits][0] : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** obj with each unknown field that stands for exactly one free known field renamed; one note per rename. */
|
|
44
|
+
function renameFields(obj, fields, at, notes, aliases) {
|
|
45
|
+
if (!plain(obj)) return obj;
|
|
46
|
+
let out = obj;
|
|
47
|
+
for (const f of Object.keys(obj)) {
|
|
48
|
+
const to = fields.includes(f) ? null : nameFor(f, fields, aliases);
|
|
49
|
+
if (!to || to in out) continue;
|
|
50
|
+
out = Object.fromEntries(Object.entries(out).map(([k, v]) => (k === f ? [to, v] : [k, v])));
|
|
51
|
+
notes.push(`corrected: ${at}.${f} -> ${at}.${to}`);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const mapList = (list, fn) => (Array.isArray(list) ? list.map(fn) : list);
|
|
57
|
+
|
|
58
|
+
/** A generator that is a walkTo: it heads for a point, plans to arrive, turns to one heading, or says so. */
|
|
59
|
+
const isWalkTo = g => plain(g) && (g.to !== undefined || g.arrive !== undefined || typeof g.heading === "number" || nameFor(String(g.type), ["walkTo"]) === "walkTo");
|
|
60
|
+
|
|
61
|
+
function moveGenerators(spec, notes) {
|
|
62
|
+
if (!Array.isArray(spec.generators) || !spec.generators.some(isWalkTo)) return spec;
|
|
63
|
+
const use = Array.isArray(spec.use) ? [...spec.use] : [], kept = [];
|
|
64
|
+
spec.generators.forEach((g, i) => {
|
|
65
|
+
if (!isWalkTo(g)) return kept.push(g);
|
|
66
|
+
const { type, f0, ...args } = g, at = use.findIndex(u => plain(u) && Number.isFinite(u.f0) && u.f0 > f0);
|
|
67
|
+
const j = at < 0 ? use.length : at;
|
|
68
|
+
use.splice(j, 0, { skill: "walkTo", f0, args });
|
|
69
|
+
const why = g.to !== undefined ? "to" : g.arrive !== undefined ? "arrive" : typeof g.heading === "number" ? "a single heading" : `type "${type}"`;
|
|
70
|
+
notes.push(`corrected: generators[${i}] (a walk with ${why}) -> use[${j}] walkTo { ${Object.keys(args).join(", ")} }: a generator walks by steps and [h0, h1]; walkTo is the walk that heads for a point`);
|
|
71
|
+
});
|
|
72
|
+
const { generators, ...rest } = spec;
|
|
73
|
+
return { ...rest, ...(kept.length ? { generators: kept } : {}), use };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fixUse(u, i, notes) {
|
|
77
|
+
if (!plain(u)) return u;
|
|
78
|
+
let out = u;
|
|
79
|
+
const skill = SKILL_NAMES.includes(u.skill) ? u.skill : nameFor(String(u.skill), SKILL_NAMES);
|
|
80
|
+
if (skill && skill !== u.skill) { out = { ...out, skill }; notes.push(`corrected: use[${i}].skill "${u.skill}" -> "${skill}"`); }
|
|
81
|
+
const info = SKILL_INFO[out.skill];
|
|
82
|
+
if (!info) return out;
|
|
83
|
+
const known = Object.keys(info.args);
|
|
84
|
+
if (plain(out.args) || out.args === undefined) {
|
|
85
|
+
const given = out.args ?? {};
|
|
86
|
+
let args = renameFields(given, known, `use[${i}].args`, notes);
|
|
87
|
+
// an enum arg written another way: "left" -> "l", "Back" -> "back"
|
|
88
|
+
for (const [a, v] of Object.entries(args)) {
|
|
89
|
+
const choices = info.args[a]?.enum, to = choices && typeof v === "string" && !choices.includes(v) ? nameFor(v, choices, { left: "l", right: "r" }) : null;
|
|
90
|
+
if (to) { args = { ...args, [a]: to }; notes.push(`corrected: use[${i}].args.${a} "${v}" -> "${to}"`); }
|
|
91
|
+
}
|
|
92
|
+
if (args.f0 !== undefined && !known.includes("f0") && out.f0 === undefined) {
|
|
93
|
+
const { f0, ...rest } = args;
|
|
94
|
+
args = rest; out = { ...out, f0 };
|
|
95
|
+
notes.push(`corrected: use[${i}].args.f0 -> use[${i}].f0`);
|
|
96
|
+
}
|
|
97
|
+
// an arg of the skill written beside args goes into args
|
|
98
|
+
for (const f of Object.keys(out)) {
|
|
99
|
+
const a = USE_FIELDS.includes(f) ? null : nameFor(f, known);
|
|
100
|
+
if (!a || a in args) continue;
|
|
101
|
+
args = { ...args, [a]: out[f] };
|
|
102
|
+
out = Object.fromEntries(Object.entries(out).filter(([k]) => k !== f));
|
|
103
|
+
notes.push(`corrected: use[${i}].${f} -> use[${i}].args.${a}`);
|
|
104
|
+
}
|
|
105
|
+
// an anchor a skill defines (reachGrip define) is an anchor like any other
|
|
106
|
+
if (plain(args.define) && ANCHOR_FIELDS[args.define.kind]) {
|
|
107
|
+
const define = renameFields(args.define, ["kind", ...ANCHOR_FIELDS[args.define.kind]], `use[${i}].args.define`, notes, FIELD_ALIASES.anchor);
|
|
108
|
+
if (define !== args.define) args = { ...args, define };
|
|
109
|
+
}
|
|
110
|
+
if (args !== given) out = { ...out, args };
|
|
111
|
+
}
|
|
112
|
+
return renameFields(out, USE_FIELDS, `use[${i}]`, notes, FIELD_ALIASES.use);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The body's channels and the spec's own (spec.channels, object and check channels, validate.js). */
|
|
116
|
+
const knownChannels = spec => [...VEC_CHANNELS, ...NUM_CHANNELS, ...MAP_CHANNELS, ...customChannels(spec)];
|
|
117
|
+
|
|
118
|
+
function channelFor(ch, known) {
|
|
119
|
+
const s = sided(ch), side = /_[lr]$/.exec(s)?.[0] ?? "", family = side ? s.slice(0, -2) : s;
|
|
120
|
+
const alias = CHANNEL_ALIASES[family] ?? CHANNEL_ALIASES[squash(family)];
|
|
121
|
+
if (alias && known.includes(alias + side)) return alias + side;
|
|
122
|
+
return nameFor(s, known);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function fixKey(key, i, known, notes) {
|
|
126
|
+
if (!plain(key) || !plain(key.set)) return key;
|
|
127
|
+
let set = key.set, step = key.step;
|
|
128
|
+
for (const ch of Object.keys(key.set)) {
|
|
129
|
+
if (known.includes(ch) || ch.endsWith("_vis") || ch.startsWith("w_")) continue;
|
|
130
|
+
const to = channelFor(ch, known);
|
|
131
|
+
if (!to || to in set) continue;
|
|
132
|
+
set = Object.fromEntries(Object.entries(set).map(([k, v]) => (k === ch ? [to, v] : [k, v])));
|
|
133
|
+
if (Array.isArray(step)) step = step.map(s => (s === ch ? to : s));
|
|
134
|
+
notes.push(`corrected: keys[${i}] (f${key.f}) channel "${ch}" -> "${to}"`);
|
|
135
|
+
}
|
|
136
|
+
for (const side of ["l", "r"]) {
|
|
137
|
+
const map = set[`fingers_${side}`];
|
|
138
|
+
if (!plain(map)) continue;
|
|
139
|
+
const fixed = renameFields(map, FINGER_PRESETS, `keys[${i}] (f${key.f}) fingers_${side}`, notes);
|
|
140
|
+
if (fixed !== map) set = { ...set, [`fingers_${side}`]: fixed };
|
|
141
|
+
}
|
|
142
|
+
return set === key.set ? key : { ...key, set, ...(step ? { step } : {}) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** objects.<id>: { free: { keys } } -> { keys }: a kind name used as a wrapper around the record's own fields. */
|
|
146
|
+
function unwrapObject(id, o, notes) {
|
|
147
|
+
if (!plain(o)) return o;
|
|
148
|
+
const wrap = Object.keys(o).filter(k => ["free", "carry", "hinge", "follow", "switch"].includes(k) && plain(o[k]));
|
|
149
|
+
if (wrap.length !== 1) return o;
|
|
150
|
+
const { [wrap[0]]: inner, ...rest } = o;
|
|
151
|
+
if (Object.keys(inner).some(k => k in rest)) return o;
|
|
152
|
+
notes.push(`corrected: objects.${id}.${wrap[0]} { ${Object.keys(inner).join(", ")} } -> objects.${id} { ${Object.keys(inner).join(", ")} }: an entry's kind is read from its fields, not named`);
|
|
153
|
+
return { ...rest, ...inner };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function fixObject(id, o, notes) {
|
|
157
|
+
const at = `objects.${id}`, rec = unwrapObject(id, o, notes);
|
|
158
|
+
if (!plain(rec)) return rec;
|
|
159
|
+
const all = [...new Set(Object.values(OBJECT_FIELDS).flat())];
|
|
160
|
+
let out = renameFields(rec, all, at, notes);
|
|
161
|
+
if (Array.isArray(out.keys)) out = { ...out, keys: out.keys.map((k, j) => renameFields(k, OBJECT_KEY_FIELDS, `${at}.keys[${j}]`, notes, FIELD_ALIASES.objectKey)) };
|
|
162
|
+
if (plain(out.fall)) out = { ...out, fall: renameFields(out.fall, FALL_FIELDS, `${at}.fall`, notes) };
|
|
163
|
+
else if (Array.isArray(out.fall)) out = { ...out, fall: out.fall.map((fl, j) => renameFields(fl, FALL_FIELDS, `${at}.fall[${j}]`, notes)) };
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const fixGoal = (g, i, notes) => renameFields(g, GOAL_FIELDS, `goals[${i}]`, notes);
|
|
168
|
+
|
|
169
|
+
function fixAnchors(anchors, notes) {
|
|
170
|
+
if (!plain(anchors)) return anchors;
|
|
171
|
+
return Object.fromEntries(Object.entries(anchors).map(([name, a]) => [name, plain(a) && ANCHOR_FIELDS[a.kind]
|
|
172
|
+
? renameFields(a, ["kind", ...ANCHOR_FIELDS[a.kind]], `anchors.${name}`, notes, FIELD_ALIASES.anchor) : a]));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function fixChecks(checks, notes) {
|
|
176
|
+
if (!plain(checks)) return checks;
|
|
177
|
+
return Object.fromEntries(Object.entries(checks).map(([name, list]) => [name, CHECK_ENTRY_FIELDS[name] ? mapList(list, (k, j) => renameFields(k, CHECK_ENTRY_FIELDS[name], `checks.${name}[${j}]`, notes)) : list]));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* correctSpec(spec, notes) -> the spec with every correction above (a new object when one applied,
|
|
182
|
+
* else spec itself); notes gets one "corrected: ..." line per correction. Run before the spec is stored (pipeline.js).
|
|
183
|
+
*/
|
|
184
|
+
function correctSpec(spec, notes = []) {
|
|
185
|
+
if (!plain(spec)) return spec;
|
|
186
|
+
const before = notes.length;
|
|
187
|
+
let s = renameFields(spec, [...SPEC_FIELDS, "probes"], "spec", notes);
|
|
188
|
+
s = moveGenerators(s, notes);
|
|
189
|
+
if (Array.isArray(s.use)) s = { ...s, use: s.use.map((u, i) => fixUse(u, i, notes)) };
|
|
190
|
+
if (s.anchors) s = { ...s, anchors: fixAnchors(s.anchors, notes) };
|
|
191
|
+
if (Array.isArray(s.keys)) { const known = knownChannels(s); s = { ...s, keys: s.keys.map((k, i) => fixKey(k, i, known, notes)) }; }
|
|
192
|
+
if (plain(s.objects)) s = { ...s, objects: Object.fromEntries(Object.entries(s.objects).map(([id, o]) => [id, fixObject(id, o, notes)])) };
|
|
193
|
+
if (Array.isArray(s.goals)) s = { ...s, goals: s.goals.map((g, i) => fixGoal(g, i, notes)) };
|
|
194
|
+
if (Array.isArray(s.fits)) s = { ...s, fits: s.fits.map((f, i) => renameFields(f, FIT_FIELDS, `fits[${i}]`, notes)) };
|
|
195
|
+
if (s.checks) s = { ...s, checks: fixChecks(s.checks, notes) };
|
|
196
|
+
return notes.length > before ? s : spec;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* fitWalks(spec, { sk, scene }, notes, refusals) -> spec with each walkTo's fitted args (skills/basic.js fitTo) written
|
|
201
|
+
* into its use entry, so the stored spec walks where it says and compiles the same again; refusals gets one line per
|
|
202
|
+
* walk it cannot fit (the validator lists them with its own, pipeline.js). A spec that does not expand is left as it is.
|
|
203
|
+
*/
|
|
204
|
+
function fitWalks(spec, { sk, scene } = {}, notes = [], refusals = []) {
|
|
205
|
+
if (!plain(spec) || !Array.isArray(spec.use) || !spec.use.some(u => u?.skill === "walkTo" && plain(u.args) && u.args.to !== undefined)) return spec;
|
|
206
|
+
// a walk may end later freely only in a frames "auto" spec with nothing after it, else into frames nothing uses
|
|
207
|
+
// (skills/basic.js fitTo, roomAfter): busy lists the frames of the object keys, grabs and releases, last the take's last frame
|
|
208
|
+
const busy = Object.values(plain(spec.objects) ? spec.objects : {}).flatMap(o => (plain(o) ? [o.grab, o.release, ...(Array.isArray(o.keys) ? o.keys.map(k => k?.f) : [])] : [])).filter(Number.isFinite);
|
|
209
|
+
const expand = noStop => {
|
|
210
|
+
const fit = { refusals: [], stretch: spec.frames === "auto", busy, last: Number.isFinite(spec.frames) ? spec.frames - 1 : Infinity, noStop, stopped: new Set() };
|
|
211
|
+
try { return { used: expandUse({ ...spec, frames: 20000 }, { sk, scene, fit, auto: true }).used, fit }; } catch { return null; }
|
|
212
|
+
};
|
|
213
|
+
// a walk stopped before a body (walk-fit.js) moves where the next one starts: a stop is kept only while the spec has no
|
|
214
|
+
// more refusals than with every walk on its to (bench replay: 3 of 692 specs refused a later walk), the earliest first
|
|
215
|
+
let run = expand(new Set());
|
|
216
|
+
if (!run) return spec;
|
|
217
|
+
if (run.fit.stopped.size) {
|
|
218
|
+
const off = new Set(run.fit.stopped), onTo = expand(off);
|
|
219
|
+
if (onTo && onTo.fit.refusals.length < run.fit.refusals.length) {
|
|
220
|
+
for (const i of [...run.fit.stopped].sort((a, b) => a - b)) {
|
|
221
|
+
off.delete(i);
|
|
222
|
+
const t = expand(off);
|
|
223
|
+
if (!t || t.fit.refusals.length > onTo.fit.refusals.length) off.add(i);
|
|
224
|
+
}
|
|
225
|
+
run = expand(off) ?? onTo;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const { used, fit } = run;
|
|
229
|
+
refusals.push(...fit.refusals);
|
|
230
|
+
if (!used.some(u => u.adjust)) return spec;
|
|
231
|
+
for (const u of used) if (u.adjust) notes.push(`corrected: ${u.adjust.note}`);
|
|
232
|
+
return { ...spec, use: spec.use.map((u, i) => (used[i]?.adjust ? { ...u, args: { ...u.args, ...used[i].adjust.args } } : u)) };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** prepareSpec(spec, { sk, scene }, notes, refusals): correctSpec, then fitWalks: the spec as it is stored and compiled. */
|
|
236
|
+
export const prepareSpec = (spec, ctx, notes, refusals) => fitWalks(correctSpec(spec, notes), ctx, notes, refusals);
|
package/src/motion3/docs.js
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
// Lane P owns the wording; the code only reads these constants. The procedure lives in the Studio
|
|
3
3
|
// system prompt and the field tables in motion.describe, so these strings say only what each action takes and answers.
|
|
4
4
|
export const MOTION_ACTION_HELP = {
|
|
5
|
-
compile: "Bake a spec on the character's rig and run QA. install: true installs (default), false or \"never\" changes nothing, \"ifClean\" only if the final qa.ok
|
|
5
|
+
compile: "Bake a spec on the character's rig and run QA. install: true installs (default), false or \"never\" changes nothing, \"ifClean\" only if the final qa.ok. "
|
|
6
6
|
+ "Send spec, or base (an answered specId) + optional patch (JSON Patch); base alone or patch: [] re-measures (new probes). "
|
|
7
|
-
+ "
|
|
8
|
-
+ "
|
|
9
|
-
+ "
|
|
7
|
+
+ "Address array items by field, not index: /keys/@f=120/set/hand_l (add creates the key at f120), remove /keys/@f=120, { op: \"move\", from: \"/keys/@f=120\", path: \"/keys/@f=132\" }, "
|
|
8
|
+
+ "/use/@skill=reach&f0=40, /goals/@name=<name>, /objects/<id>/keys/@f=40. "
|
|
9
|
+
+ "spec = { schema: \"cozyclay.motion-spec.v1\", fps: 24, frames (number or \"auto\"), ... }; any number may be { range: [lo, hi] } (searched). "
|
|
10
|
+
+ "probes (at most 16; spec.probes too, never stored): { name, distance: [ref, ref] | position: ref, frames, every? }. "
|
|
11
|
+
+ "Refs need a prefix (joint: prop: anchor: landmark: object: person:; goals[].moved: a bare id too) and take loose bone names; spec.checks need exact ones. "
|
|
10
12
|
+ "repair: { rounds } (1-8, default 4) applies group patches while severity drops. "
|
|
11
|
-
+ "qa judges only what is broken; speed,
|
|
12
|
-
+ "Answers frames, installed, specId, qa { ok, total, goals, groups (GOAL first, then severity; cause, hint, patch?) }, observations, delta (with base), repair, probes, timeline, fixed, search, skills.
|
|
13
|
+
+ "qa judges only what is broken; speed, balance and bends are observations unless checks.limits names them. "
|
|
14
|
+
+ "Answers brief (qa, misses, top groups: a script returns it as is), frames, installed, stuck (when to stop), specId, qa { ok, total, goals, groups (GOAL first, then severity; cause, hint, repair patch?) }, observations (asked: skill ends, contacts, goal points), delta (with base), repair, probes, timeline, fixed, search, skills. more: motion.report pages the rest.",
|
|
13
15
|
qa: "Measure the character's installed take again against the scene as it is now (objects may have moved). Optional checks replace the spec's checks and its skills' checks. "
|
|
14
16
|
+ "Answers specId and qa as motion.compile does (goals, ranked groups with cause, hint and patch); output.more pages with motion.report. Changes nothing.",
|
|
15
17
|
sample: "World positions (metres) of named bones of the installed take at up to 64 frames. Bone names may be loose (any case, a mixamorig: prefix, aliases such as hips or hand_l); an unknown one answers a did-you-mean. "
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// A held object brought to the face leans by default (lane arm). An aim key at the lips without lean or roll (a cup
|
|
2
|
+
// tipped to the mouth) is held as the aim asks only with the elbow out to the side and over the shoulder: the grip the
|
|
3
|
+
// reach took on the table turns the hand with the cup, and the wrist cannot take the rest (the agents' first compiles of
|
|
4
|
+
// bench S6 and S8: the upper arm past 110 deg from hanging on 31 of 56, the elbow over the head on 17). Leaning the aim
|
|
5
|
+
// toward the hand's side lets the elbow stay down (key-aim.js lean); the agent wrote lean on few of those keys and
|
|
6
|
+
// re-keyed the elbows after QA had passed. So such a key leans FACE_LEAN deg (the most lean takes) unless it gives lean
|
|
7
|
+
// or roll: its top (the rim) within FACE_NEAR m of the mouth at its frame, on the pose without the keys, on a body at
|
|
8
|
+
// most SIP m tall (a cup, a can, a bottle: a bat's knob at the shoulder in a batting stance is not a sip, and a group's
|
|
9
|
+
// height is unknown here); the rim stays where aim put it (leanPos). On the 84 first compiles of bench S6, S8 and S10 the agents wrote, the frames of BODY went
|
|
10
|
+
// from 7231 to 5526, of FACE from 1102 to 839 and of WRIST from 1212 to 1007, and the takes with an elbow over the
|
|
11
|
+
// shoulder (qa/arm-checks.js) from 39 to 31; the mouth goals missed on 694 frames instead of 623 (lean 35 within 0.2 m
|
|
12
|
+
// saved fewer BODY and FACE frames and lost 46 mouth frames).
|
|
13
|
+
import { add, scale, norm, dist } from "./compiler/math.js";
|
|
14
|
+
|
|
15
|
+
export const FACE_LEAN = 45;
|
|
16
|
+
const FACE_NEAR = 0.25, SIP = 0.3;
|
|
17
|
+
/**
|
|
18
|
+
* Entry o of a body `top` m tall (0: a group) with FACE_LEAN on its aim keys (no lean, no roll) whose top (`top` m up
|
|
19
|
+
* the aim from pos) is within FACE_NEAR of the mouth at their frame; mouthAt(f) the mouth on the pose without the
|
|
20
|
+
* keys. o itself when none is, or the body is no sip (SIP).
|
|
21
|
+
*/
|
|
22
|
+
export function faceLeaned(o, top, mouthAt) {
|
|
23
|
+
if (!(top > 0 && top <= SIP)) return o;
|
|
24
|
+
let any = false;
|
|
25
|
+
const keys = o.keys.map(k => {
|
|
26
|
+
if (!k.aim || k.lean !== undefined || k.roll !== undefined || !(dist(add(k.pos, scale(norm(k.aim), top)), mouthAt(k.f)) < FACE_NEAR)) return k;
|
|
27
|
+
any = true;
|
|
28
|
+
return { ...k, lean: FACE_LEAN, leaned: true };
|
|
29
|
+
});
|
|
30
|
+
return any ? { ...o, keys } : o;
|
|
31
|
+
}
|
package/src/motion3/fix/index.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
//
|
|
5
5
|
// spec.fix = { reach: true, slide: true, penetration: 0.02, self: true, retime: false } (false = all off)
|
|
6
6
|
// reach wrist targets past the arm's reach go onto it through a soft knee (no snap);
|
|
7
|
-
// ankle targets past the leg's reach lower the hips just enough, eased in and out
|
|
7
|
+
// ankle targets past the leg's reach lower the hips just enough, eased in and out; a hand
|
|
8
|
+
// on a pinned target (a world or a scene object's anchor) that the straight arm misses brings
|
|
9
|
+
// the body to it: lean, hip shift, lower hips, the feet kept (reach-body.js, fixed.reachBody)
|
|
8
10
|
// slide a sole under the floor lifts its ankle onto it (ground.js); a planted heel or ball
|
|
9
11
|
// that drifts less than 1 cm stays put, and a foot turning on its planted ball (heel
|
|
10
12
|
// up) or heel pivots about it whatever the drift; a flat planted foot (heel and ball
|
|
@@ -19,6 +21,7 @@
|
|
|
19
21
|
// grasp a holding hand's fingers close on what it holds and the object rests in the hand (#816);
|
|
20
22
|
// false keeps the preset grip and the grab-frame offset
|
|
21
23
|
import { lowerHips, armReach } from "./reach.js";
|
|
24
|
+
import { reachBody } from "./reach-body.js";
|
|
22
25
|
import { lockFeet } from "./slide.js";
|
|
23
26
|
import { holdTurns } from "./turn.js";
|
|
24
27
|
import { liftSoles } from "./ground.js";
|
|
@@ -46,6 +49,7 @@ export function applyFixes(out, fix, engine, { whole = true } = {}) {
|
|
|
46
49
|
if (fix.retime && whole) put("retime", retime(out, engine));
|
|
47
50
|
if (fix.slide) put("ground", liftSoles(out, engine));
|
|
48
51
|
if (fix.reach) put("reachLegs", lowerHips(out, engine));
|
|
52
|
+
if (fix.reach) put("reachBody", reachBody(out, engine));
|
|
49
53
|
if (fix.slide) put("slide", withTurns(holdTurns(out, engine), lockFeet(out, engine)));
|
|
50
54
|
// a pivot can hold a foot past the leg's reach: the hips go down for those frames too
|
|
51
55
|
if (fix.reach && fixed.slide?.pivots) put("reachLegs", mergeReach(fixed.reachLegs, lowerHips(out, engine)));
|