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.
Files changed (154) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/bin/agent/agent-routes.mjs +12 -11
  3. package/bin/agent/main-agent.mjs +21 -7
  4. package/bin/agent/main-prompt.mjs +7 -6
  5. package/bin/agent/plan-tool.mjs +114 -0
  6. package/bin/agent/script-tool.mjs +4 -1
  7. package/bin/agent/sculpt-sketch.mjs +190 -0
  8. package/bin/agent/session-store.mjs +2 -1
  9. package/bin/agent/sketch-code.mjs +151 -0
  10. package/bin/agent/sketch-sandbox.mjs +102 -0
  11. package/bin/agent/studio-prompt.mjs +1 -1
  12. package/bin/agent/studio-rebase.mjs +5 -4
  13. package/bin/agent/studio-task-routes.mjs +1 -1
  14. package/bin/agent/studio-tasks.mjs +116 -25
  15. package/bin/agent/studio-tools.mjs +3 -2
  16. package/bin/agent/turn-frames.mjs +4 -0
  17. package/bin/cozyclay.mjs +4 -2
  18. package/bin/live-endpoint.mjs +2 -0
  19. package/dist/app/index.html +6 -6
  20. package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
  21. package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
  22. package/dist/assets/app-C4QTcDcb.js +5144 -0
  23. package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
  24. package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
  25. package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
  26. package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
  27. package/dist/assets/task-board-BqReHzOi.js +15 -0
  28. package/dist/assets/workflow-C87p6E1E.js +179 -0
  29. package/dist/cozyclay-package.json +1 -1
  30. package/dist/index.html +1 -1
  31. package/dist/sitemap.xml +1 -1
  32. package/dist/workflow/index.html +4 -4
  33. package/mcp/README.md +1 -1
  34. package/mcp/live-hub.mjs +5 -3
  35. package/mcp/tool-handlers.mjs +7 -7
  36. package/package.json +3 -1
  37. package/src/App.jsx +44 -66
  38. package/src/bus-task-scopes.js +59 -11
  39. package/src/bus-task-targets.js +35 -9
  40. package/src/bus-tasks.js +23 -10
  41. package/src/command-bus.js +7 -4
  42. package/src/commands/ai.js +1 -1
  43. package/src/commands/elements.js +4 -3
  44. package/src/commands/export.js +1 -1
  45. package/src/commands/motion3.js +27 -7
  46. package/src/commands/objects.js +1 -1
  47. package/src/export-workspace.js +244 -0
  48. package/src/keyframe-pack.js +5 -1
  49. package/src/live-control.js +14 -7
  50. package/src/material-look.js +189 -0
  51. package/src/material-presets.js +78 -0
  52. package/src/material-textures.js +157 -0
  53. package/src/motion3/compile-job.js +15 -3
  54. package/src/motion3/compiler/channels.js +74 -24
  55. package/src/motion3/compiler/compile.js +2 -1
  56. package/src/motion3/compiler/idle.js +2 -0
  57. package/src/motion3/compiler/overlap.js +180 -0
  58. package/src/motion3/compiler/pose.js +6 -4
  59. package/src/motion3/compiler/validate-common.js +1 -0
  60. package/src/motion3/compiler/validate-parts.js +5 -1
  61. package/src/motion3/compiler/validate.js +30 -10
  62. package/src/motion3/correct.js +236 -0
  63. package/src/motion3/docs.js +8 -6
  64. package/src/motion3/face-lean.js +31 -0
  65. package/src/motion3/fix/index.js +5 -1
  66. package/src/motion3/fix/reach-body.js +172 -0
  67. package/src/motion3/hint-check.js +44 -0
  68. package/src/motion3/json-patch.js +120 -12
  69. package/src/motion3/lenient.js +46 -4
  70. package/src/motion3/object-keys.js +17 -5
  71. package/src/motion3/pipeline.js +46 -9
  72. package/src/motion3/qa/arm-checks.js +96 -0
  73. package/src/motion3/qa/asked.js +113 -0
  74. package/src/motion3/qa/goals.js +37 -8
  75. package/src/motion3/qa/hints-goal.js +6 -5
  76. package/src/motion3/qa/index.js +5 -2
  77. package/src/motion3/qa/key-checks.js +4 -3
  78. package/src/motion3/qa/motion-life.js +243 -0
  79. package/src/motion3/qa/spec-checks.js +15 -9
  80. package/src/motion3/qa/verdict.js +50 -0
  81. package/src/motion3/refs.js +38 -14
  82. package/src/motion3/repair-watch.js +132 -0
  83. package/src/motion3/repair.js +52 -19
  84. package/src/motion3/report.js +50 -9
  85. package/src/motion3/schema/topics.js +6 -4
  86. package/src/motion3/schema/types.js +1 -1
  87. package/src/motion3/skills/basic.js +15 -8
  88. package/src/motion3/skills/index.js +52 -18
  89. package/src/motion3/skills/info.js +1 -1
  90. package/src/motion3/skills/stance.js +6 -0
  91. package/src/motion3/skills/walk-fit.js +170 -0
  92. package/src/object-look.js +9 -5
  93. package/src/panels/PoseImagePanel.jsx +3 -2
  94. package/src/panels/SculptPanel.jsx +2 -2
  95. package/src/primitive-look.js +38 -0
  96. package/src/props.jsx +20 -28
  97. package/src/render-passes.js +7 -1
  98. package/src/result-modal.jsx +3 -3
  99. package/src/scene-objects.js +2 -2
  100. package/src/sculpt-colours.js +65 -0
  101. package/src/sculpt-compare.js +13 -50
  102. package/src/sculpt-csg.js +112 -0
  103. package/src/sculpt-curve.js +123 -0
  104. package/src/sculpt-cutters.js +43 -0
  105. package/src/sculpt-detail.js +207 -0
  106. package/src/sculpt-geometry-loft-convex.js +100 -0
  107. package/src/sculpt-geometry-shapes.js +250 -0
  108. package/src/sculpt-geometry.js +33 -15
  109. package/src/sculpt-loft-rings.js +225 -0
  110. package/src/sculpt-noise.js +65 -0
  111. package/src/sculpt-part-fields.js +27 -0
  112. package/src/sculpt-recipe-read.js +65 -0
  113. package/src/sculpt-recipe.js +33 -75
  114. package/src/sculpt-run.js +1 -1
  115. package/src/sculpt-scatter-items.js +183 -0
  116. package/src/sculpt-scatter-shape.js +57 -0
  117. package/src/sculpt-scatter.js +124 -0
  118. package/src/sculpt-shapes-curved.js +211 -0
  119. package/src/sculpt-shapes-loft-convex.js +218 -0
  120. package/src/sculpt-shapes.js +138 -0
  121. package/src/sculpt-sketch-state.js +76 -0
  122. package/src/sculpt-sketch.jsx +124 -0
  123. package/src/sculpt-surface-fields.js +65 -0
  124. package/src/shell/ExportFolderPreference.jsx +103 -0
  125. package/src/shell/PreferencesDialog.jsx +3 -166
  126. package/src/shell/StatusBar.jsx +1 -1
  127. package/src/shell/preference-controls.jsx +114 -0
  128. package/src/shell/preference-pages.jsx +72 -0
  129. package/src/shell/preferences.css +46 -0
  130. package/src/shot-prompt.js +29 -9
  131. package/src/studio-actions.js +2 -2
  132. package/src/studio-agent-motion.js +19 -9
  133. package/src/studio-agent-protocol.js +1 -1
  134. package/src/studio-app-binding.js +3 -2
  135. package/src/studio-elements.js +5 -1
  136. package/src/workflow/AgentPanel.jsx +6 -6
  137. package/src/workflow/AgentPlanCard.jsx +41 -0
  138. package/src/workflow/CozySceneNode.jsx +1 -1
  139. package/src/workflow/WorkflowBuilder.jsx +2 -2
  140. package/src/workflow/agent-client.js +14 -11
  141. package/src/workflow/agent-plan.css +93 -0
  142. package/src/workflow/agent-plan.js +59 -0
  143. package/src/workflow/keyframe-pack-request.js +3 -10
  144. package/src/workflow/task-board.css +1 -1
  145. package/src/workflow/task-board.js +7 -5
  146. package/tools/loc-baseline.json +1 -2
  147. package/tools/run-tests.mjs +8 -8
  148. package/dist/assets/app-B2BpfCl2.js +0 -5088
  149. package/dist/assets/compile-worker-D0pKioa0.js +0 -5
  150. package/dist/assets/motion3-compile-B887n77J.js +0 -1
  151. package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
  152. package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
  153. package/dist/assets/task-board-BKaM7d-9.js +0 -14
  154. package/dist/assets/workflow-B8Dhzexj.js +0 -179
