cozyclay 3.1.6 → 3.2.1

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 (224) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/INSTALL_FOR_AGENTS.md +4 -4
  3. package/README.md +4 -2
  4. package/bin/agent/agent-failure-codes.mjs +34 -8
  5. package/bin/agent/agent-routes.mjs +6 -6
  6. package/bin/agent/agent-runner.mjs +25 -17
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/openai-compatible-provider.mjs +24 -1
  10. package/bin/agent/studio-prompt.mjs +2 -2
  11. package/bin/agent/supervisor-followup.mjs +34 -0
  12. package/bin/agent/supervisor.mjs +6 -6
  13. package/bin/cozyclay.mjs +23 -60
  14. package/bin/star-prompt.mjs +15 -14
  15. package/bin/studio-routes.mjs +6 -3
  16. package/bin/telemetry-state.mjs +5 -7
  17. package/bin/update-check.mjs +61 -7
  18. package/bin/update-install.mjs +16 -3
  19. package/bin/update-restart.mjs +95 -0
  20. package/bin/update-routes.mjs +25 -3
  21. package/dist/app/index.html +13 -7
  22. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  23. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  24. package/dist/assets/{app-CEP7jzXB.css → app-CnDkB5WO.css} +1 -1
  25. package/dist/assets/app-DJt06Oyb.js +5144 -0
  26. package/dist/assets/compile-worker-D1pl6G7x.js +6 -0
  27. package/dist/assets/landing-2Fy7DU41.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  30. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  31. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  32. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  33. package/dist/cozyclay-package.json +1 -1
  34. package/dist/index.html +4 -1
  35. package/dist/privacy/index.html +9 -6
  36. package/dist/sitemap.xml +2 -2
  37. package/dist/workflow/index.html +5 -4
  38. package/mcp/README.md +1 -1
  39. package/mcp/live-hub.mjs +4 -4
  40. package/mcp/tool-handlers.mjs +14 -16
  41. package/package.json +3 -1
  42. package/src/App.jsx +42 -75
  43. package/src/analytics-controls.js +25 -3
  44. package/src/analytics-strict.js +16 -4
  45. package/src/analytics.js +32 -26
  46. package/src/app-version.js +13 -0
  47. package/src/asset-drop.js +39 -0
  48. package/src/asset-pane.css +34 -0
  49. package/src/asset-pane.jsx +4 -35
  50. package/src/bus-tasks.js +1 -1
  51. package/src/character-kind.js +8 -6
  52. package/src/click-friction.js +5 -4
  53. package/src/command-bus-telemetry.js +8 -7
  54. package/src/command-bus.js +2 -16
  55. package/src/commands/cast.js +2 -1
  56. package/src/commands/index.js +2 -1
  57. package/src/commands/motion3.js +3 -1
  58. package/src/commands/objects.js +2 -37
  59. package/src/commands/physics.js +21 -0
  60. package/src/commands/project.js +3 -3
  61. package/src/commands/sculpt-compare.js +53 -0
  62. package/src/content-tile.jsx +36 -0
  63. package/src/domains/cast.js +7 -6
  64. package/src/domains/motion.js +13 -14
  65. package/src/domains/scenes.js +41 -31
  66. package/src/execution-telemetry.js +7 -3
  67. package/src/export-menu-telemetry.js +26 -0
  68. package/src/finger-handles.jsx +128 -0
  69. package/src/friction-hint.js +82 -0
  70. package/src/live-control.js +2 -1
  71. package/src/locale-zh/panels.js +20 -0
  72. package/src/locale-zh/shell.js +4 -0
  73. package/src/locale-zh/workspace.js +6 -0
  74. package/src/motion/center-of-mass.js +5 -23
  75. package/src/motion/contact-project.js +143 -0
  76. package/src/motion/find-bone.js +13 -0
  77. package/src/motion/fingers.js +170 -0
  78. package/src/motion/fingertip-pick.js +21 -0
  79. package/src/motion/fix-collisions.js +1 -1
  80. package/src/motion/ik-drag.js +93 -1
  81. package/src/motion/ik-exposure.js +48 -0
  82. package/src/motion/ik.js +4 -11
  83. package/src/motion/timeline.css +12 -0
  84. package/src/motion/timeline.jsx +2 -2
  85. package/src/motion3/compile-job.js +3 -2
  86. package/src/motion3/compiler/compile.js +3 -3
  87. package/src/motion3/compiler/head-lead.js +4 -2
  88. package/src/motion3/compiler/spring.js +95 -0
  89. package/src/motion3/compiler/validate-objects.js +18 -2
  90. package/src/motion3/compiler/validate-parts.js +1 -0
  91. package/src/motion3/compiler/vocab.js +1 -1
  92. package/src/motion3/fix/index.js +8 -4
  93. package/src/motion3/fix/person.js +110 -0
  94. package/src/motion3/free-motion.js +49 -11
  95. package/src/motion3/hybrid-hint.js +19 -0
  96. package/src/motion3/load.js +19 -12
  97. package/src/motion3/object-pose.js +44 -2
  98. package/src/motion3/people.js +16 -1
  99. package/src/motion3/physics-frames.js +72 -0
  100. package/src/motion3/physics-trail.js +45 -0
  101. package/src/motion3/physics-view.js +79 -0
  102. package/src/motion3/pipeline.js +5 -1
  103. package/src/motion3/qa/ballistic.js +67 -0
  104. package/src/motion3/qa/com.js +140 -0
  105. package/src/motion3/qa/ground.js +10 -3
  106. package/src/motion3/qa/hints-contact.js +21 -7
  107. package/src/motion3/qa/hints-motion.js +38 -1
  108. package/src/motion3/qa/key-checks.js +5 -4
  109. package/src/motion3/qa/patch.js +32 -4
  110. package/src/motion3/qa/people-checks.js +1 -1
  111. package/src/motion3/qa/spring-pivot.js +23 -0
  112. package/src/motion3/qa/style.js +13 -30
  113. package/src/motion3/qa/verdict.js +7 -2
  114. package/src/motion3/repair.js +28 -8
  115. package/src/motion3/schema/fields.js +4 -2
  116. package/src/motion3/schema/topics.js +15 -11
  117. package/src/motion3/schema/types.js +13 -9
  118. package/src/motion3/skills/catch.js +2 -1
  119. package/src/panels/FingerPanel.jsx +38 -0
  120. package/src/panels/RigControlPanel.jsx +9 -1
  121. package/src/panels/SculptPanel.jsx +12 -12
  122. package/src/panels/SculptPartTree.jsx +46 -0
  123. package/src/panels/SubjectsPanel.jsx +1 -1
  124. package/src/panels/sculpt.css +11 -0
  125. package/src/physics-overlay-settings.js +41 -0
  126. package/src/physics-overlay.js +152 -0
  127. package/src/physics-overlay.jsx +54 -0
  128. package/src/physics-toggle.jsx +20 -0
  129. package/src/pose-fade.js +7 -3
  130. package/src/posestudio.jsx +15 -28
  131. package/src/project-browser.css +6 -0
  132. package/src/project-browser.jsx +9 -18
  133. package/src/project-start-choices.jsx +45 -0
  134. package/src/project.js +16 -12
  135. package/src/proxy-figure-shape.js +11 -0
  136. package/src/proxy-figure.jsx +25 -18
  137. package/src/scene-assets.js +17 -13
  138. package/src/scenes.js +22 -11
  139. package/src/sculpt-blueprint.js +56 -0
  140. package/src/sculpt-mode.js +5 -0
  141. package/src/sculpt-part-edit.js +115 -0
  142. package/src/sculpt-part-gizmo.jsx +159 -0
  143. package/src/sculpt-part-outline.jsx +72 -0
  144. package/src/sculpt-pick.js +53 -0
  145. package/src/sculpt-run-text.js +51 -0
  146. package/src/sculpt-run.js +10 -46
  147. package/src/sculpt-shelf-file.js +92 -0
  148. package/src/sculpt-shelf-tiles.jsx +69 -0
  149. package/src/sculpt-shelf.js +108 -0
  150. package/src/sculpt-stage.jsx +39 -2
  151. package/src/sculpt-thumb.js +72 -0
  152. package/src/sculpt-views.js +75 -0
  153. package/src/semantic-edit.js +0 -87
  154. package/src/settings-menu.jsx +2 -2
  155. package/src/shell/DetailsSlot.jsx +5 -6
  156. package/src/shell/LibrarySlot.jsx +8 -10
  157. package/src/shell/MenuBar.jsx +6 -6
  158. package/src/shell/PreferencesDialog.jsx +2 -3
  159. package/src/shell/ViewportToolbar.jsx +15 -18
  160. package/src/shell/add-character-entries.js +13 -0
  161. package/src/shell/pose-controls.js +32 -0
  162. package/src/shell/preference-pages.jsx +2 -11
  163. package/src/shell/storyboard-caption.js +21 -0
  164. package/src/shell/studio-shell-context.js +1 -0
  165. package/src/shortcut-friction.js +2 -1
  166. package/src/site/landing-analytics.js +28 -0
  167. package/src/studio-actions.js +3 -2
  168. package/src/studio-agent-protocol.js +4 -4
  169. package/src/studio-app-binding.js +4 -3
  170. package/src/update/UpdateChip.jsx +21 -2
  171. package/src/update/UpdateDialog.jsx +26 -6
  172. package/src/update/update-client.js +123 -17
  173. package/src/update/update-telemetry.js +15 -3
  174. package/src/use-sculpt-mode.js +62 -8
  175. package/src/workflow/agent-failure-codes.js +28 -9
  176. package/src/workflow/agent-turn-telemetry.js +1 -1
  177. package/src/workflow/task-board.js +2 -2
  178. package/tools/dev/page-shell.py +1 -1
  179. package/tools/dev/pages/greybox-to-video.html +21 -2
  180. package/tools/dev/pages/previs-software.html +3 -3
  181. package/tools/dev/pages/privacy.html +9 -6
  182. package/tools/dev/pages/seedance-camera-control.html +2 -2
  183. package/tools/loc-baseline.json +10 -10
  184. package/tools/run-tests.mjs +53 -84
  185. package/tools/select-tests.mjs +119 -0
  186. package/tools/test-durations.json +455 -0
  187. package/tools/test-graph.mjs +181 -0
  188. package/tools/test-shards.mjs +78 -0
  189. package/tools/tutorial/README.md +74 -0
  190. package/tools/tutorial/assemble.mjs +43 -0
  191. package/tools/tutorial/captions.mjs +43 -0
  192. package/tools/tutorial/cdp.mjs +55 -0
  193. package/tools/tutorial/cursor.mjs +159 -0
  194. package/tools/tutorial/episode.mjs +173 -0
  195. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  196. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  197. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  198. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  199. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  200. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  201. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  202. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  203. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  204. package/tools/tutorial/episodes/cast.mjs +12 -0
  205. package/tools/tutorial/episodes/first-video.mjs +121 -0
  206. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  207. package/tools/tutorial/launch.mjs +63 -0
  208. package/tools/tutorial/mux.mjs +61 -0
  209. package/tools/tutorial/overlay.mjs +142 -0
  210. package/tools/tutorial/record.mjs +156 -0
  211. package/tools/tutorial/scenes/terminal.html +63 -0
  212. package/tools/tutorial/steps.mjs +111 -0
  213. package/tools/tutorial/terminal.mjs +43 -0
  214. package/tools/tutorial/voice.mjs +49 -0
  215. package/tools/update-test-durations.mjs +42 -0
  216. package/tools/verify-package-tarball.mjs +18 -12
  217. package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
  218. package/dist/assets/app-BLS1Zaru.js +0 -5144
  219. package/dist/assets/compile-worker-or7djI2v.js +0 -6
  220. package/dist/assets/motion3-compile-Du7UXrWn.js +0 -1
  221. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  222. package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
  223. package/dist/assets/task-board-CkLZGPkv.js +0 -15
  224. package/dist/assets/workflow-DJwOUzqJ.js +0 -179
