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.
Files changed (110) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/agent/agent-routes.mjs +4 -4
  3. package/bin/agent/main-agent.mjs +21 -7
  4. package/bin/agent/main-prompt.mjs +7 -6
  5. package/bin/agent/script-tool.mjs +2 -1
  6. package/bin/agent/studio-prompt.mjs +1 -1
  7. package/bin/agent/studio-rebase.mjs +5 -4
  8. package/bin/agent/studio-task-routes.mjs +1 -1
  9. package/bin/agent/studio-tasks.mjs +116 -25
  10. package/bin/agent/studio-tools.mjs +3 -2
  11. package/bin/cozyclay.mjs +4 -2
  12. package/bin/live-endpoint.mjs +2 -0
  13. package/dist/app/index.html +6 -6
  14. package/dist/assets/TaskBoard-GR8xZm5c.js +1 -0
  15. package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
  16. package/dist/assets/app-C4QTcDcb.js +5144 -0
  17. package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
  18. package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
  19. package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
  20. package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
  21. package/dist/assets/task-board-BqReHzOi.js +15 -0
  22. package/dist/assets/workflow-C87p6E1E.js +179 -0
  23. package/dist/cozyclay-package.json +1 -1
  24. package/dist/index.html +1 -1
  25. package/dist/sitemap.xml +1 -1
  26. package/dist/workflow/index.html +4 -4
  27. package/mcp/README.md +1 -1
  28. package/mcp/live-hub.mjs +5 -3
  29. package/mcp/tool-handlers.mjs +5 -5
  30. package/package.json +1 -1
  31. package/src/App.jsx +42 -64
  32. package/src/bus-task-scopes.js +59 -11
  33. package/src/bus-task-targets.js +35 -9
  34. package/src/bus-tasks.js +23 -10
  35. package/src/command-bus.js +7 -4
  36. package/src/commands/ai.js +1 -1
  37. package/src/commands/export.js +1 -1
  38. package/src/commands/motion3.js +27 -7
  39. package/src/export-workspace.js +244 -0
  40. package/src/keyframe-pack.js +5 -1
  41. package/src/live-control.js +14 -7
  42. package/src/motion3/compile-job.js +15 -3
  43. package/src/motion3/compiler/channels.js +74 -24
  44. package/src/motion3/compiler/compile.js +2 -1
  45. package/src/motion3/compiler/idle.js +2 -0
  46. package/src/motion3/compiler/overlap.js +180 -0
  47. package/src/motion3/compiler/pose.js +6 -4
  48. package/src/motion3/compiler/validate-common.js +1 -0
  49. package/src/motion3/compiler/validate-parts.js +5 -1
  50. package/src/motion3/compiler/validate.js +30 -10
  51. package/src/motion3/correct.js +236 -0
  52. package/src/motion3/docs.js +8 -6
  53. package/src/motion3/face-lean.js +31 -0
  54. package/src/motion3/fix/index.js +5 -1
  55. package/src/motion3/fix/reach-body.js +172 -0
  56. package/src/motion3/hint-check.js +44 -0
  57. package/src/motion3/json-patch.js +120 -12
  58. package/src/motion3/lenient.js +46 -4
  59. package/src/motion3/object-keys.js +17 -5
  60. package/src/motion3/pipeline.js +46 -9
  61. package/src/motion3/qa/arm-checks.js +96 -0
  62. package/src/motion3/qa/asked.js +113 -0
  63. package/src/motion3/qa/goals.js +37 -8
  64. package/src/motion3/qa/hints-goal.js +6 -5
  65. package/src/motion3/qa/index.js +5 -2
  66. package/src/motion3/qa/key-checks.js +4 -3
  67. package/src/motion3/qa/motion-life.js +243 -0
  68. package/src/motion3/qa/spec-checks.js +15 -9
  69. package/src/motion3/qa/verdict.js +50 -0
  70. package/src/motion3/refs.js +38 -14
  71. package/src/motion3/repair-watch.js +132 -0
  72. package/src/motion3/repair.js +52 -19
  73. package/src/motion3/report.js +50 -9
  74. package/src/motion3/schema/topics.js +6 -4
  75. package/src/motion3/schema/types.js +1 -1
  76. package/src/motion3/skills/basic.js +15 -8
  77. package/src/motion3/skills/index.js +52 -18
  78. package/src/motion3/skills/info.js +1 -1
  79. package/src/motion3/skills/stance.js +6 -0
  80. package/src/motion3/skills/walk-fit.js +170 -0
  81. package/src/panels/PoseImagePanel.jsx +3 -2
  82. package/src/panels/SculptPanel.jsx +2 -2
  83. package/src/result-modal.jsx +3 -3
  84. package/src/shell/ExportFolderPreference.jsx +103 -0
  85. package/src/shell/PreferencesDialog.jsx +3 -166
  86. package/src/shell/StatusBar.jsx +1 -1
  87. package/src/shell/preference-controls.jsx +114 -0
  88. package/src/shell/preference-pages.jsx +72 -0
  89. package/src/shell/preferences.css +46 -0
  90. package/src/shot-prompt.js +29 -9
  91. package/src/studio-actions.js +2 -2
  92. package/src/studio-agent-motion.js +19 -9
  93. package/src/studio-agent-protocol.js +1 -1
  94. package/src/studio-app-binding.js +3 -2
  95. package/src/workflow/AgentPanel.jsx +2 -2
  96. package/src/workflow/CozySceneNode.jsx +1 -1
  97. package/src/workflow/WorkflowBuilder.jsx +2 -2
  98. package/src/workflow/keyframe-pack-request.js +3 -10
  99. package/src/workflow/task-board.css +1 -1
  100. package/src/workflow/task-board.js +7 -5
  101. package/tools/loc-baseline.json +1 -2
  102. package/tools/run-tests.mjs +5 -5
  103. package/dist/assets/TaskBoard-ngdqiu7u.js +0 -1
  104. package/dist/assets/app-DmR7jBkz.js +0 -5144
  105. package/dist/assets/compile-worker-D0pKioa0.js +0 -5
  106. package/dist/assets/motion3-compile-BB3_1-EB.js +0 -1
  107. package/dist/assets/scene-asset-sync-CBx7XRti.css +0 -1
  108. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +0 -13
  109. package/dist/assets/task-board-ErQvfoaz.js +0 -14
  110. 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 => (/^spine_/.test(ch) ? "spine" : /^head_/.test(ch) ? "head" : /^(aswing|abend)_[lr]$/.test(ch) ? "arms" : null);
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
- /** spec.flow -> { smooth, lag: { spine, head, arms } }: undefined is the default, false turns both off. */
47
- export function flowOptions(flow) {
48
- if (flow === false) return { smooth: false, lag: { spine: 0, head: 0, arms: 0 } };
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
- return { smooth: f.smooth !== false, lag: each };
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 byChannel) {
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
- // the trail eases out over the last 2 * lag frames, so the take ends on the last value
95
- const at = f => (lag > 0 ? f - lag * Math.min(1, (frameCount - 1 - f) / (2 * lag)) : f);
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
- out[ch] = frames;
113
- if (shape === "num" && (smooth || lag > 0) && ikSides(ch).length) { const e = evalSeries(series[0], false); plain[ch] = Array.from({ length: frameCount }, (_, f) => e(f)); }
114
- }
115
- // flow fades out as the arm goes onto its IK (the spine with either arm)
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
- spread(sk, s, ["Spine", "Spine1", "Spine2"], num(c.spine_yaw), num(c.spine_pitch), num(c.spine_roll));
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) { if (!spec.props?.[e.prop]?.points?.[e.point]) err(`${at}.effector ${e.prop}.${e.point} is not a point of spec.props`); }
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, so the agent fixes them in one edit.
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 checked when given.
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 (k !== "smooth" && k !== "lag") unknown(`spec.flow has unknown field "${k}" (fields: smooth, lag)`);
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 < MAX_ERRORS) errors.push(m); }, unknown = lenient ? () => {} : err;
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) throw new Error(`MOTION_SPEC_INVALID: ${errors.join("\n")}${errors.length >= MAX_ERRORS ? "\n(more problems not listed)" : ""}`);
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
  }