cozyclay 1.9.0 → 2.0.0

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 (286) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/bin/agent/agent-routes.mjs +74 -70
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/cozyclay.mjs +26 -8
  10. package/bin/live/cli.mjs +22 -3
  11. package/bin/mcp-runtime.mjs +35 -20
  12. package/bin/telemetry-state.mjs +5 -2
  13. package/bin/update-check.mjs +15 -0
  14. package/dist/app/index.html +6 -6
  15. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  16. package/dist/assets/app-C1sdVbg4.css +1 -0
  17. package/dist/assets/app-CA55tcI3.js +4876 -0
  18. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  19. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  20. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  21. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  22. package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
  23. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  24. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  25. package/dist/assets/workflow-dNPGY9xz.js +179 -0
  26. package/dist/cozyclay-package.json +1 -1
  27. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  28. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  29. package/dist/fonts/README.md +13 -9
  30. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  31. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  33. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  35. package/dist/index.html +7 -7
  36. package/dist/privacy/index.html +13 -8
  37. package/dist/sitemap.xml +6 -6
  38. package/dist/workflow/index.html +5 -5
  39. package/mcp/LIVE-PROTOCOL.md +1 -1
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +224 -377
  44. package/package.json +2 -1
  45. package/src/App.jsx +1273 -9075
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/collision-blockers.js +9 -3
  51. package/src/ardy/fix-collisions.js +10 -66
  52. package/src/ardy/ground.js +4 -4
  53. package/src/ardy/ik-drag.js +101 -0
  54. package/src/ardy/ik-key-json.js +43 -0
  55. package/src/ardy/ik.js +122 -76
  56. package/src/ardy/physics-panel.css +2 -0
  57. package/src/ardy/physics-panel.jsx +17 -5
  58. package/src/ardy/platform-fit-panel.css +64 -0
  59. package/src/ardy/platform-fit-panel.jsx +40 -0
  60. package/src/ardy/platform-fit.js +326 -0
  61. package/src/ardy/playback.js +166 -20
  62. package/src/ardy/range-pin.js +299 -0
  63. package/src/ardy/timeline-coordinates.js +26 -0
  64. package/src/ardy/timeline.css +1199 -0
  65. package/src/ardy/timeline.jsx +149 -41
  66. package/src/ardy/waypoints.js +2 -2
  67. package/src/asset-pane.css +462 -0
  68. package/src/asset-pane.jsx +461 -232
  69. package/src/command-bus.js +349 -0
  70. package/src/commands/ai.js +31 -0
  71. package/src/commands/cast.js +140 -0
  72. package/src/commands/elements/character.js +13 -0
  73. package/src/commands/elements/motion.js +14 -0
  74. package/src/commands/elements/object.js +22 -0
  75. package/src/commands/elements/scene.js +16 -0
  76. package/src/commands/elements/shot.js +9 -0
  77. package/src/commands/elements/stage.js +11 -0
  78. package/src/commands/elements.js +155 -0
  79. package/src/commands/export.js +20 -0
  80. package/src/commands/index.js +40 -0
  81. package/src/commands/motion.js +231 -0
  82. package/src/commands/objects.js +174 -0
  83. package/src/commands/project.js +53 -0
  84. package/src/commands/scene.js +81 -0
  85. package/src/commands/shared.js +19 -0
  86. package/src/commands/shot.js +103 -0
  87. package/src/commands/stage.js +17 -0
  88. package/src/commands/view.js +42 -0
  89. package/src/document-store.js +144 -0
  90. package/src/domains/cast.js +1007 -0
  91. package/src/domains/motion.js +3219 -0
  92. package/src/domains/objects.js +932 -0
  93. package/src/domains/scenes.js +817 -0
  94. package/src/domains/shots.js +404 -0
  95. package/src/domains/stage.js +93 -0
  96. package/src/dualview.jsx +113 -57
  97. package/src/facing-marks.js +3 -0
  98. package/src/first-success-guide.jsx +14 -14
  99. package/src/grid-view.js +18 -5
  100. package/src/hierarchy-model.js +24 -16
  101. package/src/hierarchy-panel.css +386 -0
  102. package/src/hierarchy-panel.jsx +150 -21
  103. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  104. package/src/i2v-motion-studio.jsx +180 -0
  105. package/src/ik-camera.js +3 -0
  106. package/src/main.jsx +6 -0
  107. package/src/motion/generation.js +105 -0
  108. package/src/motion-readiness-ui.jsx +4 -4
  109. package/src/motion-readiness.js +11 -0
  110. package/src/motion-trail.js +366 -9
  111. package/src/object-gizmo.jsx +7 -1
  112. package/src/otio.js +2 -1
  113. package/src/panels/CameraPanel.jsx +60 -0
  114. package/src/panels/CharacterTransformPanel.jsx +48 -0
  115. package/src/panels/EnvironmentPanel.jsx +40 -0
  116. package/src/panels/Foldout.jsx +32 -0
  117. package/src/panels/LightPanel.jsx +24 -0
  118. package/src/panels/ObjectTransformPanel.jsx +554 -0
  119. package/src/panels/PosePanel.jsx +97 -0
  120. package/src/panels/ProjectPanel.jsx +33 -0
  121. package/src/panels/PromptBlocksPanel.jsx +442 -0
  122. package/src/panels/PropsPanel.jsx +70 -0
  123. package/src/panels/ReferenceImageField.jsx +91 -0
  124. package/src/panels/RigControlPanel.jsx +78 -0
  125. package/src/panels/RigPanel.jsx +37 -0
  126. package/src/panels/SubjectBox.jsx +47 -0
  127. package/src/panels/SubjectsPanel.jsx +39 -0
  128. package/src/panels/VideoCapturePanel.jsx +180 -0
  129. package/src/panels/details.css +1171 -0
  130. package/src/panels/motion.css +197 -0
  131. package/src/panels/pose.css +39 -0
  132. package/src/planview.jsx +60 -31
  133. package/src/posestudio.jsx +59 -4
  134. package/src/project-browser.css +1073 -0
  135. package/src/project-browser.jsx +284 -123
  136. package/src/range-pin-object-transform.js +19 -0
  137. package/src/range-pin-panel.css +518 -0
  138. package/src/range-pin-panel.jsx +344 -0
  139. package/src/result-modal.jsx +5 -5
  140. package/src/room.jsx +79 -51
  141. package/src/scene-objects.js +37 -1
  142. package/src/scenes.js +5 -0
  143. package/src/semantic-edit.js +2 -0
  144. package/src/settings-menu.jsx +39 -105
  145. package/src/shell/BottomDock.jsx +450 -0
  146. package/src/shell/DetailsSlot.jsx +513 -0
  147. package/src/shell/LibrarySlot.jsx +86 -0
  148. package/src/shell/MenuBar.jsx +601 -0
  149. package/src/shell/OutlinerSlot.jsx +50 -0
  150. package/src/shell/PreferencesDialog.jsx +538 -0
  151. package/src/shell/PreferencesSlot.jsx +40 -0
  152. package/src/shell/StatusBar.jsx +125 -0
  153. package/src/shell/StudioShell.jsx +92 -0
  154. package/src/shell/TopBar.jsx +129 -0
  155. package/src/shell/ViewportToolbar.jsx +656 -0
  156. package/src/shell/agent-glass.css +296 -0
  157. package/src/shell/dock.css +93 -0
  158. package/src/shell/glass-regions.css +985 -0
  159. package/src/shell/glass.css +687 -0
  160. package/src/shell/log-store.js +52 -0
  161. package/src/shell/mode.css +9 -0
  162. package/src/shell/preferences.css +651 -0
  163. package/src/shell/shell.css +282 -0
  164. package/src/shell/studio-shell-context.js +19 -0
  165. package/src/shell/topbar.css +435 -0
  166. package/src/shell/viewport.css +451 -0
  167. package/src/store/authored-intent.js +22 -0
  168. package/src/store/runtime-adapters.js +92 -0
  169. package/src/store/scene-stage.js +21 -0
  170. package/src/store/use-document-store.js +14 -0
  171. package/src/studio-actions.js +210 -0
  172. package/src/studio-agent-commands.js +41 -271
  173. package/src/studio-agent-context.js +39 -10
  174. package/src/studio-agent-motion.js +122 -373
  175. package/src/studio-agent-protocol.js +67 -31
  176. package/src/studio-app-binding.js +379 -0
  177. package/src/studio-contact-sheet.js +73 -0
  178. package/src/studio-elements.js +58 -51
  179. package/src/styles/themes.css +200 -0
  180. package/src/styles/tokens.css +69 -0
  181. package/src/styles.css +460 -1742
  182. package/src/theme.js +44 -0
  183. package/src/timeline-extent.js +16 -0
  184. package/src/trail-key-conflicts.js +39 -0
  185. package/src/trail-pick.js +59 -0
  186. package/src/ui.jsx +8 -8
  187. package/src/use-case-question.jsx +66 -0
  188. package/src/workflow/AgentPanel.jsx +82 -69
  189. package/src/workflow/agent-client.js +12 -2
  190. package/src/workflow/agent-panel.css +34 -32
  191. package/src/workflow/cozy-scene-node.css +2 -0
  192. package/src/workflow/workflow.css +2 -0
  193. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  194. package/tools/ardy/bridge.mjs +164 -146
  195. package/tools/ardy/visual-qa.mjs +5 -5
  196. package/tools/bench/EXP3.md +61 -0
  197. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  198. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  199. package/tools/bench/cclay_bench_runner.py +42 -0
  200. package/tools/bench/cube-contact.mjs +162 -0
  201. package/tools/bench/exp3.mjs +152 -0
  202. package/tools/bench/extract-bench-lib.mjs +216 -0
  203. package/tools/bench/extract-bench.mjs +302 -0
  204. package/tools/bench/fal-generate.mjs +88 -0
  205. package/tools/bench/fit/README.md +189 -0
  206. package/tools/bench/fit/camera.mjs +59 -0
  207. package/tools/bench/fit/contact.mjs +419 -0
  208. package/tools/bench/fit/footlock.mjs +234 -0
  209. package/tools/bench/fit/motion.mjs +85 -0
  210. package/tools/bench/fit/pin.mjs +27 -0
  211. package/tools/bench/fit/remote.mjs +105 -0
  212. package/tools/bench/fit-bench.mjs +126 -0
  213. package/tools/bench/fit-sanity.mjs +45 -0
  214. package/tools/bench/metrics.mjs +313 -0
  215. package/tools/bench/obs/depth.mjs +258 -0
  216. package/tools/bench/obs/extrinsics.mjs +175 -0
  217. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  218. package/tools/bench/obs/ground.mjs +294 -0
  219. package/tools/bench/obs/heading.mjs +151 -0
  220. package/tools/bench/obs/ladder.mjs +555 -0
  221. package/tools/bench/obs/mannequin-betas.json +124 -0
  222. package/tools/bench/obs/remote.mjs +83 -0
  223. package/tools/bench/obs/rest_joints.py +52 -0
  224. package/tools/bench/obs/ybot-targets.mjs +49 -0
  225. package/tools/bench/obs-bench.mjs +520 -0
  226. package/tools/bench/score.mjs +343 -0
  227. package/tools/bench/summarize.mjs +86 -0
  228. package/tools/dev/pages/privacy.html +13 -8
  229. package/tools/gt-render/browser.mjs +159 -0
  230. package/tools/gt-render/camera-math.mjs +276 -0
  231. package/tools/gt-render/page.mjs +267 -0
  232. package/tools/gt-render/render.mjs +514 -0
  233. package/tools/gt-render/scene-box.mjs +31 -0
  234. package/tools/gt-render/take-transform.mjs +87 -0
  235. package/tools/morphgs/assets/gen_truth.py +39 -0
  236. package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
  237. package/tools/morphgs/assets/playback-check.mjs +30 -0
  238. package/tools/morphgs/demo-gate.sh +26 -0
  239. package/tools/morphgs/fbx2morphgs.mjs +68 -0
  240. package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
  241. package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
  242. package/tools/morphgs/setup-on-cluster.sh +126 -0
  243. package/tools/qa/css-rule-usage.mjs +283 -0
  244. package/tools/qa/studio-control-count.mjs +70 -32
  245. package/tools/run-tests.mjs +134 -12
  246. package/tools/track/DESIGN.md +513 -0
  247. package/tools/track/backfill-provenance.mjs +116 -0
  248. package/tools/track/budget.mjs +8 -0
  249. package/tools/track/check-rig.mjs +209 -0
  250. package/tools/track/diagnostics.schema.json +60 -0
  251. package/tools/track/export-rig.mjs +256 -0
  252. package/tools/track/fallback.mjs +7 -0
  253. package/tools/track/fk-parity-fixture.mjs +161 -0
  254. package/tools/track/gate.mjs +349 -0
  255. package/tools/track/masks.mjs +318 -0
  256. package/tools/track/metrics.mjs +201 -0
  257. package/tools/track/publish-obs.mjs +108 -0
  258. package/tools/track/py/check_env.py +61 -0
  259. package/tools/track/py/eval_lr.py +229 -0
  260. package/tools/track/py/lr_viterbi.py +294 -0
  261. package/tools/track/py/masks.py +405 -0
  262. package/tools/track/py/objective.py +382 -0
  263. package/tools/track/py/rig.py +508 -0
  264. package/tools/track/py/scene.py +326 -0
  265. package/tools/track/py/test_joint_indices.py +123 -0
  266. package/tools/track/py/test_lr_viterbi.py +195 -0
  267. package/tools/track/py/test_masks.py +229 -0
  268. package/tools/track/py/test_rig.py +288 -0
  269. package/tools/track/py/test_scene.py +354 -0
  270. package/tools/track/py/test_track.py +328 -0
  271. package/tools/track/py/track.py +411 -0
  272. package/tools/track/remote.mjs +140 -0
  273. package/tools/track/rig-dump.mjs +227 -0
  274. package/tools/track/run-box-tests.mjs +14 -0
  275. package/tools/track/run-box.mjs +168 -0
  276. package/tools/track/setup-box.sh +59 -0
  277. package/tools/track/study-2d.mjs +728 -0
  278. package/dist/assets/analytics-B1hnH66c.js +0 -1
  279. package/dist/assets/app-BWusbqgO.js +0 -4861
  280. package/dist/assets/app-qKDo4PBX.css +0 -1
  281. package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
  282. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  283. package/dist/assets/workflow-CVwzMhz2.js +0 -179
  284. package/dist/assets/workflow-DsudxKHF.css +0 -1
  285. package/src/fal-motion-studio.jsx +0 -180
  286. package/src/scene-history.js +0 -129