@@ -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
+ }
@@ -0,0 +1,113 @@
1
+ // Measured, not judged (output.observations.asked): what the spec asked against what the take did, the
2
+ // quantities agents probed or re-compiled for right after a compile (session study: the root where a walk
3
+ // ended, the mouth and the cup top on the drink frame, when the fingers met the cup), one line each:
4
+ // use[i] <skill>[ <side> <anchor>] f<a>-<b>: at [x, z] yaw <deg>[ (asked <deg>)][; to [x, z]: <m> m short|past[, <m> m aside][, ...]]
5
+ // at = the feet's midpoint at the skill's last frame (what walkTo.to and standIdle.at place), yaw = the
6
+ // body's heading there; a walkTo whose steps are given only aims at to (the steps set the distance)
7
+ // <contact name> f<a>-<b>: gap <cm> f<f> .. <cm> f<f> (tol <cm>) checks.contacts (reachGrip adds "fingers
8
+ // on <anchor>"): the frames its when holds, the closest and the farthest gap; "never on" when it never holds
9
+ // goal <name> f<f>: <ref> [x, y, z], <ref> [x, y, z][; best <m> f<f>] the goal's points on the frame its
10
+ // result names (near and above; moved and still when they fail); best: the closest frame of a near goal
11
+ // held on every frame
12
+ // An object ref is a point of the AABB of its boxes (refs.js), so a line names which: (box top) is the top-face
13
+ // centre, not a rim or an opening; (box base) the bottom-face centre; (box centre) the centre; a bare object in
14
+ // a near goal is its surface. Frames are absolute and follow a retime (out.timeMap); metres to 3 decimals.
15
+ import { refPoint, refDistance } from "../refs.js";
16
+ import { active, contactGap } from "./spec-checks.js";
17
+ import { goalKinds, goalName, movedRef } from "./goals.js";
18
+ import { m3 } from "./fmt.js";
19
+
20
+ const MAX_SKILLS = 8, MAX_CONTACTS = 6, MAX_GOALS = 8, OFF = 0.05, YAW_OFF = 5;
21
+
22
+ const v3 = p => `[${p.map(m3).join(", ")}]`;
23
+ const cm = v => `${Math.round(v * 1000) / 10}`;
24
+ const wrap = d => ((d % 360) + 540) % 360 - 180;
25
+ const meaning = { top: " (box top)", base: " (box base)", center: " (box centre)", rim: " (opening)" };
26
+
27
+ /** A ref with what an object point is: "object:cup.top (box top)". */
28
+ function named(ref, bare) {
29
+ const m = /^object:.+?(?:\.(center|top|base|rim))?$/s.exec(ref);
30
+ if (!m) return ref;
31
+ return `${ref}${m[1] ? meaning[m[1]] : bare ? " (surface)" : meaning.center}`;
32
+ }
33
+
34
+ /** The feet's midpoint [x, z] and the heading at absolute frame f, or null outside the compiled window. */
35
+ function standAt(out, f) {
36
+ const fr = out.frames[f - (out.f0 ?? 0)];
37
+ if (!fr) return null;
38
+ const { P } = fr.s, I = out.ctx.sk.index, l = P[I.LeftFoot], r = P[I.RightFoot];
39
+ return { at: [(l[0] + r[0]) / 2, (l[2] + r[2]) / 2], yaw: fr.c?.yaw ?? 0 };
40
+ }
41
+
42
+ function skillLine(out, u, i, use, map) {
43
+ const last = out.frameCount - 1, clamp = f => Math.max(0, Math.min(last, Math.round(map(f))));
44
+ const a = clamp(u.f0), b = clamp(u.end ?? u.f0), end = standAt(out, b), args = use?.args ?? {};
45
+ if (!end) return null;
46
+ const side = args.side ? ` ${args.side}` : "", anchor = args.anchor ? ` ${args.anchor}` : "";
47
+ let line = `use[${i}] ${u.skill}${side}${anchor} f${a}-${b}: at ${v3(end.at)} yaw ${Math.round(wrap(end.yaw))}`;
48
+ const heading = Array.isArray(args.heading) ? args.heading[args.heading.length - 1] : args.heading;
49
+ if (typeof heading === "number" && Math.abs(wrap(end.yaw - heading)) > YAW_OFF) line += ` (asked ${heading})`;
50
+ const goal = Array.isArray(args.to) ? [args.to[0], args.to[args.to.length - 1]] : Array.isArray(args.at) && !u.placed ? args.at : null;
51
+ if (goal) {
52
+ const d = [end.at[0] - goal[0], end.at[1] - goal[1]], off = Math.hypot(...d);
53
+ if (off > OFF) {
54
+ const start = args.to && standAt(out, a), dir = start && Math.hypot(goal[0] - start.at[0], goal[1] - start.at[1]) > 1e-6
55
+ ? [goal[0] - start.at[0], goal[1] - start.at[1]].map((x, _, v) => x / Math.hypot(...v)) : null;
56
+ const along = dir ? d[0] * dir[0] + d[1] * dir[1] : null, aside = dir ? Math.abs(d[0] * dir[1] - d[1] * dir[0]) : null;
57
+ const how = dir ? `${m3(Math.abs(along))} m ${along < 0 ? "short" : "past"}${aside > OFF ? `, ${m3(aside)} m aside` : ""}` : `${m3(off)} m off`;
58
+ line += `; ${args.to ? "to" : "at"} ${v3(goal)}: ${how}${args.to && args.steps !== undefined ? ", args.steps set the distance (to only aims)" : ""}`;
59
+ }
60
+ }
61
+ return line;
62
+ }
63
+
64
+ function contactLine(out, k) {
65
+ const f0 = out.f0 ?? 0;
66
+ let lo = null, hi = null, first = null, lastOn = null;
67
+ for (let f = 0; f < out.frames.length; f++) {
68
+ if (!active(out, f, k.when)) continue;
69
+ first ??= f + f0; lastOn = f + f0;
70
+ const e = contactGap(out, f, k);
71
+ if (e === null) continue;
72
+ if (!lo || e < lo[0]) lo = [e, f + f0];
73
+ if (!hi || e > hi[0]) hi = [e, f + f0];
74
+ }
75
+ if (first === null) return `${k.name}: never on${typeof k.when === "string" ? ` (${k.when} never > 0.99)` : ""}`;
76
+ return `${k.name} f${first}-${lastOn}: gap ${lo ? `${cm(lo[0])} cm f${lo[1]} .. ${cm(hi[0])} cm f${hi[1]}` : "unknown"} (tol ${cm(k.tol ?? 0.01)})`;
77
+ }
78
+
79
+ function goalLine(out, g, i, result) {
80
+ const kind = goalKinds(g)[0];
81
+ if (!result || result.at === null || !(kind === "near" || kind === "above" || (!result.ok && (kind === "moved" || kind === "still")))) return null;
82
+ const refs = kind === "near" || kind === "above" ? g[kind] : [movedRef(g[kind].includes(":") ? g[kind] : `object:${g[kind]}`)];
83
+ const pts = refs.map(r => [r, refPoint(out, result.at, r)]);
84
+ if (pts.some(([, p]) => !p)) return null;
85
+ let line = `goal ${goalName(g, i)} f${result.at}: ${pts.map(([r, p]) => `${named(r, kind === "near")} ${v3(p)}`).join(", ")}`;
86
+ if (kind === "near" && Array.isArray(g.at) && g.at[0] !== g.at[1] && (g.mode ?? "all") === "all") {
87
+ const f0 = out.f0 ?? 0;
88
+ let best = null;
89
+ for (let f = Math.max(g.at[0], f0); f <= Math.min(g.at[1], f0 + out.frames.length - 1); f++) {
90
+ const d = refDistance(out, f, g.near[0], g.near[1]);
91
+ if (d !== null && (!best || d < best[0])) best = [d, f];
92
+ }
93
+ if (best && best[1] !== result.at) line += `; best ${m3(best[0])} f${best[1]}`;
94
+ }
95
+ return line;
96
+ }
97
+
98
+ /**
99
+ * observeAsked(out, { used, use, checks, goals, results }) -> string[] | null
100
+ * @param {object} out compileMotion result
101
+ * @param {{ used: object[], use?: object[], checks?: object, goals?: object[], results?: object[] }} ctx used = the skills
102
+ * expandUse ran, use = the spec's own use entries (their args are what was asked), checks = the measured checks,
103
+ * goals = the expanded spec's goals and results = qa.goals (same order, by name)
104
+ */
105
+ export function observeAsked(out, { used = [], use = [], checks = {}, goals = [], results = [] }) {
106
+ const map = out.timeMap ?? (f => f), byName = new Map(results.map(r => [r.name, r]));
107
+ const lines = [
108
+ ...used.slice(0, MAX_SKILLS).map((u, i) => skillLine(out, u, i, use[i], map)),
109
+ ...(checks.contacts ?? []).slice(0, MAX_CONTACTS).map(k => contactLine(out, k)),
110
+ ...goals.map((g, i) => goalLine(out, g, i, byName.get(goalName(g, i)))).filter(Boolean).slice(0, MAX_GOALS),
111
+ ].filter(Boolean);
112
+ return lines.length ? lines : null;
113
+ }
@@ -1,6 +1,7 @@
1
1
  // Goals (C8): what the motion must achieve, measured on the compiled frames. A goal names one
