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
@@ -1,11 +1,22 @@
1
- // JSON Patch (RFC 6902) on a motion spec: add, remove, replace and test, with "-" to append to
1
+ // JSON Patch (RFC 6902) on a motion spec: add, remove, replace, move and test, with "-" to append to
2
2
  // an array in add. Used for motion.compile { base, patch } (C7) and by repair (C6). Errors are
3
3
  // MOTION_SPEC_INVALID (INVALID_ARGUMENT to the agent) and name the op and what is there:
4
- // patch[1] (replace /keys/12/f): /keys/12 does not exist (keys has 9 entries)
4
+ // patch[1] (replace /keys/12/f): /keys/12 does not exist (keys has 9 entries; by frame: /keys/@f=<f>, f 0, 24, 40, ...)
5
5
  // With a notes list (motion.compile, lenient.js), replace on a member an existing object lacks adds it
6
6
  // and says so; repair (no notes) stays strict.
7
+ //
8
+ // Stable addressing: an array step may be a selector "@field=value" ("&" joins more), the element whose
9
+ // field reads value, instead of an index. An index edit goes wrong silently when an earlier entry is
10
+ // added or removed, and the agent's own logs show index paths are most of what it patches, so:
11
+ // /keys/@f=120/set/hand_l the key at frame 120 (several there: the last, the one that wins the bake)
12
+ // /objects/cup/keys/@f=40/pos an object key by frame
13
+ // /use/@skill=reach&f0=40/args/at a skill by name and first frame; /goals/@name=cup lifted; /fits/@anchor=cupHandle
14
+ // add (and a lenient replace) through a selector that matches nothing creates the element ({ f: 120, set: {} }):
15
+ // one op sets a channel on a key whether the key exists or not. remove /keys/@f=120 removes every key at 120;
16
+ // a key moves with { op: "move", from: "/keys/@f=120", path: "/keys/@f=132" } (its f becomes 132; refused when
17
+ // 132 is keyed). A selector that matches nothing lists the values there, nearest frames first, so a retry lands.
7
18
 
8
- const OPS = ["add", "remove", "replace", "test"];
19
+ const OPS = ["add", "remove", "replace", "move", "test"];
9
20
  const bad = msg => { throw new Error(`MOTION_SPEC_INVALID: ${msg}`); };
10
21
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
11
22
 
@@ -13,10 +24,68 @@ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
13
24
  const tokens = path => path.split("/").slice(1).map(t => t.replaceAll("~1", "/").replaceAll("~0", "~"));
14
25
  const pointer = parts => parts.map(t => `/${String(t).replaceAll("~", "~0").replaceAll("/", "~1")}`).join("");
15
26
 