@@ -0,0 +1,110 @@
1
+ // Person push-out (spec.fix.person, on by default): an arm capsule (upper arm, forearm, hand) inside another character
2
+ // (scene.people, their installed take; qa/people-checks.js PERSON) moves its wrist IK target out by the depth plus a margin,
3
+ // eased over neighbouring frames, the same way push.js takes a hand out of a box. No depth limit: a hand keyed deep into
4
+ // someone comes out too. The escape keeps the side the hand came from: a normal that points the opposite way from the
5
+ // previous frame's (the first frame of a pass: from their hips to mine, level), or away from my side of them (that level
6
+ // line), is replaced by the previous one, so a hand past their axis is pulled back the way it came instead of out through
7
+ // their back (the previous frame alone lets the normal turn a little each frame until it points through them). Fixed passes and fixed order (people by id, their
8
+ // capsules in CAPSULES order), no time or random cut-off: the same spec gives the same take. Frames where that arm is FK
9
+ // (armw 0) cannot be pushed and are counted as skipped. An intended touch (checks.allow { part, object: "person:<id>",
10
+ // depth }) may go that deep.
11
+ import { add, sub, scale, dot, len, clamp } from "../compiler/math.js";
12
+ import { QA_LIMITS, allowance } from "../qa/checks.js";
13
+ import { CAPSULES, segPoints } from "../qa/geometry.js";
14
+ import { personCapsules } from "../qa/people-checks.js";
15
+ import { round4 } from "./ease.js";
16
+
17
+ const EASE = 4, MARGIN = 0.004, PASSES = 6, APART = 2.2;
18
+ const ARM_PARTS = { l: CAPSULES.filter(c => /^(uarm|farm|hand)_l$/.test(c[0])), r: CAPSULES.filter(c => /^(uarm|farm|hand)_r$/.test(c[0])) };
19
+
20
+ /** The closest points of segment PQ (sampled as qa/geometry.js segDist does, so the depth is QA's) and segment CD. */
21
+ function closest(P, Q, C, D) {
22
+ const d2 = sub(D, C), dd = dot(d2, d2);
23
+ let best = null;
24
+ for (const a of segPoints(P, Q)) {
25
+ const u = dd < 1e-12 ? 0 : clamp(dot(sub(a, C), d2) / dd, 0, 1), b = add(C, scale(d2, u)), d = len(sub(a, b));
26
+ if (!best || d < best.d) best = { a, b, d };
27
+ }
28
+ return best;
29
+ }
30
+
31
+ /** Level unit vector from a to b (their hips to mine), or null when they are on top of each other. */
32
+ function level(a, b) {
33
+ const v = [b[0] - a[0], 0, b[2] - a[2]], l = len(v);
34
+ return l > 1e-6 ? scale(v, 1 / l) : null;
35
+ }
36
+
37
+ /** The bodies to keep out of: [id, person] sorted by id, only those whose take has every capsule joint. */
38
+ function bodies(out) {
39
+ return Object.entries(out.ctx?.scene?.people ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
40
+ .filter(([, p]) => CAPSULES.every(([, a, b]) => p.names.includes(a) && p.names.includes(b)));
41
+ }
42
+
43
+ /** Per-frame push vectors for side k: the escape (side-kept normal * (depth past what is allowed + margin)) summed over people. */
44
+ function needs(out, k, people, lim, allow, stats) {
45
+ const { sk } = out.ctx, I = sk.index, n = out.frames.length, f0 = out.f0 ?? 0, v = Array.from({ length: n }, () => null);
46
+ const prev = new Map();
47
+ for (let i = 0; i < n; i++) {
48
+ const { s, c } = out.frames[i];
49
+ let sum = null;
50
+ for (const [id, p] of people) {
51
+ const theirs = personCapsules(p, i + f0), hips = theirs.find(t => t[0] === "hips")[1];
52
+ if (len(sub(s.P[I.Hips], hips)) > APART) continue;
53
+ let best = null;
54
+ for (const [part, a, b, r] of ARM_PARTS[k]) {
55
+ const meant = allowance(allow, part, `person:${id}`), limit = meant ?? lim, A = s.P[I[a]], B = s.P[I[b]];
56
+ for (const [, C, D, q] of theirs) {
57
+ const hit = closest(A, B, C, D), depth = r + q - hit.d;
58
+ if (depth > limit && (!best || depth - (meant ?? 0) > best.need)) best = { need: depth - (meant ?? 0), hit };
59
+ }
60
+ }
61
+ if (!best) continue;
62
+ const side = level(hips, s.P[I.Hips]) ?? [0, 0, 0], ref = prev.get(id) ?? side;
63
+ let nrm = best.hit.d > 1e-6 ? scale(sub(best.hit.a, best.hit.b), 1 / best.hit.d) : ref;
64
+ if (dot(nrm, ref) < 0 || dot(nrm, side) < 0) nrm = ref;
65
+ prev.set(id, nrm);
66
+ sum = add(sum ?? [0, 0, 0], scale(nrm, best.need + MARGIN));
67
+ }
68
+ if (!sum) continue;
69
+ if (!(c[`armw_${k}`] > 1e-4)) { stats.skipped++; continue; }
70
+ v[i] = sum;
71
+ }
72
+ return v;
73
+ }
74
+
75
+ export function personOut(out, engine) {
76
+ const people = bodies(out);
77
+ if (!people.length) return null;
78
+ const { checks = {} } = out.ctx.spec, lim = { ...QA_LIMITS, ...checks.limits }.allow, n = out.frames.length, stats = { skipped: 0 };
79
+ const hit = new Set();
80
+ let max = 0, passes = 0;
81
+ for (let pass = 0; pass < PASSES; pass++) {
82
+ stats.skipped = 0;
83
+ const touched = new Set();
84
+ for (const k of ["l", "r"]) {
85
+ const v = needs(out, k, people, lim, checks.allow, stats), push = out.ctx.push[k];
86
+ // eased dilation of the vectors: neighbours get the strongest nearby push, scaled down (push.js)
87
+ const eased = Array.from({ length: n }, () => null);
88
+ for (let i = 0; i < n; i++) {
89
+ if (!v[i]) continue;
90
+ hit.add(`${k}${i}`);
91
+ for (let j = Math.max(0, i - EASE - 1); j <= Math.min(n - 1, i + EASE + 1); j++) {
92
+ const t = 1 - Math.abs(i - j) / (EASE + 1), w = scale(v[i], t * t * (3 - 2 * t));
93
+ if (!eased[j] || len(w) > len(eased[j])) eased[j] = w;
94
+ }
95
+ }
96
+ for (let i = 0; i < n; i++) {
97
+ if (!eased[i]) continue;
98
+ const f = out.f0 + i, next = add(push.get(f) ?? [0, 0, 0], eased[i]);
99
+ push.set(f, next);
100
+ max = Math.max(max, len(next));
101
+ touched.add(f);
102
+ }
103
+ }
104
+ if (!touched.size) break;
105
+ passes++;
106
+ engine.solve([...touched]);
107
+ }
108
+ if (!hit.size && !stats.skipped) return null;
109
+ return { frames: hit.size, max: round4(max), passes, skipped: stats.skipped, how: "hand pushed out of the other person, back the way it came" };
110
+ }
@@ -25,6 +25,7 @@ import { curveOf, keyedPose } from "./object-keys.js";
25
25
  import { add, apply, sub, T, quatFromMat, I3 } from "./compiler/math.js";
26
26
  import { shapeOf, airPath, settlePath, settledAt, spinOf, spinEnergy, TIP } from "./settle.js";
27
27
  import { fallVelocity, moves, flightTime, drift, landingWith } from "./flight.js";
28
+ import { springRest, springStep, swingOf, swungPose, springLength, springOpts } from "./compiler/spring.js";
28
29
 
29
30
  /** Gravity, m/s^2. */
30
31
  export const G = 9.8;
@@ -138,24 +139,28 @@ function eventsOf(o, map, from = -Infinity, to = Infinity) {
138
139
  return events;
139
140
  }
140
141
 
141
- /** The pose events give at mapped frame f, starting from pose `start`; `keep` caches each fall's plan on its event. */
142
- function walk(events, f, start, plan, keep = true) {
143
- let pose = start;
142
+ /**
143
+ * The pose events give at mapped frame f, starting from pose `start`, and whether f is on or past a fall's landing with no
144
+ * key lifting it since (landed: it rests and hangs from nothing): { pose, landed }. `keep` caches each fall's plan on its event.
145
+ */
146
+ function walkLanded(events, f, start, plan, keep = true) {
147
+ let pose = start, landed = false;
144
148
  for (const e of events) {
145
149
  if (e.run) {
146
150
  const { t } = e.curve;
147
- if (f < t[0]) return pose;
148
- if (f <= t[t.length - 1]) return keyedPose(e.curve, f);
149
- pose = keyedPose(e.curve, t[t.length - 1]);
151
+ if (f < t[0]) return { pose, landed };
152
+ if (f <= t[t.length - 1]) return { pose: keyedPose(e.curve, f), landed: false };
153
+ pose = keyedPose(e.curve, t[t.length - 1]); landed = false;
150
154
  } else {
151
- if (f < e.fall.t) return pose;
155
+ if (f < e.fall.t) return { pose, landed };
152
156
  const p = keep ? (e.plan ??= plan(pose, e.fall)) : plan(pose, e.fall);
153
- if (f < p.end) return p.at(f);
154
- pose = p.rest;
157
+ if (f < p.end) return { pose: p.at(f), landed: f >= p.land };
158
+ pose = p.rest; landed = true;
155
159
  }
156
160
  }
157
- return pose;
161
+ return { pose, landed };
158
162
  }
163
+ const walk = (events, f, start, plan, keep = true) => walkLanded(events, f, start, plan, keep).pose;
159
164
 
160
165
  const timelines = new WeakMap();
161
166
  /** The events of entry o under time map `map`, cached per scene, entry and span (key curves built once). */
@@ -182,6 +187,34 @@ export function freePoseAt(ctx, id, f) {
182
187
  return walk(timelineOf(ctx, id, o, ctx.timeMap, "all"), f, { pos: rest.pos, R: rest.R }, planner(ctx, id));
183
188
  }
184
189
 
190
+ const swings = new WeakMap();
191
+ /**
192
+ * freePoseAt turned by the entry's spring (spec.objects[id].spring, compiler/spring.js), else freePoseAt: the pendulum
193
+ * walks frames 0..f of the unswung pose and stops dead on a fall's landing (landedAt), cached per spec, entry and time map. The one swing of a free entry: the take and
194
+ * refs (object-pose.js rootPoseAt) and the obstacle boxes QA and fixes read (compiler/compile.js objectTracks).
195
+ */
196
+ export function freeSwungAt(ctx, id, f) {
197
+ const pose = freePoseAt(ctx, id, f), o = ctx.spec.objects?.[id];
198
+ if (!pose || !o?.spring) return pose;
199
+ if (!swings.has(ctx.spec)) swings.set(ctx.spec, new Map());
200
+ const cache = swings.get(ctx.spec);
201
+ let c = cache.get(id);
202
+ if (!c || c.map !== ctx.timeMap || c.o !== o) cache.set(id, c = { map: ctx.timeMap, o, states: [], length: springLength(partsIn(ctx.scene, id).flatMap(p => corners(ctx.scene.objects[p]))) });
203
+ const opts = springOpts(o.spring, ctx.spec.fps ?? 30, c.length);
204
+ for (let k = c.states.length; k <= f; k++) {
205
+ const s = springStep(k ? c.states[k - 1] : springRest(), (k === f ? pose : freePoseAt(ctx, id, k)).pos, opts);
206
+ // landed, it rests on its face and hangs from nothing: no swing, and none carried on (its corners stay on the surface)
207
+ c.states[k] = landedAt(ctx, id, k) ? { ...s, th: [0, 0], w: [0, 0] } : s;
208
+ }
209
+ return swungPose(pose, swingOf(c.states[f]));
210
+ }
211
+
212
+ /** True when free entry `id` at absolute f is on or past a fall's landing and no key has lifted it since (walkLanded). */
213
+ function landedAt(ctx, id, f) {
214
+ const rest = restOf(ctx.scene, id);
215
+ return walkLanded(timelineOf(ctx, id, ctx.spec.objects[id], ctx.timeMap, "all"), f, { pos: rest.pos, R: rest.R }, planner(ctx, id)).landed;
216
+ }
217
+
185
218
  /** Where carry `id` (entry o) is before its grab at absolute f: its scene pose, or where a fall before grab left it. */
186
219
  export function beforeGrab(ctx, id, o, f, grab) {
187
220
  const rest = restOf(ctx.scene, id);
@@ -194,10 +227,15 @@ export function beforeGrab(ctx, id, o, f, grab) {
194
227
  * `beforePose` (a frame before the release) gives the spin it is let go with to a fall that starts right at the release.
195
228
  */
196
229
  export function afterRelease(ctx, id, o, f, release, releasePose, beforePose = null) {
230
+ return releaseFall(ctx, id, o, f, release, releasePose, beforePose)?.pose ?? null;
231
+ }
232
+
233
+ /** afterRelease and whether f is on or past a landing of those falls (walkLanded): { pose, landed }, or null as afterRelease. */
234
+ export function releaseFall(ctx, id, o, f, release, releasePose, beforePose = null) {
197
235
  const events = fallsOf(o).length ? timelineOf({ ...ctx, release }, id, o, ctx.timeMap, "after") : [];
198
236
  // the release pose comes from the solved body, which a rebake changes: plan these falls afresh
199
237
  const plan = (start, fall) => fallPlan(ctx.spec, ctx.scene, id, start, fall.t, fall, ctx.spec.fps ?? 30, fall.t <= release + 1 ? beforePose : null);
200
- return events.length ? walk(events, f, releasePose, plan, false) : null;
238
+ return events.length ? walkLanded(events, f, releasePose, plan, false) : null;
201
239
  }
202
240
 
203
241
  /** True when carry entry o falls at or after its release (absolute frame; afterRelease then moves it). */
@@ -0,0 +1,19 @@
1
+ // A hybrid project's rig take that stopped paying: the compile result says so in one line, so the
2
+ // agent proposes capsule figures before more repairs instead of reading output.stuck as advisory
3
+ // (bin/agent/studio-prompt.mjs, the hybrid cast gate).
4
+
5
+ /** QA group kinds that a rig rarely repairs by more compiles: contact, collision, balance, body. */
6
+ export const HYBRID_HARD_KINDS = Object.freeze(["LOAD", "COLLIDE", "BODY", "SELF", "PERSON"]);
7
+ export const HYBRID_CAPSULE_HINT = "hybrid project: propose capsule figures to the user before more repairs";
8
+
9
+ /**
10
+ * The hint for a compile in a project with this castStyle, or null. It fires in a hybrid project
11
+ * only on a failing take (qa.ok false), when the repair watch said stuck or cap, or when the worst
12
+ * non-GOAL group (qa.groups is ranked) is hard. A stuck note on a passing take (the watch's
13
+ * "already passed QA" polish) is no reason to leave the rig.
14
+ */
15
+ export function hybridCapsuleHint(castStyle, { stuck, qa } = {}) {
16
+ if (castStyle !== "hybrid" || qa?.ok !== false) return null;
17
+ const top = (qa?.groups ?? []).find(group => group.kind !== "GOAL");
18
+ return stuck || HYBRID_HARD_KINDS.includes(top?.kind) ? HYBRID_CAPSULE_HINT : null;
19
+ }
@@ -7,7 +7,8 @@
7
7
  // observe output.observations.load: each hand that carries something, { object, mass, maxNm, at, frames }
8
8
  // LOAD "<side> holds <id> at <x> N m": past QA_LIMITS-style ONE_HAND N m with no second hand on the object; its
9
9
  // hint asks for the other hand, and when the right hand holds, a patch adds a left reachGrip that arrives at
10
- // the grab on the object's face nearest the left shoulder (second-hand.js holds only a right-hand carry)
10
+ // the grab on the object's face nearest the left shoulder, with the other faces as candidates repair compiles
11
+ // and picks from (repair.js; second-hand.js holds only a right-hand carry)
11
12
  // fix.grasp false turns all three off: the take of before.
12
13
  import { add, sub, scale, norm, apply, T, len } from "./compiler/math.js";
13
14
  import { SIDES } from "./compiler/pose.js";
@@ -18,6 +19,9 @@ import { handOf, spanOf, partsOf, objectPoseAt } from "./object-pose.js";
18
19
  import { SETTLE, restOf } from "./seat.js";
19
20
 
20
21
  const REACH = 10, G = 9.81, LIGHT = 2, SAG_MAX = 0.06, SAG_PER_KG = 0.006, ONE_HAND = 25, SECOND = 0.5;
22
+ // the left palm's axis sits a palm radius (qa/grasp-checks.js PALM) out of the face, so its skin is on it, not its bone;
23
+ // on the 12 kg box at 0 the forearm grazed the edge (+X face: QA 42), at 0.022 none (QA 0); at most FACES candidates
24
+ const STANDOFF = 0.022, FACES = 4;
21
25
  export const LOAD_UNITS = "load: what each hand carries, kg, and its most N m (mass x 9.81 x the shoulder-to-wrist distance across) at f; frames [first, last] held";
22
26
 
23
27
  const graspOn = spec => spec?.fix !== false && spec?.fix?.grasp !== false;
@@ -91,19 +95,21 @@ export function loadChecks(sk, out) {
91
95
  }
92
96
 
93
97
  /**
94
- * The anchor point (object local) for the left hand on carry id at frame f: the centre of the face nearest the left
95
- * shoulder, of the faces other than the bottom and the one the right palm is on (the box's other side, as two hands hold it).
98
+ * The anchor points (object local) for the left hand on carry id at frame f, nearest the left shoulder first: the
99
+ * centres of the faces other than the bottom and the one the right palm is on (the box's other side, as two hands hold
100
+ * it), each STANDOFF out of its face. Which face is cheapest the distance does not tell (on the 12 kg box the top won
101
+ * by 0.3 mm and sent the hand through the edge: QA 121, the +X face 42), so repair compiles them all (repair.js).
96
102
  */
97
- function otherFace(out, id, f) {
103
+ function otherFaces(out, id, f) {
98
104
  const { sk, scene } = out.ctx, s = out.frames[f - (out.f0 ?? 0)]?.s, pose = objectPoseAt(out, id, f), rest = restOf(scene, id);
99
105
  const part = scene.objects[partsOf(scene, id)[0]] ?? scene.objects[id];
100
- if (!s || !pose || !part?.half) return null;
106
+ if (!s || !pose || !part?.half) return [];
101
107
  const toLocal = p => apply(T(pose.R), sub(p, pose.pos)), c = apply(T(rest.R), sub(add(part.pos, apply(part.R, [0, part.half[1], 0])), rest.pos));
102
108
  const palm = toLocal(scale(add(s.P[sk.index.RightHand], s.P[sk.index.RightHandMiddle1]), 0.5)), shoulder = toLocal(s.P[sk.index.LeftArm]);
103
109
  const faces = [0, 1, 2].flatMap(i => [-1, 1].map(sg => { const p = c.slice(); p[i] += sg * part.half[i]; return { p, i, sg }; })).filter(x => !(x.i === 1 && x.sg < 0));
104
110
  const palmFace = faces.reduce((m, x) => (Math.abs(palm[x.i] - x.p[x.i]) < Math.abs(palm[m.i] - m.p[m.i]) ? x : m));
105
- const best = faces.filter(x => x !== palmFace).reduce((m, x) => (len(sub(x.p, shoulder)) < len(sub(m.p, shoulder)) ? x : m));
106
- return best.p.map(x => Math.round(x * 1000) / 1000);
111
+ const off = x => x.p.map((v, i) => Math.round((i === x.i ? v + x.sg * STANDOFF : v) * 1000) / 1000);
112
+ return faces.filter(x => x !== palmFace).map(x => ({ x, d: len(sub(x.p, shoulder)) })).sort((a, b) => a.d - b.d).slice(0, FACES).map(({ x }) => off(x));
107
113
  }
108
114
 
109
115
  export const loadHints = {
@@ -114,12 +120,13 @@ export const loadHints = {
114
120
  const k = side === "left" ? "l" : "r", other = k === "r" ? "left" : "right", [grab] = spanOf(X.out, o), kg = massOf(X.spec, X.out.ctx.scene, id);
115
121
  const plain = { cause: `objects.${id} (${kg.toFixed(1)} kg)`, hint: `${id} weighs ${kg.toFixed(1)} kg: ${g.worst.toFixed(0)} N m on the ${side} arm at f${g.worstFrame} (one hand holds ${ONE_HAND}); put the ${other} hand on it too (a reachGrip on an object anchor on ${id} from f${grab}), or carry it closer to the body` };
116
122
  if (k !== "r") return plain;
117
- const local = otherFace(X.out, id, Math.max(grab, Math.min(g.worstFrame, spanOf(X.out, o)[1])));
118
- if (!local) return plain;
123
+ const faces = otherFaces(X.out, id, Math.max(grab, Math.min(g.worstFrame, spanOf(X.out, o)[1])));
124
+ if (!faces.length) return plain;
119
125
  const name = `${id}Other`;
120
126
  // a big face takes the flat palm against it (a power grip's thumb would go into the box)
121
- const anchor = { kind: "object", object: id, local, grasp: "palm" };
122
- return { ...plain, fix: { ops: user => [user.anchors ? { op: "add", path: `/anchors/${name}`, value: anchor } : { op: "add", path: "/anchors", value: { [name]: anchor } },
123
- { op: "add", path: "/use/-", value: { skill: "reachGrip", f0: Math.max(0, grab - REACH), args: { anchor: name, side: "l", reach: Math.min(REACH, grab) || 1, hold: Math.max(1, spanOf(X.out, o)[1] - grab), release: 1 } } }] } };
127
+ const ops = local => user => { const anchor = { kind: "object", object: id, local, grasp: "palm" };
128
+ return [user.anchors ? { op: "add", path: `/anchors/${name}`, value: anchor } : { op: "add", path: "/anchors", value: { [name]: anchor } },
129
+ { op: "add", path: "/use/-", value: { skill: "reachGrip", f0: Math.max(0, grab - REACH), args: { anchor: name, side: "l", reach: Math.min(REACH, grab) || 1, hold: Math.max(1, spanOf(X.out, o)[1] - grab), release: 1 } } }]; };
130
+ return { ...plain, fix: { ops: ops(faces[0]), candidates: faces.map(ops) } };
124
131
  },
125
132
  };
@@ -32,8 +32,9 @@ import { groupsOf } from "./scene.js";
32
32
  import { solveFrame } from "./compiler/compile.js";
33
33
  import { channelsAt } from "./compiler/channels.js";
34
34
  import { resolveBone } from "./bones.js";
35
- import { isFree, freePoseAt, beforeGrab, afterRelease, fallsAfter, corners, topOf, under, TOUCH } from "./free-motion.js";
35
+ import { isFree, freePoseAt, freeSwungAt, beforeGrab, afterRelease, releaseFall, fallsAfter, corners, topOf, under, TOUCH } from "./free-motion.js";
36
36
  import { add } from "./compiler/math.js";
37
+ import { springRest, springStep, swingOf, swungPose, springLength, springOpts } from "./compiler/spring.js";
37
38
 
38
39
  /**
39
40
  * Body ids that make up `id` in a motionScene: [id] for a body (plus any bodies parented under
@@ -161,12 +162,53 @@ function carriedAt(out, id, o, f) {
161
162
  * solved frame, so the carrying hand must already be solved (compiler/anchors.js).
162
163
  */
163
164
  export function rootPoseAt(out, id, f, now = null) {
165
+ const o = out.ctx.spec.objects?.[id];
166
+ if (o?.spring && isFree(o)) return freeSwungAt(freeCtx(out), id, f);
167
+ const pose = basePoseAt(out, id, f, now);
168
+ return o?.spring && pose && isCarried(o) ? swungPose(pose, springAt(out, id, o, f, now ? pose : null)) : pose;
169
+ }
170
+
171
+ const springs = new WeakMap();
172
+
173
+ /**
174
+ * The swing [tx, tz] of spring carry `id` at absolute f (compiler/spring.js): the solver walks frames 0..f of the
175
+ * record's unswung pose (its anchor). Each frame's state is cached with the solved frame it was read on, and reused
176
+ * while frames 0..k are still those frames (a fix that re-solves a frame starts the walk again there). `nowPose`: the
177
+ * unswung pose of frame f while it is being solved (not cached).
178
+ */
179
+ function springAt(out, id, o, f, nowPose) {
180
+ if (!springs.has(out)) springs.set(out, new Map());
181
+ const cache = springs.get(out), f0 = out.f0 ?? 0, fps = out.fps ?? out.ctx.spec.fps ?? 30, { scene } = out.ctx;
182
+ if (!cache.has(id)) cache.set(id, { states: [], keys: [], length: springLength(partsOf(scene, id).flatMap(p => corners(scene.objects[p]))) });
183
+ const c = cache.get(id), opts = springOpts(o.spring, fps, c.length), release = spanOf(out, o)[1];
184
+ const end = nowPose ? f - 1 : f;
185
+ let k = 0;
186
+ while (k <= end && k < c.states.length && c.keys[k] === (out.frames[k - f0] ?? null)) k += 1;
187
+ c.states.length = k; c.keys.length = k;
188
+ // let go and fallen (free-motion.js releaseFall): from its landing it rests and hangs from nothing, no swing carried on
189
+ const step = (prev, j, pose) => {
190
+ const fell = j > release ? releaseFall(...releaseArgs(out, id, o, j, release)) : null, s = springStep(prev, (pose ?? fell?.pose ?? basePoseAt(out, id, j)).pos, opts);
191
+ return fell?.landed ? { ...s, th: [0, 0], w: [0, 0] } : s;
192
+ };
193
+ for (; k <= end; k++) {
194
+ c.states[k] = step(k ? c.states[k - 1] : springRest(), k, null);
195
+ c.keys[k] = out.frames[k - f0] ?? null;
196
+ }
197
+ const last = end >= 0 ? c.states[end] : springRest();
198
+ return swingOf(nowPose ? step(last, f, nowPose) : last);
199
+ }
200
+
201
+ /** The arguments of free-motion.js afterRelease / releaseFall for carry `id` (entry o) at f past its release frame. */
202
+ const releaseArgs = (out, id, o, f, release) => [freeCtx(out), id, o, f, release, carriedAt(out, id, o, release), release > 0 ? carriedAt(out, id, o, release - 1) : null];
203
+
204
+ /** rootPoseAt before a spring swings it: where the keys and the hand put it (qa/key-checks.js measures the keys on it). */
205
+ export function basePoseAt(out, id, f, now = null) {
164
206
  const { spec, scene } = out.ctx, o = movesRecord(spec.objects?.[id]) ? spec.objects[id] : undefined, rest = restPose(scene, id);
165
207
  if (isFollow(o)) return followPose(now ? { ...out, f0: f, frames: [now] } : out, o, f);
166
208
  if (isCarried(o) && (rest || !keepsOffset(scene, id, o))) {
167
209
  const [grab, release] = spanOf(out, o), g = Math.max(grab, Math.min(f, release));
168
210
  if (f < grab && rest) return restingAt(out, id, f);
169
- const after = f > release ? afterRelease(freeCtx(out), id, o, f, release, carriedAt(out, id, o, release), release > 0 ? carriedAt(out, id, o, release - 1) : null) : null;
211
+ const after = f > release ? afterRelease(...releaseArgs(out, id, o, f, release)) : null;
170
212
  if (after) return after;
171
213
  const by = f > release ? riderOf(out, id) : null;
172
214
  if (by) { const at = carriedAt(out, id, o, release); return compose(rootPoseAt(out, by, f), relative(rootPoseAt(out, by, release), at)); }
@@ -8,6 +8,8 @@ import { skeletonFor } from "./rigs/index.js";
8
8
  import { fk } from "./compiler/skeleton.js";
9
9
  import { add, apply, ry } from "./compiler/math.js";
10
10
  import { resolveBone } from "./bones.js";
11
+ import { CAPSULES } from "./qa/geometry.js";
12
+ import { APART } from "./qa/people-checks.js";
11
13
 
12
14
  const sampled = new WeakMap();
13
15
  // joints are kept to 1e-5 m: what a saved take keeps of them (keptPeople) is then exactly what its compile read
@@ -61,8 +63,10 @@ export const peopleList = scene => Object.entries(scene?.people ?? {}).map(([id,
61
63
  * What a take keeps of the people so that its saved source compiles the same again without the others' takes
62
64
  * (take-source.js): only the joints its person anchors and refs name (anywhere in the spec: anchors, a skill's
63
65
  * define, goals, probes; a bare person:<id> in a skill's from is that person's Hips), as plain arrays (a saved project is JSON), or null.
66
+ * When fix.person moved an arm (out.fixed.person.frames), every capsule joint (qa/geometry.js CAPSULES) of each person whose
67
+ * hips came within APART of mine is kept too, so the saved source pushes the same arm out again (fix/person.js).
64
68
  */
65
- export function keptPeople(scene, spec) {
69
+ export function keptPeople(scene, spec, out) {
66
70
  const want = new Map(), keep = (id, b) => {
67
71
  const p = scene?.people?.[id], bone = p && resolveBone(skeletonFor(p.rig), b).bone;
68
72
  if (bone) want.set(id, new Set([...(want.get(id) ?? []), bone]));
@@ -73,6 +77,17 @@ export function keptPeople(scene, spec) {
73
77
  else if (v && typeof v === "object") { if (v.kind === "person") keep(v.person, v.bone); for (const [k, x] of Object.entries(v)) walk(x, k); }
74
78
  };
75
79
  walk(spec);
80
+ if (out?.fixed?.person?.frames > 0) {
81
+ const H = out.ctx.sk.index.Hips, f0 = out.f0 ?? 0, joints = CAPSULES.flatMap(([, a, b]) => [a, b]);
82
+ for (const [id, p] of Object.entries(scene?.people ?? {})) {
83
+ const j = p.names.indexOf(CAPSULES.find(c => c[0] === "hips")[1]), J = p.names.length;
84
+ const near = j >= 0 && out.frames.some(({ s }, i) => {
85
+ const at = (Math.max(0, Math.min(p.frames - 1, i + f0)) * J + j) * 3, h = s.P[H];
86
+ return Math.hypot(h[0] - p.P[at], h[1] - p.P[at + 1], h[2] - p.P[at + 2]) <= APART;
87
+ });
88
+ if (near) for (const b of joints) if (p.names.includes(b)) keep(id, b);
89
+ }
90
+ }
76
91
  if (!want.size) return null;
77
92
  return Object.fromEntries([...want].map(([id, bones]) => {
78
93
  const p = scene.people[id], names = [...bones], J = p.names.length, P = [];
@@ -0,0 +1,72 @@
1
+ // The frames QA measured, rebuilt from an installed take v3 (take.js bakeTake), for the physics overlay
2
+ // (physics-view.js). A take keeps local bone turns and the hips in the character's placement frame, plus world
3
+ // tracks of the objects it moves; QA read the compiler's `out`: world joint positions per frame, the skeleton and
4
+ // the obstacle boxes per frame. takeOut gives that shape back, so the overlay calls QA's own functions (qa/com.js,
5
+ // qa/ground.js, qa/style.js) on the pose the viewport plays, and its verdicts are QA's.
6
+ // frames[f].s { P, R }: fk of the take's frame f, turned by the placement yaw and moved to its position
7
+ // objectsAt(f) the authored records as motion boxes (scene.js), the way compile.js objectTracks lays them out:
8
+ // a record a carry or a follow moves is not an obstacle; one a free or hinge entry moves stands
9
+ // where the take's track puts its mover
10
+ // Pure: the take and the records in, numbers out; cached per take and records.
11
+ import { skeletonFor } from "./rigs/index.js";
12
+ import { fk } from "./compiler/skeleton.js";
13
+ import { add, apply, mul, ry, matFromQuat } from "./compiler/math.js";
14
+ import { motionScene, groupsOf } from "./scene.js";
15
+ import { isCarried, isFollow, movesRecord, compose, relative } from "./carried.js";
16
+ import { expandUse } from "./skills/index.js";
17
+
18
+ const cache = new WeakMap();
19
+
20
+ /** The take's spec with its skills expanded (the checks and objects QA read), or the spec as stored when it cannot expand. */
21
+ function expandedSpec(take, sk, scene) {
22
+ try { return expandUse(take.spec ?? {}, { sk, scene, lenient: true }).spec; } catch { return take.spec ?? {}; }
23
+ }
24
+
25
+ /** World pose { pos, R } of track `track` at frame f, or null when it has none there. */
26
+ export function trackPose(track, f) {
27
+ if (!track?.pos || !track.quat || f < 0 || (f + 1) * 3 > track.pos.length) return null;
28
+ const p = track.pos, q = track.quat;
29
+ return { pos: [p[f * 3], p[f * 3 + 1], p[f * 3 + 2]], R: matFromQuat([q[f * 4], q[f * 4 + 1], q[f * 4 + 2], q[f * 4 + 3]]) };
30
+ }
31
+
32
+ /** The obstacle boxes of frame f (compile.js objectTracks obstacles, the movers read from the take's tracks). */
33
+ function obstaclesAt(take, spec, scene, f) {
34
+ const out = {}, groups = groupsOf(scene);
35
+ for (const [id, o] of Object.entries(scene.objects)) {
36
+ const mover = [id, ...(o.ancestors ?? [])].find(k => movesRecord(spec.objects?.[k])), entry = spec.objects?.[mover];
37
+ if (!entry) { out[id] = o; continue; }
38
+ if (isCarried(entry) || isFollow(entry)) continue;
39
+ const pose = trackPose(take.objects?.[mover], f), rest = scene.objects[mover] ?? groups[mover];
40
+ out[id] = pose && rest ? { ...o, ...compose(pose, relative(rest, o)) } : o;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /**
46
+ * takeOut(take, objects) -> { frames, ctx: { sk, spec, scene }, objectsAt, fps, f0, frameCount } | null: the `out`
47
+ * QA reads, from take v3 `take` over the authored records `objects`. null for another take or a body motion3 cannot
48
+ * drive. spec is the take's spec with its skills expanded.
49
+ */
50
+ export function takeOut(take, objects = []) {
51
+ if (take?.schema !== "cozyclay.take.v3" || !(take.frameCount > 0)) return null;
52
+ const kept = cache.get(take);
53
+ if (kept && kept.objects === objects) return kept.out;
54
+ const sk = skeletonFor(take.rig);
55
+ if (!sk) return null;
56
+ const B = take.bones.length, slot = take.bones.map(name => sk.index[name]), yaw = ry(take.placement?.yaw ?? 0), at = take.placement?.position ?? [0, 0, 0];
57
+ const frames = [];
58
+ for (let f = 0; f < take.frameCount; f++) {
59
+ const quats = sk.names.map(() => [0, 0, 0, 1]);
60
+ for (let b = 0; b < B; b++) if (slot[b] !== undefined) quats[slot[b]] = Array.from(take.rot.subarray((f * B + b) * 4, (f * B + b) * 4 + 4));
61
+ const local = fk(sk, quats, Array.from(take.hips.subarray(f * 3, f * 3 + 3)));
62
+ frames.push({ s: { P: local.P.map(p => p && add(at, apply(yaw, p))), R: local.R.map(R => R && mul(yaw, R)) } });
63
+ }
64
+ const scene = motionScene(objects), spec = expandedSpec(take, sk, scene), boxes = new Map();
65
+ const objectsAt = f => {
66
+ if (!boxes.has(f)) boxes.set(f, obstaclesAt(take, spec, scene, Math.max(0, Math.min(take.frameCount - 1, f))));
67
+ return boxes.get(f);
68
+ };
69
+ const out = { frames, ctx: { sk, spec, scene }, objectsAt, fps: take.fps ?? 24, f0: 0, frameCount: take.frameCount };
70
+ cache.set(take, { objects, out });
71
+ return out;
72
+ }
@@ -0,0 +1,45 @@
1
+ // The physics overlay's motion trail of spring objects (spec.objects[id].spring, compiler/spring.js): the path of
2
+ // each one's tip over the last TRAIL_FRAMES frames, read from the take's own track (what playback draws). The tip
3
+ // is the corner of the record's boxes (its parts, for a group) farthest from its anchor, the pose's origin the
4
+ // spring swings it about (compiler/spring.js swungPose). An object without a spring has no trail.
5
+ // Pure and cached per view (physics-view.js).
6
+ import { add, apply, sub, len, T } from "./compiler/math.js";
7
+ import { trackPose } from "./physics-frames.js";
8
+ import { groupsOf } from "./scene.js";
9
+ import { corners } from "./free-motion.js";
10
+ import { partsOf } from "./object-pose.js";
11
+
12
+ /** How many frames back a trail reaches (the playhead's frame included). */
13
+ export const TRAIL_FRAMES = 24;
14
+ const tips = new WeakMap();
15
+
16
+ /** [{ id, local }]: each spring object the take tracks, with its tip in its own frame. */
17
+ function springTips(view) {
18
+ if (tips.has(view)) return tips.get(view);
19
+ const { scene, spec } = view.out.ctx, groups = groupsOf(scene), list = [];
20
+ for (const [id, o] of Object.entries(spec.objects ?? {})) {
21
+ const rest = scene.objects[id] ?? groups[id];
22
+ if (!o?.spring || !rest || !view.take.objects?.[id]?.pos) continue;
23
+ let local = null;
24
+ for (const c of partsOf(scene, id).flatMap(part => corners(scene.objects[part]))) {
25
+ const q = apply(T(rest.R), sub(c, rest.pos));
26
+ if (!local || len(q) > len(local) + 1e-9) local = q;
27
+ }
28
+ if (local) list.push({ id, local });
29
+ }
30
+ tips.set(view, list);
31
+ return list;
32
+ }
33
+
34
+ /** [{ id, points: [[x, y, z], ...] }]: each spring object's tip from `length` - 1 frames before `frame` up to it, oldest first. */
35
+ export function tipTrails(view, frame, length = TRAIL_FRAMES) {
36
+ const last = Math.max(0, Math.min(view.out.frames.length - 1, Math.round(frame) || 0));
37
+ return springTips(view).map(({ id, local }) => {
38
+ const points = [];
39
+ for (let f = Math.max(0, last - length + 1); f <= last; f++) {
40
+ const pose = trackPose(view.take.objects[id], f);
41
+ if (pose) points.push(add(pose.pos, apply(pose.R, local)));
42
+ }
43
+ return { id, points };
44
+ });
45
+ }
@@ -0,0 +1,79 @@
1
+ // What the physics overlay draws (src/physics-overlay.js), frame by frame, measured by QA's own functions on the
2
+ // installed take (physics-frames.js takeOut), so the overlay's colours are QA's verdicts:
3
+ // comAt the mass-weighted centre of mass (qa/com.js) and its state: "outside" when it sits farther outside
4
+ // the support polygon than the balance limit (checks.limits.balance, else QA_LIMITS.balance: what
5
+ // BALANCE reports), "inside" otherwise, "air" with no foot on the ground, "supported" where a declared
6
+ // support carries the body (qa/style.js balanceFrames)
7
+ // supportAt the convex hull of the sole points on the ground (qa/com.js balanceAt feet), each corner on
8
+ // the ground QA measured that sole point from (balanceAt ground)
9
+ // airArcs per air run of 4 frames or more (qa/style.js airRuns, the runs floatChecks judges): the hips path
10
+ // from the frame before takeoff to the frame after landing, and the gravity parabola through the same
11
+ // two anchors (qa/ballistic.js); float marks the runs QA calls FLOAT
12
+ // Positions are world metres where the take was compiled (take.placement). Pure and cached per take.
13
+ import { takeOut } from "./physics-frames.js";
14
+ import { balanceFrames, airRuns, floatChecks } from "./qa/style.js";
15
+ import { balanceAt, bodyCentre, convexHull } from "./qa/com.js";
16
+ import { groundY } from "./qa/ground.js";
17
+ import { ballisticArc } from "./qa/ballistic.js";
18
+ import { QA_LIMITS } from "./qa/checks.js";
19
+
20
+ /** floatChecks judges runs of this many frames or more. */
21
+ const MIN_RUN = 4;
22
+ const views = new WeakMap();
23
+
24
+ /** The overlay's view of take v3 `take` over the authored records `objects`, or null when it has none. */
25
+ export function physicsView(take, objects = []) {
26
+ const out = takeOut(take, objects);
27
+ if (!out) return null;
28
+ if (!views.has(out)) {
29
+ const checks = out.ctx.spec.checks ?? {};
30
+ views.set(out, { take, out, checks, limit: checks.limits?.balance ?? QA_LIMITS.balance, balance: null, arcs: null });
31
+ }
32
+ return views.get(out);
33
+ }
34
+
35
+ /** The take frame shown at timeline frame `frame` (clamped to the take). */
36
+ const viewFrame = (view, frame) => Math.max(0, Math.min(view.out.frames.length - 1, Math.round(frame) || 0));
37
+
38
+ const balanceOf = view => (view.balance ??= balanceFrames(view.out, view.checks.supports));
39
+
40
+ /** { frame, p: [x, y, z], ground: the ground height under p, state, margin (m, null off the ground) } at `frame`. */
41
+ export function comAt(view, frame) {
42
+ const f = viewFrame(view, frame), b = balanceOf(view)[f], measured = b && b !== "air";
43
+ const p = measured ? b.com : bodyCentre(view.out.ctx.sk, view.out.frames[f].s);
44
+ const state = b === null ? "supported" : b === "air" ? "air" : -b.margin > view.limit ? "outside" : "inside";
45
+ return { frame: f, p, ground: groundY(view.out, f, p), state, margin: measured ? b.margin : null };
46
+ }
47
+
48
+ /**
49
+ * The support polygon at `frame`: its corners [x, y, z] counter-clockwise in (x, z), y the ground QA measured that
50
+ * sole point from (balanceAt ground); [] with no foot down. convexHull keeps the feet's own points, so each corner
51
+ * finds its ground by identity.
52
+ */
53
+ export function supportAt(view, frame) {
54
+ const f = viewFrame(view, frame), b = balanceOf(view)[f] ?? balanceAt(view.out, f);
55
+ if (b === "air") return [];
56
+ return convexHull(b.feet).map(p => [p[0], b.ground[b.feet.indexOf(p)], p[1]]);
57
+ }
58
+
59
+ /**
60
+ * [{ a, b, from, to, float, path, fit, worst }]: per air run [a, b], the hips path from anchor `from` (a - 1) to `to`
61
+ * (b + 1) and the parabola `fit` over the same frames (the hips' x and z, the arc's height); worst = the largest
62
+ * fit - actual height (m). A run that touches either end of the take has no anchor and no arc.
63
+ */
64
+ export function airArcs(view) {
65
+ if (view.arcs) return view.arcs;
66
+ const { out, checks } = view, I = out.ctx.sk.index, hips = out.frames.map(fr => fr.s.P[I.Hips]), y = hips.map(p => p[1]);
67
+ const floats = new Set(floatChecks(out, checks).map(issue => issue.frame)), g = 9.81 / out.fps ** 2;
68
+ view.arcs = [];
69
+ for (const [a, b] of airRuns(out, checks.supports)) {
70
+ const arc = b - a + 1 >= MIN_RUN ? ballisticArc(y, a, b, g, Infinity) : null;
71
+ if (!arc) continue;
72
+ const path = hips.slice(arc.from, arc.to + 1).map(p => [...p]);
73
+ const fit = path.map((p, i) => (i === 0 || i === path.length - 1 ? [...p] : [p[0], p[1] + arc.delta[i - 1], p[2]]));
74
+ let float = true;
75
+ for (let f = a; f <= b; f++) float &&= floats.has(f);
76
+ view.arcs.push({ a, b, from: arc.from, to: arc.to, float, path, fit, worst: arc.delta[arc.worst - a] });
77
+ }
78
+ return view.arcs;
79
+ }
@@ -7,7 +7,8 @@
7
7
  // heldReadings; each hand and foot against the nearest object, qa/near.js observeNear; each holding
8
8
  // wrist's bend, qa/wrist-obs.js observeWrist; how mechanical it reads against the agent's own
9
9
  // keys, qa/motion-life.js observeLife; a skill
10
- // paced to fit a short span: observations.paced; a skill whose release or
10
+ // paced to fit a short span: observations.paced; a carried spring whose base is far from the hand:
11
+ // qa/spring-pivot.js observations.spring; a skill whose release or
11
12
  // settle the last frame cut: observations.cut; what the spec asked against what it got, per skill,
12
13
  // contact and goal: qa/asked.js observeAsked), and the timeline and the
13
14
  // requested probes read the result.
@@ -33,6 +34,7 @@ import { observeWrist, WRIST_UNITS } from "./qa/wrist-obs.js";
33
34
  import { observeLoad, LOAD_UNITS } from "./load.js";
34
35
  import { observeLife, LIFE_UNITS } from "./qa/motion-life.js";
35
36
  import { observeGait, GAIT_UNITS } from "./qa/gait-observe.js";
37
+ import { springPivots } from "./qa/spring-pivot.js";
36
38
  import { planWalks, applyWalkPlans } from "./walk-plan.js";
37
39
  import { relaxSpec } from "./lenient.js";
38
40
  import { observeAsked } from "./qa/asked.js";
@@ -140,6 +142,8 @@ function compilePrepared(prepared, { sk, scene }, opts, notes, refusals) {
140
142
  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}`);
141
143
  if (placed.length) observations.placed = placed;
142
144
  if (plans.length) observations.planned = plans.map(p => p.note);
145
+ const pivots = springPivots(out);
146
+ if (pivots) observations.spring = pivots;
143
147
  const got = observeAsked(out, { used, use: resolved?.use, checks: measured.checks, goals: measured.goals, results: qa.goals });
144
148
  if (got) observations.asked = got;
145
149
  const timeline = timelineOf(out, { used, spec: expanded }), probes = asked && runProbes(out, asked, { spec: expanded, sk, scene, notes });