2
2
  // predicate and the frames it must hold on (at: an absolute frame or [f0, f1]):
3
- // near: [a, b] refDistance(a, b) <= tol (default 0.05 m)
3
+ // near: [a, b] refDistance(a, b) <= tol (default 0.05 m); a round body's .top and the mouth measure its rim
4
+ // (nearRefs), and a failing near goal on a .top says which point it read
4
5
  // above: [a, b] a.y - b.y >= by (default 0 m)
5
6
  // moved: ref an object's point (default its base) is at least by (default 0.05 m) from
6
7
  // where the scene put it; any other point ref (prop:, joint:, landmark:, anchor:)
@@ -19,7 +20,7 @@
19
20
  // count and first..last show how much of at fails and the search (search.js) sees progress; "any"
20
21
  // one, at the best frame. Issue frames index out.frames (out.f0 + i); goals[].at and every frame in
21
22
  // a message are absolute.
22
- import { refPoint, refDistance } from "../refs.js";
23
+ import { refPoint, refDistance, openingOf } from "../refs.js";
23
24
  import { partsOf } from "../object-pose.js";
24
25
  import { add, apply, len, sub } from "../compiler/math.js";
25
26
  import { m3 } from "./fmt.js";
@@ -42,13 +43,14 @@ export const goalNeed = (g, kind) => (AT_MOST.has(kind) ? g.tol : g.by) ?? DEFAU
42
43
  const goalMode = (g, kind) => g.mode ?? (kind === "speed" ? "any" : "all");