16
- function what(container, name) {
17
- if (Array.isArray(container)) return `${name} has ${container.length} entries`;
18
- const keys = Object.keys(container);
19
- return keys.length ? `${name} has ${keys.slice(0, 8).join(", ")}${keys.length > 8 ? ", ..." : ""}` : `${name} is empty`;
27
+ const SELECTOR = /^@[A-Za-z_]\w*=[^&]*(&[A-Za-z_]\w*=[^&]*)*$/;
28
+ /** "@skill=reach&f0=40" -> [["skill", "reach"], ["f0", "40"]], or null for any other step. */
29
+ const conditions = t => (SELECTOR.test(t) ? t.slice(1).split("&").map(c => [c.slice(0, c.indexOf("=")), c.slice(c.indexOf("=") + 1)]) : null);
30
+ const matches = (el, conds) => el !== null && typeof el === "object" && conds.every(([k, v]) => Object.hasOwn(el, k) && String(el[k]) === v);
31
+ const isMap = v => v !== null && typeof v === "object" && !Array.isArray(v);
32
+ const numeric = v => (/^-?\d+(\.\d+)?$/.test(v) ? Number(v) : v);
33
+ // the fields that tell entries apart, in the order an error offers them
34
+ const NAMES = ["name", "skill", "anchor", "f", "f0", "side", "type"];
35
+
36
+ /** What an array holds, for an error: its length, and how to address it by a field its entries carry. */
37
+ function what(container, name, want) {
38
+ if (!Array.isArray(container)) {
39
+ const keys = Object.keys(container);
40
+ return keys.length ? `${name} has ${keys.slice(0, 8).join(", ")}${keys.length > 8 ? ", ..." : ""}` : `${name} is empty`;
41
+ }
42
+ const field = want?.[0] ?? NAMES.find(k => container.some(el => el && typeof el === "object" && Object.hasOwn(el, k)));
43
+ if (!field) return `${name} has ${container.length} entries`;
44
+ const all = [...new Set(container.filter(el => el && Object.hasOwn(el, field)).map(el => el[field]))];
45
+ // frames: the nearest to the one asked for, shown in order
46
+ const target = Number(want?.[1]);
47
+ const values = all.every(v => typeof v === "number")
48
+ ? (Number.isFinite(target) ? [...all].sort((a, b) => Math.abs(a - target) - Math.abs(b - target)) : all).slice(0, 8).sort((a, b) => a - b) : all.slice(0, 8);
49
+ return `${name} has ${container.length} entries; by ${field}: /${name}/@${field}=<${field}>, ${field} ${values.join(", ")}${all.length > values.length ? ", ..." : ""}`;
50
+ }
51
+
52
+ /** Index (as a string) of the element of arr that step t selects; creates it when `create`, else -1 when none. */
53
+ function select(arr, t, rest, tag, name, create) {
54
+ const conds = conditions(t), hits = [];
55
+ arr.forEach((el, i) => matches(el, conds) && hits.push(i));
56
+ const frameOnly = conds.length === 1 && conds[0][0] === "f";
57
+ if (hits.length > 1 && !frameOnly) {
58
+ const field = NAMES.find(k => !conds.some(c => c[0] === k) && new Set(hits.map(i => arr[i]?.[k])).size === hits.length);
59
+ bad(`${tag}: ${hits.length} ${name} entries have ${t.slice(1)}${field ? ` (${field} ${hits.map(i => arr[i][field]).join(", ")}; add &${field}=<${field}>)` : ` (indices ${hits.join(", ")})`}`);
60
+ }
61
+ if (hits.length) {
62
+ // keys at one frame merge, the last winning: prefer the last that holds the rest of the path, then its parent
63
+ const has = (i, path) => { let n = arr[i]; for (const p of path) { if (n === null || typeof n !== "object" || !Object.hasOwn(n, p)) return false; n = n[p]; } return true; };
64
+ return String(hits.findLast(i => has(i, rest)) ?? hits.findLast(i => has(i, rest.slice(0, -1))) ?? hits.at(-1));
65
+ }
66
+ if (!create) return -1;
67
+ const el = Object.fromEntries(conds.map(([k, v]) => [k, numeric(v)]));
68
+ if (rest.length > 1) el[rest[0]] = {};
69
+ arr.push(el);
70
+ return String(arr.length - 1);
71
+ }
72
+
73
+ /** parts with every selector step but the last turned into an index; creates elements when `create`. */
74
+ function resolve(doc, parts, tag, create, notes) {
75
+ let node = doc;
76
+ const out = [];
77
+ for (let i = 0; i < parts.length; i++) {
78
+ let t = parts[i];
79
+ if (Array.isArray(node) && conditions(t) && i < parts.length - 1) {
80
+ const name = i ? out[i - 1] : "the spec", k = select(node, t, parts.slice(i + 1), tag, name, false);
81
+ if (k === -1 && !create) bad(`${tag}: ${pointer([...out, t])} matches nothing (${what(node, name, conditions(t)[0])})`);
82
+ t = k === -1 ? select(node, t, parts.slice(i + 1), tag, name, true) : k;
83
+ if (k === -1) { out.created = true; notes?.push(`${tag}: created ${pointer(out)}/${t} (${parts[i].slice(1)})`); }
84
+ }
85
+ out.push(t);
86
+ if (i < parts.length - 1) node = node !== null && typeof node === "object" ? node[Array.isArray(node) ? Number(t) : t] : undefined;
87
+ }
88
+ return out;
20
89
  }
21
90
 
22
91
  /** The container holding the last token, walking every token before it. */
@@ -32,18 +101,57 @@ function parentOf(doc, parts, tag) {
32
101
  return node;
33
102
  }
34
103
 
104
+ /** The last step on an array, a selector: add appends (its fields set), remove drops every match, the rest take the one match. */
105
+ function bySelector(node, last, op, value, tag, name) {
106
+ const conds = conditions(last), hits = node.flatMap((el, i) => (matches(el, conds) ? [i] : []));
107
+ if (op === "add") {
108
+ if (hits.length) bad(`${tag}: ${name} already has an entry with ${last.slice(1)}; replace its fields or remove it first`);
109
+ if (value === null || typeof value !== "object" || Array.isArray(value)) bad(`${tag}: add through a selector takes an object value`);
110
+ node.push({ ...structuredClone(value), ...Object.fromEntries(conds.map(([k, v]) => [k, numeric(v)])) });
111
+ return null;
112
+ }
113
+ if (!hits.length) bad(`${tag}: ${name} has no entry with ${last.slice(1)} (${what(node, name, conds[0])})`);
114
+ if (op === "remove") { for (const i of hits.reverse()) node.splice(i, 1); return null; }
115
+ return select(node, last, [], tag, name, false);
116
+ }
117
+
35
118
  function applyOne(doc, op, i, notes) {
36
119
  if (!op || typeof op !== "object" || !OPS.includes(op.op)) bad(`patch[${i}]: op must be ${OPS.join(", ")}, got ${JSON.stringify(op?.op)}`);
37
120
  const tag = `patch[${i}] (${op.op} ${op.path})`;
38
- if (typeof op.path !== "string" || (op.path !== "" && !op.path.startsWith("/"))) bad(`${tag}: path must be a JSON Pointer such as "/keys/0/f"`);
121
+ if (typeof op.path !== "string" || (op.path !== "" && !op.path.startsWith("/"))) bad(`${tag}: path must be a JSON Pointer such as "/keys/0/f" or "/keys/@f=24/set/hand_l"`);
122
+ if (op.op === "move") {
123
+ if (typeof op.from !== "string" || !op.from.startsWith("/")) bad(`${tag}: move needs from, a JSON Pointer such as "/keys/@f=120"`);
124
+ const parts = resolve(doc, tokens(op.from), tag, false), last = parts.at(-1), node = parentOf(doc, parts, tag), name = parts.at(-2) ?? "the spec";
125
+ let value;
126
+ if (Array.isArray(node) && conditions(last)) {
127
+ // every key at that frame moves, merged into one (later entries win, as in the bake)
128
+ const hits = node.flatMap((el, j) => (matches(el, conditions(last)) ? [j] : []));
129
+ if (!hits.length) bad(`${tag}: ${name} has no entry with ${last.slice(1)} (${what(node, name, conditions(last)[0])})`);
130
+ value = hits.map(j => node[j]).reduce((a, b) => Object.fromEntries([...new Set([...Object.keys(a), ...Object.keys(b)])].map(f => [f,
131
+ isMap(a[f]) && isMap(b[f]) ? { ...a[f], ...b[f] } : Array.isArray(a[f]) && Array.isArray(b[f]) && f === "step" ? [...new Set([...a[f], ...b[f]])] : Object.hasOwn(b, f) ? b[f] : a[f]])));
132
+ for (const j of hits.reverse()) node.splice(j, 1);
133
+ } else {
134
+ if (!Object.hasOwn(node, Array.isArray(node) ? Number(last) : last)) bad(`${tag}: ${op.from} does not exist (${what(node, name)})`);
135
+ value = node[Array.isArray(node) ? Number(last) : last];
136
+ if (Array.isArray(node)) node.splice(Number(last), 1); else delete node[last];
137
+ }
138
+ return applyOne(doc, { op: "add", path: op.path, value }, i, notes);
139
+ }
39
140
  if (op.op !== "remove" && !Object.hasOwn(op, "value")) bad(`${tag}: needs a value`);
40
141
  if (op.path === "") {
41
142
  if (op.op === "replace") return structuredClone(op.value);
42
143
  if (op.op === "test") { if (!same(doc, op.value)) bad(`${tag}: the spec differs from the test value`); return doc; }
43
144
  bad(`${tag}: ${op.op} needs a path below the spec`);
44
145
  }
45
- const parts = tokens(op.path), last = parts[parts.length - 1], node = parentOf(doc, parts, tag);
146
+ const create = op.op === "add" || (op.op === "replace" && Boolean(notes));
147
+ const parts = resolve(doc, tokens(op.path), tag, create, notes), node = parentOf(doc, parts, tag);
148
+ let last = parts[parts.length - 1];
46
149
  const name = parts.length > 1 ? parts[parts.length - 2] : "the spec";
150
+ if (Array.isArray(node) && conditions(last)) {
151
+ if (op.op === "replace" && notes && !node.some(el => matches(el, conditions(last)))) { notes.push(`${tag}: nothing had ${last.slice(1)}; added`); return applyOne(doc, { ...op, op: "add" }, i); }
152
+ last = bySelector(node, last, op.op, op.value, tag, name);
153
+ if (last === null) return doc;
154
+ }
47
155
  if (Array.isArray(node)) {
48
156
  if (last === "-") { if (op.op !== "add") bad(`${tag}: "-" (append) works with add only`); node.push(structuredClone(op.value)); return doc; }
49
157
  const k = /^\d+$/.test(last) ? Number(last) : NaN;
@@ -55,7 +163,7 @@ function applyOne(doc, op, i, notes) {
55
163
  else if (!same(node[k], op.value)) bad(`${tag}: value is ${JSON.stringify(node[k])?.slice(0, 60)}, not ${JSON.stringify(op.value)?.slice(0, 60)}`);
56
164
  return doc;
57
165
  }
58
- if (op.op === "replace" && notes && !Object.hasOwn(node, last)) notes.push(`${tag}: ${op.path} did not exist (${what(node, name)}); added`);
166
+ if (op.op === "replace" && notes && !Object.hasOwn(node, last)) { if (!parts.created) notes.push(`${tag}: ${op.path} did not exist (${what(node, name)}); added`); }
59
167
  else if (op.op !== "add" && !Object.hasOwn(node, last)) bad(`${tag}: ${op.path} does not exist (${what(node, name)})`);
60
168
  if (op.op === "remove") delete node[last];
61
169
  else if (op.op === "test") { if (!same(node[last], op.value)) bad(`${tag}: value is ${JSON.stringify(node[last])?.slice(0, 60)}, not ${JSON.stringify(op.value)?.slice(0, 60)}`); }
@@ -66,9 +174,9 @@ function applyOne(doc, op, i, notes) {
66
174
  /**
67
175
  * Applies ops in order to a copy of doc (doc is never changed) and returns the copy; the first
68
176
  * failing op throws MOTION_SPEC_INVALID and nothing is applied. notes (optional) turns a replace on a
69
- * missing object member into an add and collects a line for it.
177
+ * missing object member (or a selector that matches nothing) into an add and collects a line for it.
70
178
  * @param {object} doc
71
- * @param {{ op: "add" | "remove" | "replace" | "test", path: string, value?: any }[]} ops
179
+ * @param {{ op: "add" | "remove" | "replace" | "move" | "test", path: string, from?: string, value?: any }[]} ops
72
180
  * @param {string[]} [notes]
73
181
  */
74
182
  export function applyPatch(doc, ops, notes) {
@@ -4,16 +4,22 @@
4
4
  // relaxSpec on the expanded spec before validation (pipeline.js): a goal window partly past the
5
5
  // clip is cut to it; an object key with only pos takes the turn of the key before it
6
6
  // (else after it, else its scene pose: rot for a free entry, aim for a carry so the
7
- // arm picks the roll); a rot of length strictly between 0.5 and 2 is normalized
7
+ // arm picks the roll); a rot of length strictly between 0.5 and 2 is normalized; a knee
8
+ // pole that points backward in the body frame but forward read as a world direction at
9
+ // the root yaw of its frame (a body turned 90 deg or more) is that world direction; a bare
10
+ // name in goals near / above that is exactly one ref (refs.js bareRefs) is that ref
11
+ // (spelling and placement slips in the sent spec: correct.js, before it is stored)
8
12
  // clampProbe a probe window partly past the take is cut to it (probes.js)
9
13
  // fitEvery a position probe's every grows to fit its 16 rows (probes.js)
10
14
  // clampFrames contact-sheet frames past the range are pulled into it (studio-motion-visual.js)
11
15
  // normalizeCompileArgs install spellings ("always", "if_clean", ...) and repair: { rounds: 0 } / true
12
16
  // (the registry's normalizeArgs, before the schema check)
13
- import { quatFromMat } from "./compiler/math.js";
17
+ import { quatFromMat, apply, ry } from "./compiler/math.js";
18
+ import { stateAt } from "./compiler/walk.js";
14
19
  import { groupsOf } from "./scene.js";
15
20
  import { isCarried } from "./carried.js";
16
21
  import { isFree } from "./free-motion.js";
22
+ import { bareRefs } from "./refs.js";
17
23
 
18
24
  const fmtRange = r => `[${r[0]},${r[1]}]`;
19
25
  /** Adds note to notes once (search trials expand the same spec again). */
@@ -72,13 +78,49 @@ function normalizeRots(id, o, notes) {
72
78
  });
73
79
  }
74
80
 
81
+ const r3 = v => v.map(x => Math.round(x * 1000) / 1000);
82
+ /** A knee pole sent as a world direction (agents facing -Z write [0, 0, -1] for "forward"): its body-frame reading at the root yaw. */
83
+ function worldPoles(spec, notes) {
84
+ if (!Array.isArray(spec.keys)) return;
85
+ spec.keys.forEach((key, i) => {
86
+ for (const ch of ["knee_l", "knee_r"]) {
87
+ const v = key?.set?.[ch];
88
+ if (!(Array.isArray(v) && v.length === 3 && v.every(Number.isFinite))) continue;
89
+ const l = Math.hypot(...v);
90
+ if (!(l > 1e-6 && v[2] < -0.7 * l)) continue;
91
+ const yaw = stateAt(spec.keys, key.f).yaw;
92
+ if (!(Number.isFinite(yaw) && Math.abs(((yaw % 360) + 540) % 360 - 180) >= 90)) continue;
93
+ const body = r3(apply(ry(-yaw), v));
94
+ if (body[2] < 0.7 * l) continue;
95
+ key.set[ch] = body;
96
+ note(notes, `corrected: ${key.src?.startsWith("keys[") ? key.src : `keys[${i}]`} (f${key.f}) ${ch} [${r3(v)}] is a world direction at root yaw ${Math.round(yaw)}: body frame [${body}] (a pole is +z forward)`);
97
+ }
98
+ });
99
+ }
100
+
101
+ /** goals[i].near / above: a bare name with one reading in the expanded spec, rig and scene takes it ("LeftHand" -> joint:LeftHand). */
102
+ function bareGoalRefs(spec, ctx, notes) {
103
+ if (!Array.isArray(spec.goals)) return;
104
+ spec.goals.forEach((g, i) => {
105
+ for (const kind of ["near", "above"]) (Array.isArray(g?.[kind]) ? g[kind] : []).forEach((ref, j) => {
106
+ if (typeof ref !== "string" || /^[a-z]+:/.test(ref)) return;
107
+ const known = bareRefs(spec, ctx, ref);
108
+ if (known.length !== 1) return;
109
+ g[kind][j] = known[0];
110
+ note(notes, `corrected: goals[${i}].${kind}[${j}] "${ref}" -> "${known[0]}"`);
111
+ });
112
+ });
113
+ }
114
+
75
115
  /**
76
- * relaxSpec(spec, scene, notes): the fixes above, in place on the expanded spec (a plain JSON copy the
116
+ * relaxSpec(spec, scene, notes, sk): the fixes above, in place on the expanded spec (a plain JSON copy the
77
117
  * compile measures; the stored spec stays as sent). notes collects one line per fix.
78
118
  */
79
- export function relaxSpec(spec, scene, notes) {
119
+ export function relaxSpec(spec, scene, notes, sk) {
80
120
  if (!spec || typeof spec !== "object") return spec;
81
121
  relaxGoals(spec, notes);
122
+ bareGoalRefs(spec, { sk, scene }, notes);
123
+ worldPoles(spec, notes);
82
124
  for (const [id, o] of Object.entries(spec.objects ?? {})) {
83
125
  if (!o || typeof o !== "object" || !Array.isArray(o.keys) || !(isCarried(o) || isFree(o))) continue;
84
126
  fillTurns(id, o, scene, notes);
@@ -13,10 +13,11 @@
13
13
  import { isCarried, carriedPose, compose, relative } from "./carried.js";
14
14
  import { seatsInHand, seatOffset, settleAt, between as slide } from "./seat.js";
15
15
  import { groupsOf } from "./scene.js";
16
- import { restState } from "./compiler/skeleton.js";
16
+ import { restState, bonePoint } from "./compiler/skeleton.js";
17
17
  import { add, scale, sub, apply, T, matFromQuat, quatFromMat } from "./compiler/math.js";
18
18
  import { qmul, qconj, qlog, qexp, qrot, qdot, qnorm } from "./compiler/quat.js";
19
19
  import { keyRots, leanPos } from "./key-aim.js";
20
+ import { faceLeaned } from "./face-lean.js";
20
21
  import { holdCost } from "./compiler/hold.js";
21
22
  import { polePlan, smoothPath, smoothed } from "./hold-plan.js";
22
23
  import { secondGrip, secondHold, gripGate } from "./second-hand.js";
@@ -60,7 +61,7 @@ export function curveIn(out, id) {
60
61
  if (!had || had.map !== map || had.baked !== baked) {
61
62
  const hold = out.ctx.hold, solved = new Map(), at = t => { const f = Math.round(t); if (!solved.has(f)) solved.set(f, hold.plainFrame(f).s); return solved.get(f); };
62
63
  const plain = t => quatFromMat(hold.carryOn(id, at(t)).R), strain = (t, pos, q, opts) => hold.strainOn(id, at(t), { pos, R: matFromQuat(q) }, opts);
63
- cache.set(id, { map, baked, curve: curveOf(out.ctx.spec.objects[id], f => (map ? map(f) : f), plain, strain, 2 * (out.ctx.scene?.objects?.[id]?.half?.[1] ?? 0)) });
64
+ cache.set(id, { map, baked, curve: curveOf(hold.entry?.(id) ?? out.ctx.spec.objects[id], f => (map ? map(f) : f), plain, strain, 2 * (out.ctx.scene?.objects?.[id]?.half?.[1] ?? 0)) });
64
65
  }
65
66
  return cache.get(id).curve;
66
67
  }
@@ -112,6 +113,17 @@ export function holdHook(ctx, out, solvePlain, solveLed) {
112
113
  const { sk, spec, scene } = ctx, held = keyedEntries(spec);
113
114
  if (!held.length) return null;
114
115
  const seated = new Set(held.map(([id]) => id).filter(id => seatsInHand(spec, spec.objects[id], scene, id)).map(id => spec.objects[id]));
116
+ // the entry as the compile holds it: mouth keys leaned (faceLeaned), read once per compile on the frames without keys
117
+ const entries = new WeakMap(), mapped0 = f => (out.timeMap ? Math.round(out.timeMap(f)) : f);
118
+ const entry = id => {
119
+ if (!entries.has(out)) entries.set(out, new Map());
120
+ const cache = entries.get(out);
121
+ if (!cache.has(id)) {
122
+ const top = 2 * (scene?.objects?.[id]?.half?.[1] ?? 0);
123
+ cache.set(id, faceLeaned(spec.objects[id], top, f => bonePoint(solvePlain(mapped0(f)).s, sk.index.Head, sk.landmarks.mouth)));
124
+ }
125
+ return cache.get(id);
126
+ };
115
127
  const palmOf = (o, s) => carriedPose(sk, spec, s, { prop: o.prop }, seated.has(o)), attached = new WeakMap(), attaching = new Set();
116
128
  const restAt = (id, f) => ctx.restingAt?.(id, f) ?? scene?.objects?.[id] ?? groupsOf(scene)[id];
117
129
  // a keyed carry of a thin body (a cup, a can, a bar) whose grip is free (no at/along, no orient: false, no grasp fit, no keyed
@@ -130,7 +142,7 @@ export function holdHook(ctx, out, solvePlain, solveLed) {
130
142
  && mapped(Math.min(...o.keys.map(key => key.f))) <= grab;
131
143
  if (!free) return null;
132
144
  attaching.add(id);
133
- try { cache.set(id, chooseGrip({ sk, o, side: SIDES[k], grab, rest: restAt(id, grab), plain: f => solvePlain(f).s, palmOf: s => palmOf(o, s), map: mapped })); } finally { attaching.delete(id); }
145
+ try { cache.set(id, chooseGrip({ sk, o: entry(id), side: SIDES[k], grab, rest: restAt(id, grab), plain: f => solvePlain(f).s, palmOf: s => palmOf(o, s), map: mapped })); } finally { attaching.delete(id); }
134
146
  return cache.get(id);
135
147
  };
136
148
  const attach = id => {
@@ -156,7 +168,7 @@ export function holdHook(ctx, out, solvePlain, solveLed) {
156
168
  const strainOn = (id, s, pose, opts) => { const o = spec.objects[id]; return holdCost(sk, s, SIDES[spec.props[o.prop].hand], compose(pose, invert(attach(id))), palmOf(o, s), opts); };
157
169
  const heldAt = (id, o, f) => {
158
170
  const curve = curveIn(out, id), w = leadWeight(curve, spanIn(out, o), f);
159
- return w > 0 ? { w, palm: compose(keyedPose(curve, f), invert(attachAt(id, f))), measure: s => palmOf(o, s), comfort: o.keys.some(k => k.lean > 0) } : null;
171
+ return w > 0 ? { w, palm: compose(keyedPose(curve, f), invert(attachAt(id, f))), measure: s => palmOf(o, s), comfort: entry(id).keys.some(k => k.lean > 0) } : null;
160
172
  };
161
173
  // the elbow's planned path over the frames the keys lead (hold-plan.js), once per baked channels and time map
162
174
  const plans = new WeakMap();
@@ -268,5 +280,5 @@ export function holdHook(ctx, out, solvePlain, solveLed) {
268
280
  const firstKey = id => { const t = Math.min(...spec.objects[id].keys.map(k => k.f)); return out.timeMap ? Math.round(out.timeMap(t)) : t; };
269
281
  // the grips chosen for keyed carries (observations.wrist reads them): [{ id, hand, name, cost, today }]
270
282
  const grips = () => held.map(([id, o]) => { const p = chosen(id); return p && { id, hand: spec.props[o.prop].hand, name: p.name, cost: p.cost, today: p.today }; }).filter(Boolean);
271
- return { lead, attach, attachAt, attaching: id => attaching.has(id), plainFrame: solvePlain, carryOn, strainOn, span: id => spanIn(out, spec.objects[id]), firstKey, grips };
283
+ return { lead, attach, attachAt, attaching: id => attaching.has(id), plainFrame: solvePlain, entry, carryOn, strainOn, span: id => spanIn(out, spec.objects[id]), firstKey, grips };
272
284
  }
@@ -5,11 +5,14 @@
5
5
  // a cause and a hint per issue group against the agent's own spec, the style quantities are
6
6
  // measured (qa/style.js observeMotion; keyed objects: every key as drawn, qa/key-checks.js
7
7
  // heldReadings; each hand and foot against the nearest object, qa/near.js observeNear; each holding
8
- // wrist's bend, qa/wrist-obs.js observeWrist; a skill
8
+ // wrist's bend, qa/wrist-obs.js observeWrist; how mechanical it reads against the agent's own
9
+ // keys, qa/motion-life.js observeLife; a skill
9
10
  // paced to fit a short span: observations.paced; a skill whose release or
10
- // settle the last frame cut: observations.cut), and the timeline and the
11
+ // settle the last frame cut: observations.cut; what the spec asked against what it got, per skill,
12
+ // contact and goal: qa/asked.js observeAsked), and the timeline and the
11
13
  // requested probes read the result.
12
- // liftRequest first takes what the agent obviously meant: spec.probes are request probes, and a
14
+ // prepareSpec (correct.js) first corrects slips with one reading and fits walkTo strides, both kept in the
15
+ // stored spec; liftRequest takes what the agent obviously meant: spec.probes are request probes, and a
13
16
  // bare goals[].moved id is object:<id>; relaxSpec (lenient.js) fixes mechanical slips in the expanded
14
17
  // spec before validation and the answer lists each fix in notes.
15
18
  import { compileMotion } from "./compiler/compile.js";
@@ -28,8 +31,12 @@ import { heldReadings, HELD_UNITS } from "./qa/key-checks.js";
28
31
  import { observeNear, NEAR_UNITS } from "./qa/near.js";
29
32
  import { observeWrist, WRIST_UNITS } from "./qa/wrist-obs.js";
30
33
  import { observeLoad, LOAD_UNITS } from "./load.js";
34
+ import { observeLife, LIFE_UNITS } from "./qa/motion-life.js";
31
35
  import { planWalks, applyWalkPlans } from "./walk-plan.js";
32
36
  import { relaxSpec } from "./lenient.js";
37
+ import { observeAsked } from "./qa/asked.js";
38
+ import { prepareSpec } from "./correct.js";
39
+ import { compareQa, noWorse } from "./qa/verdict.js";
33
40
 
34
41
  const ms = t => Math.round(performance.now() - t);
35
42
 
@@ -41,6 +48,21 @@ function warpChecks(spec, map) {
41
48
  return { ...spec, checks: c };
42
49
  }
43
50
 
51
+ /**
52
+ * fix.retime is kept only when QA is no worse with it (qa/verdict.js noWorse: no more problems, at as many no higher
53
+ * summed severity, no goal lost); else the take compiles without it and fixed.retime says what it did (a session went from 30
54
+ * problems to 1015 after a retime). -> { out, measured, qa }
55
+ */
56
+ function keptRetime(out, expanded, compile, qaOf) {
57
+ const qa = qaOf(out), measured = out.timeMap ? warpChecks(expanded, out.timeMap) : expanded;
58
+ if (!out.fixed?.retime) return { out, measured, qa };
59
+ const plain = compile({ ...expanded, fix: { ...expanded.fix, retime: false } }), base = qaOf(plain);
60
+ if (noWorse(qa, base)) return { out, measured, qa };
61
+ const v = compareQa(qa, base);
62
+ plain.fixed = { ...plain.fixed, retime: { ...out.fixed.retime, kept: false, how: `${out.fixed.retime.how}; not kept: QA was worse with it (summed severity ${v.sev[0]} -> ${v.sev[1]}, problems ${v.problems[0]} -> ${v.problems[1]}), so the take is compiled without it` } };
63
+ return { out: plain, measured: expanded, qa: base };
64
+ }
65
+
44
66
  /**
45
67
  * liftRequest(spec, probes) -> { spec, probes }. spec.probes (sent inside the spec, or added by
46
68
  * a patch) join the request's probes after them and leave the spec, so they are never stored and
@@ -63,14 +85,27 @@ export function liftRequest(spec, probes) {
63
85
  * (with spec.probes, liftRequest).
64
86
  */
65
87
  export function compileSpec(sent, { sk, scene }, opts = {}) {
66
- const { spec, probes: asked } = liftRequest(sent, opts.probes);
67
- const t0 = performance.now(), ctx = { sk, scene, lenient: opts.lenient === true }, notes = [];
88
+ const notes = [], refusals = [];
89
+ try {
90
+ return compilePrepared(opts.lenient ? sent : prepareSpec(sent, { sk, scene }, notes, refusals), { sk, scene }, opts, notes, refusals);
91
+ } catch (error) {
92
+ // a walk prepareSpec refused is listed with whatever refused the spec first (validateSpec lists it with its own)
93
+ if (refusals.length && /^MOTION_SPEC_INVALID/.test(error.message) && !error.message.includes(refusals[0])) error.message = error.message.replace(/^MOTION_SPEC_INVALID: /, `MOTION_SPEC_INVALID: ${refusals.join("\n")}\n`);
94
+ // a refusal says what was corrected first, so the paths it names (a moved generator: use[i]) read right
95
+ if (notes.length && /^MOTION_SPEC_INVALID/.test(error.message)) error.message += `\n(adjusted before these checks: ${notes.join("; ")})`;
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ function compilePrepared(prepared, { sk, scene }, opts, notes, refusals) {
101
+ const { spec, probes: asked } = liftRequest(prepared, opts.probes);
102
+ const t0 = performance.now(), ctx = { sk, scene, lenient: opts.lenient === true, problems: refusals };
68
103
  const checks = opts.checks && nameChecks({ checks: opts.checks }).checks;
69
104
  let plans = [];
70
105
  const expand = s => {
71
106
  const { spec: exp, used } = expandUse(nameChecks(applyWalkPlans(s, plans)), ctx);
72
107
  // plain JSON objects compile about twice as fast as structuredClone'd ones (V8 shapes)
73
- const full = relaxSpec(JSON.parse(JSON.stringify(checks ? { ...exp, checks } : exp)), scene, notes);
108
+ const full = relaxSpec(JSON.parse(JSON.stringify(checks ? { ...exp, checks } : exp)), scene, notes, sk);
74
109
  validateSpec(full, ctx);
75
110
  return { full, used };
76
111
  };
@@ -83,16 +118,16 @@ export function compileSpec(sent, { sk, scene }, opts = {}) {
83
118
  if (spec?.frames === "auto") markFramesAuto(resolved);
84
119
  const t1 = performance.now();
85
120
  const { full: expanded, used } = expand(resolved);
86
- const out = compileMotion({ fps: 24, ...expanded }, ctx);
121
+ const { out, measured, qa } = keptRetime(compileMotion({ fps: 24, ...expanded }, ctx), expanded, s => compileMotion({ fps: 24, ...s }, ctx), o => runMotionQa(sk, o, o.timeMap ? warpChecks(expanded, o.timeMap) : expanded, { userSpec: resolved }));
87
122
  const compileMs = ms(t1), t2 = performance.now();
88
- const measured = out.timeMap ? warpChecks(expanded, out.timeMap) : expanded;
89
- const qa = runMotionQa(sk, out, measured, { userSpec: resolved }), observations = observeMotion(sk, out, measured.checks), held = heldReadings(out);
123
+ const observations = observeMotion(sk, out, measured.checks), held = heldReadings(out);
90
124
  if (held) Object.assign(observations, { units: `${observations.units}; ${HELD_UNITS}`, held });
91
125
  const near = observeNear(sk, out), wrist = observeWrist(sk, out);
92
126
  if (near) Object.assign(observations, { units: `${observations.units}; ${NEAR_UNITS}`, near });
93
127
  if (wrist) Object.assign(observations, { units: `${observations.units}; ${WRIST_UNITS}`, wrist });
94
128
  const load = observeLoad(sk, out);
95
129
  if (load) Object.assign(observations, { units: `${observations.units}; ${LOAD_UNITS}`, load });
130
+ Object.assign(observations, { units: `${observations.units}; ${LIFE_UNITS}`, life: observeLife(sk, out, resolved?.keys) });
96
131
  // a skill that paced its beats to fit a short span says so where every answer keeps it (C7 tier 1)
97
132
  const paced = used.filter(u => u.paced).map(u => `${u.skill} paced ${u.paced.toFixed(2)} to fit f${u.f0}..f${u.end}`);
98
133
  if (paced.length) observations.paced = paced;
@@ -101,6 +136,8 @@ export function compileSpec(sent, { sk, scene }, opts = {}) {
101
136
  const placed = used.filter(u => u.placed).map(u => `${u.skill} f${u.f0}: ${u.placed.why}: at [${u.placed.at.join(", ")}], heading ${u.placed.heading}`);
102
137
  if (placed.length) observations.placed = placed;
103
138
  if (plans.length) observations.planned = plans.map(p => p.note);
139
+ const got = observeAsked(out, { used, use: resolved?.use, checks: measured.checks, goals: measured.goals, results: qa.goals });
140
+ if (got) observations.asked = got;
104
141
  const timeline = timelineOf(out, { used, spec: expanded }), probes = asked && runProbes(out, asked, { spec: expanded, sk, scene, notes });
105
142
  return { out, qa, observations, spec: resolved, expanded, used, fixed: out.fixed, search, timeline, ...(probes ? { probes } : {}), ...(notes.length ? { notes } : {}),
106
143
  ms: { compile: compileMs, qa: ms(t2), total: ms(t0) } };
@@ -0,0 +1,96 @@
1
+ // QA ARM warnings (lane arm): an elbow that reads awkward while the hand works, though nothing collides or breaks.
2
+ // The agent saw these only in renders and re-keyed them after QA had passed (17% of the compiles in the hundred
3
+ // costliest motion turns: elbows too high, wrapped over the head, out to the side). A working arm (on its IK, or a
4
+ // hand a keyed carry leads, compiler/hold.js) is read on each frame in the chest's frame, the hand not overhead (the
5
+ // wrist below the top of the head plus OVERHEAD: a lift over the head needs the elbow up; a hand that tips a cup at the
6
+ // lips from above sits by the top of the head and is read):
7
+ // over the head the elbow above the head's centre: the upper arm wraps over the head
8
+ // high the upper arm more than HIGH deg from hanging (90 level): the elbow over the shoulder
9
+ // wing the upper arm more than WING deg out to the side in the chest's frontal plane with the hand
10
+ // within NEAR m of the trunk's axis: the elbow flared out while the hand works by the body
11
+ // Runs of at least MIN frames on a side (gaps of GAP at most) make one line, worst first, at most LINES of them in
12
+ // qa.warnings. They are warnings, not QA groups: they never fail a take (qa.ok), repair does not chase them, and the
13
+ // output budget keeps them after every group (report.js). Each says the frames, the side and the spec change that
14
+ // fixes it: on a keyed carry the object's keys there (lean FACE_LEAN, what a key at the lips takes by default,
15
+ // ../face-lean.js; with that, the pos lower and nearer the chest), on an IK arm the elbow pole (elbow_<side>, body
16
+ // frame: x out to the character's left, -y down, z forward).
17
+ import { sub, scale, dot, len, clamp } from "../compiler/math.js";
18
+ import { carried } from "../compiler/limbs.js";
19
+ import { bonePoint } from "../compiler/skeleton.js";
20
+ import { keyedEntries } from "../object-keys.js";
21
+ import { FACE_LEAN } from "../face-lean.js";
22
+
23
+ const HIGH = 105, WING = 75, NEAR = 0.3, OVERHEAD = 0.1, MIN = 4, GAP = 6, LINES = 2, WORKS = 0.5, DEG = 180 / Math.PI;
24
+ const SIDES = { l: "Left", r: "Right" }, ORDER = { "over the head": 0, high: 1, wing: 2 };
25
+
26
+ /** What the arm of side k reads like on solved frame { s, diag, c }: { kind, deg, held } or null (not working, or natural). */
27
+ export function armPose(sk, { s, diag, c }, k) {
28
+ const held = (diag[`hold_${k}`]?.w ?? 0) > WORKS && !diag[`hold_${k}`].second;
29
+ if (!held && !((c[`armw_${k}`] ?? 0) > WORKS)) return null;
30
+ const I = sk.index, S = s.P[I[`${SIDES[k]}Arm`]], E = s.P[I[`${SIDES[k]}ForeArm`]], W = s.P[I[`${SIDES[k]}Hand`]], chest = I.Spine2 ?? I.Hips;
31
+ const up = carried(sk, s, chest, [0, 1, 0]), out = scale(carried(sk, s, chest, [1, 0, 0]), k === "l" ? 1 : -1);
32
+ const head = bonePoint(s, I.Head, sk.landmarks.headCentre);
33
+ if (dot(sub(W, head), up) > sk.landmarks.headRadii[1] + OVERHEAD) return null;
34
+ const v = sub(E, S), el = Math.acos(clamp(-dot(v, up) / len(v), -1, 1)) * DEG, flare = Math.atan2(dot(v, out), -dot(v, up)) * DEG;
35
+ if (dot(sub(E, head), up) > 0) return { kind: "over the head", deg: el, held };
36
+ if (el > HIGH) return { kind: "high", deg: el, held };
37
+ const axis = sub(W, s.P[I.Spine]), across = len(sub(axis, scale(up, dot(axis, up))));
38
+ if (flare > WING && across < NEAR) return { kind: "wing", deg: flare, held };
39
+ return null;
40
+ }
41
+
42
+ /** The keyed carry hand k holds and its keys within timeline frames f0..f1, else the one nearest f: { id, picks: [[index, key]] } or null. */
43
+ function carryAt(out, k, f0, f1, f) {
44
+ const spec = out.ctx.spec, map = t => (out.timeMap ? out.timeMap(t) : t);
45
+ for (const [id, o] of keyedEntries(spec)) {
46
+ if (spec.props?.[o.prop]?.hand !== k) continue;
47
+ const keys = (out.ctx.hold?.entry?.(id) ?? o).keys, within = keys.map((key, i) => [i, key]).filter(([, key]) => map(key.f) >= f0 && map(key.f) <= f1);
48
+ const near = keys.reduce((b, key, j) => (Math.abs(map(key.f) - f) < Math.abs(map(keys[b].f) - f) ? j : b), 0);
49
+ return { id, picks: within.length ? within : [[near, keys[near]]] };
50
+ }
51
+ return null;
52
+ }
53
+
54
+ const r2 = v => Math.round(v * 100) / 100;
55
+ const listed = (id, picks) => picks.map(([i, key]) => `objects.${id}.keys[${i}] (f${key.f})`).join(", ");
56
+ /** The spec change that fixes run g (see the header). */
57
+ function fixFor(sk, out, g) {
58
+ const k = g.k, sx = k === "l" ? 1 : -1, c = g.held && carryAt(out, k, g.first, g.last, g.worst);
59
+ if (c) {
60
+ const lean = c.picks.filter(([, key]) => key.aim && !(key.lean >= FACE_LEAN));
61
+ if (lean.length) return `give ${listed(c.id, lean)} lean ${FACE_LEAN} (tips the aim to the hand's side, the elbow stays down)`;
62
+ // lower, and nearer the chest across the ground, read on the frame of the run's worst key
63
+ const fr = out.frames[Math.max(0, Math.min(out.frames.length - 1, Math.round(g.worst - (out.f0 ?? 0))))], P = c.picks[0][1].pos;
64
+ const to = sub(fr.s.P[sk.index.Spine2 ?? sk.index.Hips], P), h = Math.hypot(to[0], to[2]) || 1;
65
+ return `move ${listed(c.id, c.picks)} pos by [${r2((0.05 * to[0]) / h)}, -0.08, ${r2((0.05 * to[2]) / h)}] (lower, nearer the chest)`;
66
+ }
67
+ // the pole in the body frame: down, a little out, and for a hand by the face in front of the chest
68
+ const pole = g.kind === "wing" ? [r2(0.1 * sx), -1, 0.2] : [r2(0.3 * sx), -1, g.mouth ? 0.35 : -0.2];
69
+ return `key elbow_${k} [${pole.join(", ")}] from f${g.first} to f${g.last} (down${g.kind === "wing" ? " and in" : " and a little out"}${g.mouth ? ", in front of the chest" : ""})`;
70
+ }
71
+
72
+ const WORDS = { "over the head": "upper arm wraps over the head", high: "elbow over the shoulder", wing: "elbow flared out to the side" };
73
+ /**
74
+ * ARM warning lines of a compile (out.frames solved; frames absolute with out.f0): the worst LINES runs, worst
75
+ * first. A run is a side's awkward frames with gaps of at most GAP, all held or all on the IK, named by its worst
76
+ * kind (ORDER) and that kind's worst frame. [] when every working arm reads natural.
77
+ */
78
+ export function armWarnings(sk, out) {
79
+ const runs = [], f0 = out.f0 ?? 0, mouthAt = s => bonePoint(s, sk.index.Head, sk.landmarks.mouth);
80
+ for (const k of ["l", "r"]) {
81
+ let run = null;
82
+ const close = () => { if (run && run.n >= MIN) runs.push(run); run = null; };
83
+ out.frames.forEach((fr, i) => {
84
+ const a = armPose(sk, fr, k), f = i + f0;
85
+ if (run && (f - run.last > GAP || (a && a.held !== run.held))) close();
86
+ if (!a) return;
87
+ const mouth = len(sub(fr.s.P[sk.index[`${SIDES[k]}Hand`]], mouthAt(fr.s))) < 0.25;
88
+ run ??= { k, kind: a.kind, held: a.held, first: f, worst: f, deg: a.deg, n: 0, mouth: false };
89
+ run.last = f; run.n++; run.mouth ||= mouth;
90
+ if (ORDER[a.kind] < ORDER[run.kind] || (a.kind === run.kind && a.deg > run.deg)) Object.assign(run, { kind: a.kind, deg: a.deg, worst: f });
91
+ });
92
+ close();
93
+ }
94
+ runs.sort((a, b) => ORDER[a.kind] - ORDER[b.kind] || b.n - a.n);
95
+ return runs.slice(0, LINES).map(g => `ARM arm_${g.k} ${WORDS[g.kind]} f${g.first}-f${g.last} (worst f${g.worst}, upper arm ${Math.round(g.deg)}deg ${g.kind === "wing" ? "out" : "up"}): ${fixFor(sk, out, g)}`);
96
+ }