@@ -0,0 +1,419 @@
1
+ import { CSKEL27_PARENTS } from "../../../src/ardy/cskel27.js";
2
+ import { deriveBoneOffsets, forwardKinematics, globalRotations, matMul, matTranspose, quatToMat } from "../../../src/ardy/convert.js";
3
+ import { add, bodyOffsets, cloneMotion, jointsAt, localsAt, matVec, shiftFrame, smoothstep, sub, vec } from "./motion.mjs";
4
+
5
+ const FEET = [21, 22, 25, 26], HANDS = [10, 16];
6
+ const EPS = 1e-5;
7
+
8
+ export function validateBoxes(boxes) {
9
+ if (!Array.isArray(boxes) || boxes.some(b => ![b?.min, b?.max].every(v => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite)) || b.min.some((v, k) => v >= b.max[k]))) {
10
+ throw new Error("scene: boxes must be {min:[x,y,z],max:[x,y,z]} with min < max");
11
+ }
12
+ return boxes;
13
+ }
14
+
15
+ /** Smallest translation of an interior POINT to a box face. Boundary is not
16
+ * penetration. Used for hand contact proximity; no skin radius is implied. */
17
+ export function boxPenetration(point, box) {
18
+ if (point.some((v, k) => v <= box.min[k] || v >= box.max[k])) return [0, 0, 0];
19
+ const candidates = box.min.flatMap((lo, k) => [[k, lo - point[k]], [k, box.max[k] - point[k]]]);
20
+ candidates.sort((a, b) => Math.abs(a[1]) - Math.abs(b[1]));
21
+ const delta = [0, 0, 0]; delta[candidates[0][0]] = candidates[0][1];
22
+ return delta;
23
+ }
24
+
25
+ /** Open-interior segment/AABB slab intersection catches a bone crossing a
26
+ * box even when both of its joints lie outside (point-only tests miss it). */
27
+ export function segmentPenetratesBox(a, b, box) {
28
+ let near = 0, far = 1;
29
+ for (let k = 0; k < 3; k++) {
30
+ const d = b[k] - a[k];
31
+ if (Math.abs(d) < 1e-12) {
32
+ if (a[k] <= box.min[k] || a[k] >= box.max[k]) return false;
33
+ } else {
34
+ const t0 = (box.min[k] - a[k]) / d, t1 = (box.max[k] - a[k]) / d;
35
+ near = Math.max(near, Math.min(t0, t1)); far = Math.min(far, Math.max(t0, t1));
36
+ if (near >= far) return false;
37
+ }
38
+ }
39
+ return near < far;
40
+ }
41
+
42
+ function intersects(points, box) {
43
+ return CSKEL27_PARENTS.some((p, j) => p !== null && segmentPenetratesBox(points[p], points[j], box));
44
+ }
45
+
46
+ /** Conservative rigid skeleton projection. On collision, clear the whole
47
+ * skeleton along its nearest feasible separating axis, not independent
48
+ * joint pushes (which would stretch bones). Floor wins over downward pushes.
49
+ * This tests joint/bone segments, NOT mesh/capsule thickness or self-collision.
50
+ * Multiple mutually obstructing boxes fail explicitly instead of silently
51
+ * emitting penetration. Cube/mesh refinement is a separate experiment. */
52
+ export function resolveScene(points, boxes, { floorY = 0 } = {}) {
53
+ let placed = points.map(p => p.slice()), total = [0, 0, 0];
54
+ for (let pass = 0; pass < 32; pass++) {
55
+ const floorLift = Math.max(0, floorY + EPS - Math.min(...FEET.map(j => placed[j][1])));
56
+ if (floorLift) { const d = [0, floorLift, 0]; placed = placed.map(p => add(p, d)); total = add(total, d); }
57
+ const box = boxes.find(b => intersects(placed, b));
58
+ if (!box) return { delta: total, positions: placed };
59
+ const candidates = [];
60
+ for (let k = 0; k < 3; k++) {
61
+ const lo = Math.min(...placed.map(p => p[k])), hi = Math.max(...placed.map(p => p[k]));
62
+ for (const amount of [box.min[k] - hi - EPS, box.max[k] - lo + EPS]) {
63
+ if (k === 1 && Math.min(...FEET.map(j => placed[j][1])) + amount < floorY) continue;
64
+ const delta = [0, 0, 0]; delta[k] = amount; candidates.push(delta);
65
+ }
66
+ }
67
+ candidates.sort((a, b) => Math.hypot(...a) - Math.hypot(...b));
68
+ placed = placed.map(p => add(p, candidates[0])); total = add(total, candidates[0]);
69
+ }
70
+ throw new Error("scene: no collision-free rigid placement after 32 projections; constraints conflict");
71
+ }
72
+
73
+ export function penetrates(points, boxes, floorY = 0) {
74
+ return Math.min(...FEET.map(j => points[j][1])) < floorY - EPS || boxes.some(b => intersects(points, b));
75
+ }
76
+
77
+ /** Scene corrections that are smooth in time. Per frame: the smallest push
78
+ * that clears the skeleton (its nearest face may change between frames). Then
79
+ * each signed axis component
80
+ * is dilated by `radius` frames and Gaussian-smoothed (sigma = radius / 3):
81
+ * the result is at least the needed push at every frame, so it stays
82
+ * collision-free, and it ramps in and out instead of snapping. The per-frame
83
+ * solver picks each frame's nearest face from scratch and teleported the body
84
+ * whenever that face changed (fal stepup: 0.76 m in one frame). */
85
+ export function continuousSceneDeltas(motion, boxes, radius) {
86
+ const n = motion.frames, shifted = (f, delta) => jointsAt(motion, f).map(p => add(p, delta));
87
+ const blocked = (f, delta) => penetrates(shifted(f, delta), boxes);
88
+ const raw = Array.from({ length: n }, (_, f) => (blocked(f, [0, 0, 0]) ? resolveScene(shifted(f, [0, 0, 0]), boxes).delta : [0, 0, 0]));
89
+ const sigma = Math.max(1, radius / 3), kernel = Array.from({ length: 2 * radius + 1 }, (_, k) => Math.exp(-0.5 * ((k - radius) / sigma) ** 2));
90
+ const at = (x, f) => x[Math.min(n - 1, Math.max(0, f))];
91
+ const ramp = (x) => {
92
+ const dilated = x.map((_, f) => { let m = 0; for (let k = -radius; k <= radius; k++) m = Math.max(m, at(x, f + k)); return m; });
93
+ return dilated.map((_, f) => { let s = 0, t = 0; for (let k = -radius; k <= radius; k++) { s += kernel[k + radius] * at(dilated, f + k); t += kernel[k + radius]; } return s / t; });
94
+ };
95
+ const axes = [0, 1, 2].map(k => {
96
+ const up = ramp(raw.map(d => Math.max(0, d[k]))), down = ramp(raw.map(d => Math.max(0, -d[k])));
97
+ return up.map((v, f) => v - down[f]);
98
+ });
99
+ // Where opposite pushes on one axis overlap, the ramp can fall short: top it
100
+ // up by the smallest extra push (small, since the ramp already carries most).
101
+ return raw.map((_, f) => { const smooth = [axes[0][f], axes[1][f], axes[2][f]]; return blocked(f, smooth) ? add(smooth, resolveScene(shifted(f, smooth), boxes).delta) : smooth; });
102
+ }
103
+
104
+ // Only these descendants can be moved without moving the torso/attachment.
105
+ // [hip/shoulder, knee/elbow, ankle/wrist, ...toe/finger joints]
106
+ const LIMBS = [[19, 20, 21, 22], [23, 24, 25, 26], [8, 9, 10, 11, 12], [14, 15, 16, 17, 18]];
107
+ const LIMB_BONES = new Set(LIMBS.flatMap(chain => chain.slice(1)));
108
+ const dot = (a, b) => a.reduce((s, v, k) => s + v * b[k], 0);
109
+ const scale = (v, s) => v.map(x => x * s);
110
+ const unit = v => scale(v, 1 / Math.hypot(...v));
111
+ const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
112
+ const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
113
+
114
+ /** Forbidden ray offsets for a whole BONE, not just its endpoints. Clip the
115
+ * convex polygon (t,s) with a + t*(b-a) + s*ray inside the six box planes,
116
+ * then project onto s. A small margin survives Float32 FK/writeback. */
117
+ function boneRayInterval(a, b, ray, box) {
118
+ const corners = Array.from({ length: 8 }, (_, i) => box.min.map((v, k) => i & (1 << k) ? box.max[k] : v));
119
+ const extent = 1 + Math.max(...corners.flatMap(p => [Math.hypot(...sub(p, a)), Math.hypot(...sub(p, b))]));
120
+ let polygon = [[0, -extent], [1, -extent], [1, extent], [0, extent]];
121
+ for (let k = 0; k < 3; k++) for (const sign of [-1, 1]) {
122
+ const face = sign > 0 ? box.max[k] + EPS : box.min[k] - EPS;
123
+ const distance = p => sign * (a[k] + p[0] * (b[k] - a[k]) + p[1] * ray[k] - face);
124
+ const clipped = [];
125
+ for (let i = 0; i < polygon.length; i++) {
126
+ const p = polygon[i], q = polygon[(i + 1) % polygon.length], dp = distance(p), dq = distance(q);
127
+ if (dp <= 0) clipped.push(p);
128
+ if ((dp < 0 && dq > 0) || (dp > 0 && dq < 0)) clipped.push(p.map((v, j) => v + (q[j] - v) * dp / (dp - dq)));
129
+ }
130
+ polygon = clipped;
131
+ if (!polygon.length) return null;
132
+ }
133
+ return [Math.min(...polygon.map(p => p[1])), Math.max(...polygon.map(p => p[1]))];
134
+ }
135
+
136
+ /** Ray offsets s at which one frame's TORSO (non-limb bones) crosses a box.
137
+ * The torso MUST be clear: limb IK cannot repair its penetration. Swept
138
+ * interval endpoints avoid a depth grid missing a narrow feasible corridor. */
139
+ function torsoRayIntervals(points, ray, boxes) {
140
+ const intervals = [];
141
+ for (let j = 1; j < 27; j++) if (!LIMB_BONES.has(j)) for (const box of boxes) {
142
+ const interval = boneRayInterval(points[CSKEL27_PARENTS[j]], points[j], ray, box);
143
+ if (interval) intervals.push(interval);
144
+ }
145
+ return intervals;
146
+ }
147
+
148
+ /** Smallest t >= t0 (t = sign * s) outside every forbidden torso interval. */
149
+ function clearFrom(intervals, sign, t0) {
150
+ let t = t0;
151
+ for (let moved = true; moved;) {
152
+ moved = false;
153
+ for (const [lo, hi] of intervals) {
154
+ const a = sign > 0 ? lo : -hi, b = sign > 0 ? hi : -lo;
155
+ if (t >= a - EPS && t <= b + EPS) { t = b + 2 * EPS; moved = true; }
156
+ }
157
+ }
158
+ return t;
159
+ }
160
+
161
+ /** Exact upper envelope max_g (required[g] - path budget g..f): the smallest
162
+ * curve at or above `required` whose change on edge f-1..f is <= budget[f]. */
163
+ function slopeEnvelope(required, budget) {
164
+ const t = required.slice();
165
+ for (let f = 1; f < t.length; f++) t[f] = Math.max(t[f], t[f - 1] - budget[f]);
166
+ for (let f = t.length - 2; f >= 0; f--) t[f] = Math.max(t[f], t[f + 1] - budget[f + 1]);
167
+ return t;
168
+ }
169
+
170
+ /** Time-varying offset for one sign: per frame the minimal torso-clear
171
+ * t_req(f) >= 0 (0 when the frame needs none), then its slope-limited upper
172
+ * envelope, so t is 0 far from any collision and ramps in/out. The vector
173
+ * correction step |s_f r_f - s_{f-1} r_{f-1}| <= |ds| + |s| |dr|, so each
174
+ * edge's slope budget is rayMaxStepM minus what the turning ray already uses
175
+ * at the peak offset. An envelope value may land inside another torso
176
+ * interval; raise that frame's requirement past it and rebuild. Requirements
177
+ * only increase through a finite set of interval ends, so this terminates.
178
+ * Returns null when the sign cannot stay within the caps/budget. */
179
+ function signedRayOffsets(intervals, sign, caps, turns, rayMaxStepM) {
180
+ const required = intervals.map(i => clearFrom(i, sign, 0));
181
+ for (;;) {
182
+ const peak = Math.max(...required), budget = turns.map(d => rayMaxStepM - peak * d - 1e-9);
183
+ if (budget.some((b, f) => f && !(b > 0))) return null;
184
+ const t = slopeEnvelope(required, budget);
185
+ if (t.some((v, f) => v > caps[f])) return null;
186
+ let raised = false;
187
+ for (let f = 0; f < t.length; f++) {
188
+ const clear = clearFrom(intervals[f], sign, t[f]);
189
+ if (clear > t[f]) { required[f] = clear; raised = true; }
190
+ }
191
+ if (!raised) return t.map(v => sign * v);
192
+ }
193
+ }
194
+
195
+ function perpendicular(v) {
196
+ const axis = Math.abs(v[0]) < 0.8 ? [1, 0, 0] : [0, 1, 0];
197
+ return unit(sub(axis, scale(v, dot(axis, v))));
198
+ }
199
+
200
+ function swingRotation(from, to) {
201
+ const a = unit(from), b = unit(to), cosine = clamp(dot(a, b), -1, 1);
202
+ return cosine < -1 + 1e-10 ? quatToMat([0, ...perpendicular(a)]) : quatToMat([1 + cosine, ...cross(a, b)]);
203
+ }
204
+
205
+ /** Analytic two-bone IK; only the chain changes. Preserve the effector's
206
+ * world orientation so a corrected ankle does not tip its toe into the floor
207
+ * (and a wrist does not swing its fingers back through the box). */
208
+ function limbPose(locals, points, globals, offsets, chain, target, poleAngle) {
209
+ const [a, b, c] = chain, origin = points[a], toward = sub(target, origin);
210
+ const l0 = Math.hypot(...offsets[b]), l1 = Math.hypot(...offsets[c]);
211
+ const distance = Math.hypot(...toward), direction = distance > EPS ? scale(toward, 1 / distance) : unit(sub(points[c], origin));
212
+ const reach = clamp(distance, Math.abs(l0 - l1) + EPS, l0 + l1 - EPS);
213
+ const along = (l0 * l0 + reach * reach - l1 * l1) / (2 * reach);
214
+ const height = Math.sqrt(Math.max(0, l0 * l0 - along * along));
215
+ const oldBend = sub(points[b], origin), projected = sub(oldBend, scale(direction, dot(oldBend, direction)));
216
+ const bend = Math.hypot(...projected) > EPS ? unit(projected) : perpendicular(direction);
217
+ const pole = add(scale(bend, Math.cos(poleAngle)), scale(cross(direction, bend), Math.sin(poleAngle)));
218
+ const elbow = add(origin, add(scale(direction, along), scale(pole, height)));
219
+ const end = add(origin, scale(direction, reach));
220
+ const upper = matMul(swingRotation(sub(points[b], origin), sub(elbow, origin)), globals[a]);
221
+ const movedLower = matMul(upper, locals[b]);
222
+ const lower = matMul(swingRotation(matVec(movedLower, offsets[c]), sub(end, elbow)), movedLower);
223
+ const solved = locals.slice();
224
+ solved[a] = matMul(matTranspose(globals[CSKEL27_PARENTS[a]]), upper);
225
+ solved[b] = matMul(matTranspose(upper), lower);
226
+ solved[c] = matMul(matTranspose(lower), globals[c]);
227
+ return solved;
228
+ }
229
+
230
+ function limbBlocked(points, chain, boxes) {
231
+ return chain.slice(1).some(j => (FEET.includes(j) && points[j][1] < -EPS) || boxes.some(box => segmentPenetratesBox(points[CSKEL27_PARENTS[j]], points[j], box)));
232
+ }
233
+
234
+ function clearLimb(locals, points, offsets, chain, boxes) {
235
+ const end = points[chain[2]], tips = chain.slice(2), globals = globalRotations(locals);
236
+ // Each face contributes an effector coordinate which clears ALL distal
237
+ // joints. Cartesian combinations include edges/corners and multiple boxes.
238
+ const coordinates = end.map((v, k) => {
239
+ const relative = tips.map(j => points[j][k] - end[k]);
240
+ return [v, ...boxes.flatMap(box => [box.min[k] - Math.max(...relative) - 4 * EPS, box.max[k] - Math.min(...relative) + 4 * EPS])];
241
+ });
242
+ const floor = FEET.includes(chain[2]) ? EPS - Math.min(...tips.map(j => points[j][1] - end[1])) : -Infinity;
243
+ coordinates[1] = [...new Set(coordinates[1].map(y => Math.max(y, floor)))];
244
+ const targets = coordinates[0].flatMap(x => coordinates[1].flatMap(y => coordinates[2].map(z => [x, y, z])));
245
+ targets.sort((a, b) => Math.hypot(...sub(a, end)) - Math.hypot(...sub(b, end)));
246
+ let best = null;
247
+ for (const target of targets) for (let i = 0; i < 16; i++) {
248
+ const solved = limbPose(locals, points, globals, offsets, chain, target, i * Math.PI / 8);
249
+ const positions = forwardKinematics(solved, offsets, points[0]);
250
+ if (limbBlocked(positions, chain, boxes)) continue;
251
+ const cost = chain.slice(1).reduce((s, j) => s + dot(sub(positions[j], points[j]), sub(positions[j], points[j])), 0);
252
+ if (!best || cost < best.cost) best = { locals: solved, positions, cost };
253
+ }
254
+ return best;
255
+ }
256
+
257
+ /** cameraOrigin opts into ray + limb correction instead of rigid stance
258
+ * locks. Those locks (including floor lifts) would violate the root's camera
259
+ * ray and reintroduce the glide this path is meant to remove. s(f) is the
260
+ * slope-limited envelope of each frame's minimal torso-clear offset: 0 where
261
+ * no correction is needed (so known A/B ends stay put when collisions are
262
+ * elsewhere), one sign for the clip (either may be chosen; the smaller peak
263
+ * wins). A single clip-level s contradicted those ends. Bound BOTH scalar
264
+ * motion and vector correction as rays turn. Residual limb penetration is
265
+ * fixed by two-bone IK; an impossible torso placement or limb solve fails
266
+ * explicitly. */
267
+ function fitRayContacts(motion, boxes, cameraOrigin, rayMaxOffsetM, rayMaxStepM) {
268
+ if (!Array.isArray(cameraOrigin) || cameraOrigin.length !== 3 || !cameraOrigin.every(Number.isFinite)) throw new Error("cameraOrigin must be a finite world [x,y,z]");
269
+ if (!(Number.isFinite(rayMaxOffsetM) && rayMaxOffsetM >= 0 && Number.isFinite(rayMaxStepM) && rayMaxStepM > 0)) throw new Error("rayMaxOffsetM must be nonnegative and rayMaxStepM positive");
270
+ const out = cloneMotion(motion), n = motion.frames;
271
+ // G5 independently filters joints and rotations; its posed joints may
272
+ // already disagree with FK. Solve against the actual playback skeleton.
273
+ const offsets = motion.boneScale ? bodyOffsets(motion.boneScale) : deriveBoneOffsets(jointsAt(motion, 0), localsAt(motion, 0));
274
+ const locals = Array.from({ length: n }, (_, f) => localsAt(motion, f));
275
+ const points = locals.map((r, f) => forwardKinematics(r, offsets, vec(motion.rootPos, f * 3)));
276
+ const rays = points.map(p => {
277
+ const v = sub(p[0], cameraOrigin);
278
+ if (Math.hypot(...v) < EPS) throw new Error("scene ray: root coincides with cameraOrigin");
279
+ return unit(v);
280
+ });
281
+ let inputFkResidualM = 0;
282
+ for (let f = 0; f < n; f++) {
283
+ const input = jointsAt(motion, f);
284
+ for (let j = 0; j < 27; j++) inputFkResidualM = Math.max(inputFkResidualM, Math.hypot(...sub(points[f][j], input[j])));
285
+ }
286
+ // Do not translate through the camera, even if a large limit is supplied.
287
+ const caps = points.map(p => Math.min(rayMaxOffsetM, Math.hypot(...sub(p[0], cameraOrigin)) - EPS));
288
+ const turns = rays.map((r, f) => (f ? Math.hypot(...sub(r, rays[f - 1])) : 0));
289
+ const intervals = points.map((p, f) => torsoRayIntervals(p, rays[f], boxes));
290
+ const peak = x => Math.max(...x.map(Math.abs)), total = x => x.reduce((a, v) => a + Math.abs(v), 0);
291
+ const options = [1, -1].map(sign => signedRayOffsets(intervals, sign, caps, turns, rayMaxStepM)).filter(Boolean);
292
+ options.sort((a, b) => peak(a) - peak(b) || total(a) - total(b));
293
+ if (!options.length) throw new Error(`scene ray: no torso-clear ray offset within ${rayMaxOffsetM.toFixed(3)} m and ${rayMaxStepM.toFixed(3)} m/frame; ray limits/scene conflict`);
294
+ const s = options[0], limbFixFrames = [];
295
+ let sceneOverrides = 0, limbFixes = 0, sceneMaxStepM = 0, maxRootStepM = 0, rayMaxStep = 0;
296
+ for (let f = 0; f < n; f++) {
297
+ const delta = scale(rays[f], s[f]);
298
+ out.rootPos.set(add(points[f][0], delta), f * 3);
299
+ let positions = points[f].map(p => add(p, delta)), rotations = locals[f], fixed = false;
300
+ for (const chain of LIMBS) {
301
+ if (!limbBlocked(positions, chain, boxes)) continue;
302
+ const solved = clearLimb(rotations, positions, offsets, chain, boxes);
303
+ if (!solved) throw new Error(`scene ray: no collision-free limb IK at frame ${f}, joint ${chain[0]}; root was not lifted`);
304
+ rotations = solved.locals; positions = solved.positions; fixed = true; limbFixes++;
305
+ }
306
+ if (fixed) limbFixFrames.push(f);
307
+ if (fixed || Math.abs(s[f]) > EPS) sceneOverrides++;
308
+ for (let j = 0; j < 27; j++) out.rotMats.set(rotations[j].flat(), (f * 27 + j) * 9);
309
+ // Recompute from the stored Float32 channels, not unquantized IK points.
310
+ positions = forwardKinematics(localsAt(out, f), offsets, vec(out.rootPos, f * 3));
311
+ if (penetrates(positions, boxes)) throw new Error(`scene ray: residual penetration after FK at frame ${f}`);
312
+ for (let j = 0; j < 27; j++) out.posedJoints.set(positions[j], (f * 27 + j) * 3);
313
+ if (f) {
314
+ sceneMaxStepM = Math.max(sceneMaxStepM, Math.hypot(...sub(delta, scale(rays[f - 1], s[f - 1]))));
315
+ rayMaxStep = Math.max(rayMaxStep, Math.abs(s[f] - s[f - 1]));
316
+ maxRootStepM = Math.max(maxRootStepM, Math.hypot(...sub(vec(out.rootPos, f * 3), vec(out.rootPos, (f - 1) * 3))));
317
+ }
318
+ }
319
+ return { motion: out, diagnostics: {
320
+ runs: 0, lockedFrames: 0, support: Array(n).fill(-1), maxLockResidualM: 0,
321
+ sceneOverrides, maxCorrectionM: peak(s), sceneMaxStepM, maxRootStepM,
322
+ rayOffset: { minM: Math.min(...s), maxM: Math.max(...s), meanM: s.reduce((a, v) => a + v, 0) / n, maxAbsM: peak(s), maxStepM: rayMaxStep, framesNonZero: s.filter(v => Math.abs(v) > EPS).length },
323
+ rayMaxOffsetM, rayMaxStepM, limbFixFrames, limbFixes, inputFkResidualM,
324
+ geometry: "cskel27 joint/bone segments; no skin radius", sceneSolver: "camera-ray",
325
+ solver: "slope-limited per-frame torso-clear camera-ray offset + two-bone limb IK; no rigid contact locks",
326
+ } };
327
+ }
328
+
329
+ function contactTarget(point, joint, boxes, height) {
330
+ if (FEET.includes(joint)) {
331
+ let y = 0;
332
+ for (const box of boxes) if (point[0] >= box.min[0] && point[0] <= box.max[0] && point[2] >= box.min[2] && point[2] <= box.max[2] && Math.abs(point[1] - box.max[1]) <= height) y = Math.max(y, box.max[1]);
333
+ return Math.abs(point[1] - y) <= height ? [point[0], y + EPS, point[2]] : null;
334
+ }
335
+ // A hand may support on any known box face; never invent a hand contact
336
+ // from a low percentile of its own trajectory when there is no surface.
337
+ for (const box of boxes) {
338
+ const clamped = point.map((x, k) => Math.max(box.min[k], Math.min(box.max[k], x)));
339
+ const target = add(clamped, boxPenetration(clamped, box));
340
+ if (Math.hypot(...sub(point, target)) <= height) return target;
341
+ }
342
+ return null;
343
+ }
344
+
345
+ /** F5 uses F4 motion + known floor/boxes ONLY. Low, slow runs nominate one
346
+ * support point (feet first, then a box-contact hand); rigid root offsets
347
+ * lock it without changing pose or bone lengths. Other simultaneous contacts
348
+ * are not an IK solve. Scene feasibility overrides pinning/locks; diagnostics
349
+ * expose every such override instead of claiming incompatible constraints.
350
+ */
351
+ export function fitContacts(motion, { boxes = [], contactHeight = 0.04, maxSpeed = 0.18, minStanceSeconds = 0.08, sceneSmoothSeconds = null, cameraOrigin = null, rayMaxOffsetM = 0.75, rayMaxStepM = 0.02 } = {}) {
352
+ validateBoxes(boxes);
353
+ if (![contactHeight, maxSpeed, minStanceSeconds].every(x => Number.isFinite(x) && x > 0)) throw new Error("contact thresholds must be positive");
354
+ if (cameraOrigin !== null) return fitRayContacts(motion, boxes, cameraOrigin, rayMaxOffsetM, rayMaxStepM);
355
+ const out = cloneMotion(motion), n = motion.frames, sites = [...FEET, ...HANDS];
356
+ const minimum = Math.max(2, Math.ceil(minStanceSeconds * motion.fps));
357
+ const targets = new Map(), candidates = new Map();
358
+ for (const j of sites) {
359
+ const track = Array.from({ length: n }, (_, f) => vec(motion.posedJoints, (f * 27 + j) * 3));
360
+ const target = track.map(p => contactTarget(p, j, boxes, contactHeight));
361
+ const eligible = target.map((p, f) => {
362
+ const lo = Math.max(0, f - 1), hi = Math.min(n - 1, f + 1);
363
+ const speed = Math.max(...[lo, hi].map(t => Math.hypot(...sub(track[f], track[t])) * motion.fps));
364
+ return !!p && speed <= maxSpeed;
365
+ });
366
+ const good = new Uint8Array(n);
367
+ for (let f = 0; f < n;) {
368
+ if (!eligible[f]) { f++; continue; }
369
+ const start = f; while (f < n && eligible[f]) f++;
370
+ if (f - start >= minimum) good.fill(1, start, f);
371
+ }
372
+ targets.set(j, target); candidates.set(j, good);
373
+ }
374
+ const support = new Int8Array(n).fill(-1);
375
+ for (let f = 0; f < n; f++) {
376
+ const previous = f ? support[f - 1] : -1;
377
+ support[f] = previous >= 0 && candidates.get(previous)[f] ? previous : (sites.find(j => candidates.get(j)[f]) ?? -1);
378
+ }
379
+ const offsets = new Array(n).fill(null), anchors = new Array(n).fill(null);
380
+ let carry = [0, 0, 0], runs = 0, lockedFrames = 0;
381
+ for (let f = 0; f < n;) {
382
+ const j = support[f];
383
+ if (j < 0) { f++; continue; }
384
+ const first = f, target = targets.get(j)[f];
385
+ // Keep accumulated horizontal travel across stance changes, but the
386
+ // surface owns contact height. A hand's authored face owns all axes.
387
+ const anchor = FEET.includes(j) ? [target[0] + carry[0], target[1], target[2] + carry[2]] : target;
388
+ while (f < n && support[f] === j) {
389
+ offsets[f] = sub(anchor, vec(motion.posedJoints, (f * 27 + j) * 3)); anchors[f] = anchor; f++;
390
+ }
391
+ carry = offsets[f - 1]; runs++; lockedFrames += f - first;
392
+ }
393
+ // Smooth offset bridges through flight/swing; no reset snap on release.
394
+ for (let f = 0; f < n;) {
395
+ if (offsets[f]) { f++; continue; }
396
+ const start = f; while (f < n && !offsets[f]) f++;
397
+ const a = start ? offsets[start - 1] : [0, 0, 0], b = f < n ? offsets[f] : a;
398
+ for (let t = start; t < f; t++) { const w = smoothstep((t - start + 1) / (f - start + 1)); offsets[t] = a.map((v, k) => v + (b[k] - v) * w); }
399
+ }
400
+ let sceneOverrides = 0, maxLockResidualM = 0, maxCorrectionM = 0, sceneMaxStepM = 0;
401
+ // sceneSmoothSeconds selects the time-smooth scene solver (its ramp half
402
+ // width); null keeps the original per-frame projection (F5 and earlier).
403
+ let sceneDeltas = null;
404
+ if (sceneSmoothSeconds !== null) {
405
+ if (!(Number.isFinite(sceneSmoothSeconds) && sceneSmoothSeconds > 0)) throw new Error("sceneSmoothSeconds must be a positive duration");
406
+ for (let f = 0; f < n; f++) shiftFrame(out, f, offsets[f]);
407
+ sceneDeltas = continuousSceneDeltas(out, boxes, Math.max(1, Math.round(sceneSmoothSeconds * motion.fps)));
408
+ }
409
+ for (let f = 0; f < n; f++) {
410
+ if (!sceneDeltas) shiftFrame(out, f, offsets[f]);
411
+ const { delta } = sceneDeltas ? { delta: sceneDeltas[f] } : resolveScene(jointsAt(out, f), boxes);
412
+ if (f && sceneDeltas) sceneMaxStepM = Math.max(sceneMaxStepM, Math.hypot(...sub(sceneDeltas[f], sceneDeltas[f - 1])));
413
+ if (Math.hypot(...delta) > 1e-6) sceneOverrides++;
414
+ shiftFrame(out, f, delta);
415
+ maxCorrectionM = Math.max(maxCorrectionM, Math.hypot(...add(offsets[f], delta)));
416
+ if (support[f] >= 0) maxLockResidualM = Math.max(maxLockResidualM, Math.hypot(...sub(vec(out.posedJoints, (f * 27 + support[f]) * 3), anchors[f])));
417
+ }
418
+ return { motion: out, diagnostics: { runs, lockedFrames, sceneOverrides, maxLockResidualM, maxCorrectionM, support: Array.from(support), geometry: "cskel27 joint/bone segments; no skin radius", solver: "one rigid support; scene constraints override locks and A/B pins", sceneSolver: sceneDeltas ? "continuous" : "per-frame", ...(sceneDeltas ? { sceneSmoothSeconds, sceneMaxStepM } : {}) } };
419
+ }
@@ -0,0 +1,234 @@
1
+ /** Foot lock: keep a planted foot fixed in the world during stance by solving
2
+ * analytic two-bone IK on the leg (UpLeg-Leg-Foot). Root-only corrections
3
+ * (depth, scene shifts) translate the body while local rotations stay put, so
4
+ * the planted ankle slides with the pelvis; this pass moves only the leg's
5
+ * local rotations back under the anchor. Root, spine and arms are untouched;
6
+ * the foot keeps its input world orientation. */
7
+ import { forwardKinematics, globalRotations, matMul, matTranspose } from "../../../src/ardy/convert.js";
8
+ import { bodyOffsets, cloneMotion, localsAt, smoothstep, vec } from "./motion.mjs";
9
+
10
+ const LEGS = [
11
+ { foot: "left", hip: 23, knee: 24, ankle: 25, toe: 26 },
12
+ { foot: "right", hip: 19, knee: 20, ankle: 21, toe: 22 },
13
+ ];
14
+ const HIPS = 0;
15
+
16
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
17
+ const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
18
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
19
+ const scale = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
20
+ const norm = (a) => Math.hypot(a[0], a[1], a[2]);
21
+ const unit = (a) => scale(a, 1 / norm(a));
22
+ const perp = (a, axis) => sub(a, scale(axis, dot(a, axis)));
23
+ const mulVec = (m, v) => m.map((row) => dot(row, v));
24
+ const columns = (x, y, z) => [[x[0], y[0], z[0]], [x[1], y[1], z[1]], [x[2], y[2], z[2]]];
25
+ const angleDeg = (a, b) => {
26
+ const r = matMul(matTranspose(a), b);
27
+ return (Math.acos(Math.max(-1, Math.min(1, (r[0][0] + r[1][1] + r[2][2] - 1) / 2))) * 180) / Math.PI;
28
+ };
29
+ const median = (values) => {
30
+ const s = [...values].sort((a, b) => a - b), m = (s.length - 1) / 2;
31
+ return (s[Math.floor(m)] + s[Math.ceil(m)]) / 2;
32
+ };
33
+
34
+ /** Minimal rotation taking unit vector a onto unit vector b (Rodrigues). */
35
+ function rotationBetween(a, b) {
36
+ const axis = cross(a, b), s = norm(axis), c = dot(a, b);
37
+ if (s < 1e-12) return [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
38
+ const [x, y, z] = scale(axis, 1 / s), t = 1 - c;
39
+ return [
40
+ [t * x * x + c, t * x * y - s * z, t * x * z + s * y],
41
+ [t * x * y + s * z, t * y * y + c, t * y * z - s * x],
42
+ [t * x * z - s * y, t * y * z + s * x, t * z * z + c],
43
+ ];
44
+ }
45
+
46
+ /** Knee pole: the knee's offset from the hip-ankle line; for a straight leg the
47
+ * toe direction (knees bend over the toes); last resort any perpendicular. */
48
+ function poleDirection(H, K, A, toe, axis) {
49
+ for (const candidate of [perp(sub(K, H), axis), perp(sub(toe, A), axis), perp([0, 0, 1], axis), perp([1, 0, 0], axis)]) {
50
+ if (norm(candidate) > 1e-3) return unit(candidate);
51
+ }
52
+ throw new Error("footlock: no knee pole direction");
53
+ }
54
+
55
+ /** Soft IK reach: near full extension the knee angle is hypersensitive to the
56
+ * ankle distance (a straightening knee sweeps centimetres per millimetre), so
57
+ * the reach saturates exponentially inside the last `soft` metres instead of
58
+ * snapping straight. Applied relative to the input distance d0 so a zero
59
+ * request reproduces the input pose exactly. */
60
+ function softReach(d0, distance, length, soft) {
61
+ if (!(soft > 0)) return Math.min(distance, length);
62
+ const knee = length - soft, cap = length - 1e-9 * length;
63
+ const squash = (x) => (x <= knee ? x : knee + soft * (1 - Math.exp(-(x - knee) / soft)));
64
+ const unsquash = (y) => (y <= knee ? y : knee - soft * Math.log(1 - (Math.min(y, cap) - knee) / soft));
65
+ return squash(unsquash(d0) + distance - d0);
66
+ }
67
+
68
+ /** Two-bone IK on one leg in place: updates locals of hip/knee/ankle so the
69
+ * ankle reaches T (clamped to the reachable shell), the knee stays in the
70
+ * plane through hip, target and the original pole, and the foot keeps its
71
+ * world orientation. */
72
+ function solveLeg(locals, G, P, leg, T, softness) {
73
+ const H = P[leg.hip], K = P[leg.knee], A = P[leg.ankle];
74
+ const a = norm(sub(K, H)), b = norm(sub(A, K));
75
+ const axis0 = unit(sub(A, H)), pole = poleDirection(H, K, A, P[leg.toe], axis0);
76
+ const toTarget = sub(T, H), distance = norm(toTarget);
77
+ const axis1 = distance > 1e-9 ? scale(toTarget, 1 / distance) : axis0;
78
+ const reach = Math.max(softReach(norm(sub(A, H)), distance, a + b, softness * (a + b)), Math.abs(a - b) + 1e-6);
79
+ const cosA = Math.max(-1, Math.min(1, (a * a + reach * reach - b * b) / (2 * a * reach)));
80
+ const sinA = Math.sqrt(1 - cosA * cosA);
81
+ const pole1 = norm(perp(pole, axis1)) > 1e-6 ? unit(perp(pole, axis1)) : poleDirection(H, K, A, P[leg.toe], axis1);
82
+ const K1 = [0, 1, 2].map((k) => H[k] + axis1[k] * a * cosA + pole1[k] * a * sinA);
83
+ const A1 = [0, 1, 2].map((k) => H[k] + axis1[k] * reach);
84
+ // Thigh: align the (thigh, bend-normal) frame; knee: hinge about the normal.
85
+ const u0 = unit(sub(K, H)), n0 = unit(perp(cross(axis0, pole), u0));
86
+ const u1 = unit(sub(K1, H)), n1 = unit(perp(cross(axis1, pole1), u1));
87
+ const thigh = matMul(columns(u1, n1, cross(u1, n1)), matTranspose(columns(u0, n0, cross(u0, n0))));
88
+ const knee = rotationBetween(unit(mulVec(thigh, sub(A, K))), unit(sub(A1, K1)));
89
+ const gUp = matMul(thigh, G[leg.hip]), gLeg = matMul(knee, matMul(thigh, G[leg.knee]));
90
+ locals[leg.hip] = matMul(matTranspose(G[HIPS]), gUp);
91
+ locals[leg.knee] = matMul(matTranspose(gUp), gLeg);
92
+ locals[leg.ankle] = matMul(matTranspose(gLeg), G[leg.ankle]);
93
+ }
94
+
95
+ /** Highest support (floor or box top under the foot) the foot may stand on. */
96
+ function supportUnder(points, lowY, floorY, boxes, maxHeight) {
97
+ let best = floorY;
98
+ for (const box of boxes) {
99
+ const inside = points.some((p) => p[0] >= box.min[0] && p[0] <= box.max[0] && p[2] >= box.min[2] && p[2] <= box.max[2]);
100
+ if (inside && box.max[1] > best && lowY >= box.max[1] - maxHeight) best = box.max[1];
101
+ }
102
+ return best;
103
+ }
104
+
105
+ /** Per frame: { goal } (absolute ankle target) inside a stance, { offset } in
106
+ * the ramps around it, null where the leg is left alone. The ramps fade the
107
+ * stance-edge correction (not a pull toward the anchor), so the swing keeps its
108
+ * shape; each ramp lasts at least blendFrames and long enough that the added
109
+ * per-frame displacement stays near maxBlendStepM. Two stances whose ramps meet
110
+ * share the gap: the correction interpolates from one edge to the next. */
111
+ function planCorrections(motion, leg, stances, blendFrames, maxBlendStepM) {
112
+ const T = motion.frames, plan = new Array(T).fill(null);
113
+ const ankle = (f) => vec(motion.posedJoints, (f * 27 + leg.ankle) * 3);
114
+ const toeY = (f) => motion.posedJoints[(f * 27 + leg.toe) * 3 + 1];
115
+ // A ramp carries a stance-edge correction onto frames whose foot pitch
116
+ // differs; never let it push the foot's lowest point under the support
117
+ // (or deeper than the input already was). The foot keeps its world
118
+ // orientation, so the toe moves with the ankle.
119
+ const fade = (f, offset, level) => {
120
+ const lowest = Math.min(ankle(f)[1], toeY(f));
121
+ return { offset: [offset[0], Math.max(offset[1], -Math.max(0, lowest - level)), offset[2]] };
122
+ };
123
+ const goal = (stance, f) => [stance.anchor[0], stance.height(f), stance.anchor[2]];
124
+ // smoothstep's peak slope is 1.5 / (n + 1) per frame over an n-frame ramp.
125
+ const ramp = (d) => Math.max(blendFrames, Math.ceil((1.5 * norm(d)) / maxBlendStepM) - 1);
126
+ const edges = stances.map((s) => {
127
+ const first = sub(goal(s, s.start), ankle(s.start)), last = sub(goal(s, s.end), ankle(s.end));
128
+ return { first, last, before: ramp(first), after: ramp(last) };
129
+ });
130
+ stances.forEach((s, i) => {
131
+ for (let f = s.start; f <= s.end; f++) plan[f] = { goal: goal(s, f) };
132
+ const next = stances[i + 1], gap = next ? next.start - s.end : Infinity;
133
+ if (next && gap - 1 <= edges[i].after + edges[i + 1].before) {
134
+ for (let f = s.end + 1; f < next.start; f++) {
135
+ const t = smoothstep((f - s.end) / gap);
136
+ plan[f] = fade(f, edges[i].last.map((v, k) => v + (edges[i + 1].first[k] - v) * t), Math.max(s.level, next.level));
137
+ }
138
+ } else {
139
+ const n = edges[i].after;
140
+ for (let f = s.end + 1; f <= Math.min(T - 1, s.end + n); f++) plan[f] = fade(f, scale(edges[i].last, smoothstep(1 - (f - s.end) / (n + 1))), s.level);
141
+ if (next) {
142
+ const m = edges[i + 1].before;
143
+ for (let f = next.start - m; f < next.start; f++) plan[f] = fade(f, scale(edges[i + 1].first, smoothstep(1 - (next.start - f) / (m + 1))), next.level);
144
+ }
145
+ }
146
+ if (i === 0) {
147
+ const m = edges[0].before;
148
+ for (let f = Math.max(0, s.start - m); f < s.start; f++) plan[f] = fade(f, scale(edges[0].first, smoothstep(1 - (s.start - f) / (m + 1))), s.level);
149
+ }
150
+ });
151
+ return plan;
152
+ }
153
+
154
+ function detectStances(motion, leg, { floorY, boxes, maxHeight, maxSpeed, minStanceFrames, minSwingFrames }) {
155
+ const T = motion.frames, at = (f, j) => vec(motion.posedJoints, (f * 27 + j) * 3);
156
+ const ankle = Array.from({ length: T }, (_, f) => at(f, leg.ankle));
157
+ const toe = Array.from({ length: T }, (_, f) => at(f, leg.toe));
158
+ // The slower one-sided step: a stance's first/last frame has one slow side.
159
+ const step = (f, g) => Math.hypot(ankle[g][0] - ankle[f][0], ankle[g][2] - ankle[f][2]) * motion.fps;
160
+ const speed = ankle.map((_, f) => Math.min(f > 0 ? step(f - 1, f) : Infinity, f + 1 < T ? step(f, f + 1) : Infinity, T === 1 ? 0 : Infinity));
161
+ const support = ankle.map((p, f) => {
162
+ const lowY = Math.min(p[1], toe[f][1]), s = supportUnder([p, toe[f]], lowY, floorY, boxes, maxHeight);
163
+ return lowY - s <= maxHeight && speed[f] <= maxSpeed ? s : null;
164
+ });
165
+ const runs = [];
166
+ for (let f = 0; f < T;) {
167
+ if (support[f] === null) { f += 1; continue; }
168
+ let end = f;
169
+ while (end + 1 < T && support[end + 1] !== null && Math.abs(support[end + 1] - support[f]) < 0.01) end += 1;
170
+ const last = runs.at(-1);
171
+ // A foot cannot lift and replant in fewer than minSwingFrames: one fast
172
+ // frame inside a sliding stance is not a step.
173
+ if (last && f - last.end - 1 < minSwingFrames && Math.abs(last.level - support[f]) < 0.01) last.end = end;
174
+ else runs.push({ start: f, end, level: support[f] });
175
+ f = end + 1;
176
+ }
177
+ const stances = [];
178
+ for (const { start, end, level } of runs) {
179
+ if (end - start + 1 >= minStanceFrames) {
180
+ const frames = Array.from({ length: end - start + 1 }, (_, i) => start + i);
181
+ const mean = (k) => frames.reduce((sum, i) => sum + ankle[i][k], 0) / frames.length;
182
+ // Flat-foot ankle height above the toe; heel-off raises the ankle above it.
183
+ const flat = median(frames.map((i) => ankle[i][1] - toe[i][1]));
184
+ const height = (i) => level + Math.max(flat, ankle[i][1] - toe[i][1]);
185
+ stances.push({ foot: leg.foot, start, end, anchor: [mean(0), level + flat, mean(2)], level, height });
186
+ }
187
+ }
188
+ return stances;
189
+ }
190
+
191
+ /**
192
+ * Stance = ankle/toe within maxHeight of the floor or a box top AND ankle
193
+ * horizontal speed <= maxSpeed for >= minStanceFrames. maxSpeed defaults to
194
+ * 0.4 m/s, not 0.25: the fitted takes this pass repairs slide 25-30 cm/s on
195
+ * truly planted frames, so 0.25 misses about half of each stance (measured on
196
+ * the 11 gt+cube items: mean skate 9.7 cm/s at 0.25 vs 8.2 at 0.4).
197
+ * @param {object} motion cskel27 motion ({ frames, fps, rotMats, rootPos, posedJoints, boneScale })
198
+ * @returns {{ motion, diagnostics: { stances, maxResidualM, maxAdjustDeg } }}
199
+ */
200
+ export function lockFeet(motion, { floorY = 0, boxes = [], maxHeight = 0.06, maxSpeed = 0.4, minStanceFrames = 3, blendFrames = 3,
201
+ minSwingFrames = 4, maxBlendStepM = 0.01, softness = 0.03, offsets = bodyOffsets(motion.boneScale) } = {}) {
202
+ if (!(blendFrames >= 0) || !(minStanceFrames >= 1) || !(minSwingFrames >= 0) || !(maxBlendStepM > 0) || !(softness >= 0)) {
203
+ throw new Error("lockFeet: blendFrames >= 0, minStanceFrames >= 1, minSwingFrames >= 0, maxBlendStepM > 0, softness >= 0 required");
204
+ }
205
+ const out = cloneMotion(motion), T = motion.frames;
206
+ const detect = { floorY, boxes, maxHeight, maxSpeed, minStanceFrames, minSwingFrames };
207
+ const plans = LEGS.map((leg) => {
208
+ const stances = detectStances(motion, leg, detect);
209
+ return { leg, stances, plan: planCorrections(motion, leg, stances, blendFrames, maxBlendStepM) };
210
+ });
211
+ let maxResidualM = 0, maxAdjustDeg = 0;
212
+ for (let f = 0; f < T; f++) {
213
+ const active = plans.filter(({ plan }) => plan[f] !== null);
214
+ if (!active.length) continue;
215
+ const original = localsAt(motion, f), locals = original.map((m) => m.map((row) => row.slice()));
216
+ const root = vec(motion.rootPos, f * 3), G = globalRotations(locals), P = forwardKinematics(locals, offsets, root);
217
+ const targets = active.map(({ leg, plan }) => {
218
+ const target = plan[f].goal ?? P[leg.ankle].map((v, k) => v + plan[f].offset[k]);
219
+ solveLeg(locals, G, P, leg, target, softness);
220
+ return { leg, target };
221
+ });
222
+ const solved = forwardKinematics(locals, offsets, root);
223
+ for (const { leg, target } of targets) {
224
+ maxResidualM = Math.max(maxResidualM, norm(sub(solved[leg.ankle], target)));
225
+ for (const j of [leg.hip, leg.knee, leg.ankle]) {
226
+ maxAdjustDeg = Math.max(maxAdjustDeg, angleDeg(original[j], locals[j]));
227
+ out.rotMats.set(locals[j].flat(), (f * 27 + j) * 9);
228
+ }
229
+ }
230
+ for (let j = 0; j < 27; j++) out.posedJoints.set(solved[j], (f * 27 + j) * 3);
231
+ }
232
+ const stances = plans.flatMap(({ stances }) => stances.map(({ foot, start, end, anchor }) => ({ foot, start, end, anchor })));
233
+ return { motion: out, diagnostics: { stances, maxResidualM, maxAdjustDeg } };
234
+ }