43
44
 
44
45
  /** moved and still measure the named point of an object ref (its base when none is named), any other ref as it is. */
45
- export const movedRef = ref => (!ref.startsWith("object:") || /\.(center|top|base)$/.test(ref) ? ref : `${ref}.base`);
46
+ export const movedRef = ref => (!ref.startsWith("object:") || /\.(center|top|base|rim)$/.test(ref) ? ref : `${ref}.base`);
46
47
 
47
48
  /** Where the scene put the point of a moved object ref: the same AABB point refs.js answers, from the scene boxes. */
48
49
  function scenePoint(scene, ref) {
49
- const [, id, which] = /^object:(.+)\.(center|top|base)$/s.exec(movedRef(ref)) ?? [];
50
+ const [, id, which] = /^object:(.+)\.(center|top|base|rim)$/s.exec(movedRef(ref)) ?? [];
50
51
  const boxes = partsOf(scene, id).map(p => scene.objects[p]);
51
52
  if (!boxes.length) return null;
53
+ if (which === "rim") return openingOf(boxes).o;
52
54
  const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
53
55
  for (const b of boxes) for (const x of [-1, 1]) for (const y of [0, 2]) for (const z of [-1, 1]) {
54
56
  const p = add(b.pos, apply(b.R, [x * b.half[0], y * b.half[1], z * b.half[2]]));
@@ -74,9 +76,35 @@ function speedAt(out, ref, f) {
74
76
  return q ? len(sub(p, q)) * fps : null;
75
77
  }
76
78
 
79
+ const MOUTH = "landmark:mouth", TOP = /^object:(.+)\.top$/s;
80
+
81
+ /**
82
+ * A near goal of an object's .top and the mouth, on a body whose highest part is round (a cylinder: a cup, a can, a
83
+ * bottle), measures its opening's rim nearest the mouth (refs.js .rim): .top is the top-face centre of its world
84
+ * bounding box, which a tilted cup lifts off the rim it is drunk from (a session read 8 failures in a row off it).
85
+ * -> the refs measured, as given when nothing changes.
86
+ */
87
+ export function nearRefs(scene, g) {
88
+ const [a, b] = g.near, top = [a, b].map(r => TOP.exec(r)?.[1]), i = top[0] && b === MOUTH ? 0 : top[1] && a === MOUTH ? 1 : -1;
89
+ if (i < 0) return g.near;
90
+ const parts = partsOf(scene, top[i]).map(p => scene.objects[p]).filter(p => p?.half && p.pos && p.R);
91
+ const high = parts.length ? parts.reduce((p, q) => (q.pos[1] + 2 * q.half[1] > p.pos[1] + 2 * p.half[1] ? q : p)) : null;
92
+ if (high?.renderer !== "cylinder") return g.near;
93
+ return g.near.map((r, j) => (j === i ? `object:${top[i]}.rim` : r));
94
+ }
95
+
96
+ /** What a near goal's .top measured, said in its message (refs.js): the rim it was read at, or the bounding-box top it is. */
97
+ function topNote(scene, g) {
98
+ const ref = g.near.find(r => TOP.test(r));
99
+ if (!ref) return "";
100
+ const id = TOP.exec(ref)[1];
101
+ return nearRefs(scene, g) !== g.near ? ` (measured at ${id}'s rim nearest the mouth, object:${id}.rim; its .top is its bounding box's top)`
102
+ : ` (${ref} is the top-face centre of its world bounding box; object:${id}.rim is its opening, which tilts with it)`;
103
+ }
104
+
77
105
  /** The measured value of goal g at absolute frame f (metres, m/s for speed), or null when a ref is unknown there. */
78
106
  function measure(out, g, kind, f, home) {
79
- if (kind === "near") return refDistance(out, f, g.near[0], g.near[1]);
107
+ if (kind === "near") { const [a, b] = nearRefs(out.ctx.scene, g); return refDistance(out, f, a, b); }
80
108
  if (kind === "above") {
81
109
  const a = refPoint(out, f, g.above[0]), b = refPoint(out, f, g.above[1]);
82
110
  return a && b ? a[1] - b[1] : null;
@@ -139,9 +167,9 @@ function windowOf(X, i) {
139
167
  return { a, b };
140
168
  }
141
169
 
142
- function message(name, kind, row, need, any, from) {
170
+ function message(name, kind, row, need, any, from, note = "") {
143
171
  const where = any ? `f${row.f}, the best of f${any[0]}-f${any[1]}` : `f${row.f}`;
144
- if (kind === "near") return `${name}: ${m3(row.v)} m at ${where} (needs <= ${need})`;
172
+ if (kind === "near") return `${name}: ${m3(row.v)} m at ${where} (needs <= ${need})${note}`;
145
173
  if (kind === "above") return `${name}: ${m3(row.v)} m above at ${where} (needs >= ${need})`;
146
174
  if (kind === "speed") return `${name}: ${m3(row.v)} m/s at ${where} (needs >= ${need})`;
147
175
  if (kind === "still") return `${name}: ${m3(row.v)} m from ${from === undefined ? "its scene pose" : `f${from}`} at ${where} (needs <= ${need})`;
@@ -183,8 +211,9 @@ export function goalChecks(out, goals) {
183
211
  const ok = pick.short <= 0;
184
212
  results.push({ name, kind, ok, value: m3(pick.v), at: pick.f, need });
185
213
  if (ok) return;
214
+ const note = kind === "near" ? topNote(out.ctx.scene, g) : "";
186
215
  for (const r of any ? [pick] : rows.filter(r => r.short > 0)) {
187
- issues.push({ kind: "GOAL", msg: message(name, kind, r, need, any && cut ? [w.a, w.b] : null, from), frame: r.f - f0, value: round4(r.short), limit: 0 });
216
+ issues.push({ kind: "GOAL", msg: message(name, kind, r, need, any && cut ? [w.a, w.b] : null, from, kind === "near" ? note : ""), frame: r.f - f0, value: round4(r.short), limit: 0 });
188
217
  }
189
218
  });
190
219
  return { issues, results };
@@ -14,7 +14,7 @@ import { inHandAt } from "../object-pose.js";
14
14
  import { refPoint } from "../refs.js";
15
15
  import { scale, norm, sub, len } from "../compiler/math.js";
16
16
  import { moveHint } from "./hints-contact.js";
17
- import { goalKinds, goalName, goalNeed, goalWindow, movedHome, movedRef } from "./goals.js";
17
+ import { goalKinds, goalName, goalNeed, goalWindow, movedHome, movedRef, nearRefs } from "./goals.js";
18
18
  import { m3, vec } from "./fmt.js";
19
19
 
20
20
  const MARGIN = 0.003, LEAK = 0.02, BODY_MAX = 0.2;
@@ -79,7 +79,7 @@ function partOf(X, ref, f) {
79
79
  const [, kind, rest] = /^([a-z]+):(.+)$/s.exec(ref) ?? [];
80
80
  if (kind === "prop") return X.spec.props?.[rest.split(".")[0]]?.hand ? `hand_${X.spec.props[rest.split(".")[0]].hand}` : null;
81
81
  if (kind === "object") {
82
- const o = carrierOf(X, rest.replace(/\.(center|top|base)$/, ""), f), k = o && X.spec.props?.[o.prop]?.hand;
82
+ const o = carrierOf(X, rest.replace(/\.(center|top|base|rim)$/, ""), f), k = o && X.spec.props?.[o.prop]?.hand;
83
83
  return k ? `hand_${k}` : null;
84
84
  }
85
85
  if (kind === "landmark") return "root";
@@ -107,7 +107,8 @@ function plan(X, goal, kind, f, amount, from) {
107
107
  const away = p && home && len(sub(p, home)) > 1e-3 ? norm(sub(p, home)) : [0, 1, 0];
108
108
  return part ? [part, scale(away, kind === "still" ? -amount : amount)] : null;
109
109
  }
110
- const [a, b] = goal[kind], pa = partOf(X, a, f), pb = partOf(X, b, f);
110
+ // a near goal measures what goals.js nearRefs reads (a cup's rim at the mouth)
111
+ const [a, b] = kind === "near" ? nearRefs(X.out.ctx.scene, goal) : goal[kind], pa = partOf(X, a, f), pb = partOf(X, b, f);
111
112
  const rank = p => (p?.startsWith("hand_") ? 2 : p ? 1 : 0), moveA = rank(pa) >= rank(pb);
112
113
  const part = moveA ? pa : pb, sign = moveA ? 1 : -1;
113
114
  if (!part) return null;
@@ -136,13 +137,13 @@ export const goalHints = {
136
137
  const move = plan(X, goal, kind, f, amount, w.a);
137
138
  if (!move) {
138
139
  if (kind === "still" && goal.still.startsWith("object:")) {
139
- const id = goal.still.slice(7).replace(/\.(center|top|base)$/, "");
140
+ const id = goal.still.slice(7).replace(/\.(center|top|base|rim)$/, "");
140
141
  return { cause: `objects.${id}`, hint: `${id} moves at f${f}, where it should stay: hold its hinge channel or keys until then, or start them later${why}` };
141
142
  }
142
143
  if (kind === "still") return { cause: `goal ${name}`, hint: `${goal.still} moves at f${f} and nothing that follows the body moves it; name a joint, prop point or held object${why}` };
143
144
  if (kind === "moved" && !goal.moved.startsWith("object:")) return { cause: `goal ${name}`, hint: `${goal.moved} does not move with the body at f${f}; name a joint, prop point or held object` };
144
145
  if (kind === "moved") {
145
- const id = goal.moved.slice(7).replace(/\.(center|top|base)$/, "");
146
+ const id = goal.moved.slice(7).replace(/\.(center|top|base|rim)$/, "");
146
147
  return { cause: `objects.${id}`, hint: `${id} is not in a hand at f${f}, so nothing moves it: carry it (objects.${id} = { prop, grab }) or hinge it` };
147
148
  }
148
149
  return { cause: `goal ${name}`, hint: `neither ${goal[kind][0]} nor ${goal[kind][1]} moves with the body at f${f}; name a joint, prop point or held object` };
@@ -2,7 +2,8 @@
2
2
  // moves (object-checks.js), the other people in the scene (people-checks.js, PERSON), its goals (goals.js) and the style checks it opted into by naming
3
3
  // their limit (style.js; otherwise style is measured, not judged: observeMotion). Groups are
4
4
  // ranked by severity.js. total counts frame-level issues (a goal held on "all" frames adds one per failing frame); problems
5
- // counts the groups, which is what a person reads (src/qa-label.js).
5
+ // counts the groups, which is what a person reads (src/qa-label.js). qa.warnings (arm-checks.js) reads an awkward
6
+ // working arm and never fails a take.
6
7
  import { generalChecks, QA_LIMITS } from "./checks.js";
7
8
  import { styleChecks, floatChecks } from "./style.js";
8
9
  import { specChecks } from "./spec-checks.js";
@@ -12,6 +13,7 @@ import { loadChecks } from "../load.js";
12
13
  import { goalChecks } from "./goals.js";
13
14
  import { wristIssues } from "./wrist-checks.js";
14
15
  import { peopleChecks } from "./people-checks.js";
16
+ import { armWarnings } from "./arm-checks.js";
15
17
  import { explainGroups } from "./explain.js";
16
18
  import { LOW_IS_BAD, groupKey, rankGroups } from "./severity.js";
17
19
 
@@ -42,5 +44,6 @@ export function runMotionQa(sk, out, spec, opts = {}) {
42
44
  if (out.f0) for (const i of issues) i.frame += out.f0;
43
45
  const groups = summarizeIssues(issues);
44
46
  if (opts.explain !== false) explainGroups(sk, out, spec, groups, { userSpec: opts.userSpec });
45
- return { ok: issues.length === 0, total: issues.length, problems: groups.length, ...(spec.goals ? { goals: goals.results } : {}), groups };
47
+ const warnings = armWarnings(sk, out);
48
+ return { ok: issues.length === 0, total: issues.length, problems: groups.length, ...(spec.goals ? { goals: goals.results } : {}), groups, ...(warnings.length ? { warnings } : {}) };
46
49
  }
@@ -62,7 +62,7 @@ export function keyHint(X, g) {
62
62
 
63
63
  const r1 = v => Math.round(v * 10) / 10, r2 = v => Math.round(v * 100) / 100;
64
64
  /** observations.held units: what a row of a keyed object says. */
65
- export const HELD_UNITS = "held: per key, deg = the drawn +Y off the asked one (aim, or rot's +Y), cm = the drawn base off pos, got = the drawn +Y (when 1 deg off or more), rot = what an aim key used";
65
+ export const HELD_UNITS = "held: per key, deg = the drawn +Y off the asked one (aim, or rot's +Y), cm = the drawn base off pos, got = the drawn +Y (when 1 deg off or more), rot = what an aim key used, lean = the lean a mouth key took by default (give lean 0 to hold aim exactly)";
66
66
 
67
67
  /**
68
68
  * Every key of every keyed carry as drawn: { <id>: [{ f, deg, cm, got?, rot? }] } in key order
@@ -72,11 +72,12 @@ export function heldReadings(out) {
72
72
  const held = keyedEntries(out.ctx.spec);
73
73
  if (!held.length) return null;
74
74
  return Object.fromEntries(held.map(([id, o]) => {
75
- const curve = curveIn(out, id), keys = [...o.keys].sort((a, b) => a.f - b.f);
75
+ // the keys as the compile holds them: a mouth key the compile leaned says so (object-keys.js faceLeaned)
76
+ const curve = curveIn(out, id), keys = [...(out.ctx.hold?.entry?.(id) ?? o).keys].sort((a, b) => a.f - b.f);
76
77
  return [id, keys.map((k, i) => {
77
78
  const got = rootPoseAt(out, id, Math.round(curve.t[i])), y = [got.R[0][1], got.R[1][1], got.R[2][1]];
78
79
  const want = k.aim ? norm(k.aim) : apply(matFromQuat(curve.q[i]), [0, 1, 0]), deg = r1(angle(want, y));
79
- return { f: k.f, deg, cm: r1(dist(k.pos, got.pos) * 100), ...(deg >= 1 ? { got: y.map(r2) } : {}), ...(k.aim ? { rot: curve.rots[i] } : {}) };
80
+ return { f: k.f, deg, cm: r1(dist(k.pos, got.pos) * 100), ...(deg >= 1 ? { got: y.map(r2) } : {}), ...(k.aim ? { rot: curve.rots[i] } : {}), ...(k.leaned ? { lean: k.lean } : {}) };
80
81
  })];
81
82
  }));
82
83
  }