cozyclay 2.1.1 → 3.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 (577) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +12 -30
  3. package/bin/agent/agent-routes.mjs +40 -113
  4. package/bin/agent/agent-runner.mjs +9 -10
  5. package/bin/agent/cliproxy-provider.mjs +135 -0
  6. package/bin/agent/credential-store.mjs +30 -7
  7. package/bin/agent/pi-tools.mjs +4 -1
  8. package/bin/agent/provider-keys.mjs +32 -0
  9. package/bin/agent/provider-login.mjs +110 -0
  10. package/bin/agent/provider-routes.mjs +99 -0
  11. package/bin/agent/providers.mjs +19 -97
  12. package/bin/agent/script-tool.mjs +17 -4
  13. package/bin/agent/studio-motion-reference.mjs +23 -0
  14. package/bin/agent/studio-prompt.mjs +1 -1
  15. package/bin/agent/studio-rebase-text.mjs +32 -0
  16. package/bin/agent/studio-rebase.mjs +26 -39
  17. package/bin/agent/studio-tools.mjs +9 -28
  18. package/bin/agent/tool-arguments.mjs +49 -0
  19. package/bin/agent/turn-frames.mjs +66 -0
  20. package/bin/codex-auth.mjs +9 -2
  21. package/bin/cozyclay.mjs +10 -116
  22. package/bin/live/cli.mjs +3 -3
  23. package/bin/mcp-runtime.mjs +2 -12
  24. package/bin/package-signature.mjs +0 -4
  25. package/dist/app/index.html +7 -6
  26. package/dist/assets/EXRLoader-CvVIvGVE.js +1 -0
  27. package/dist/assets/analytics-C9sUnndd.js +1 -0
  28. package/dist/assets/app-CCh--1Hc.js +5087 -0
  29. package/dist/assets/app-f-fkV5uP.css +1 -0
  30. package/dist/assets/compile-worker-CkxiD6lH.js +5 -0
  31. package/dist/assets/fflate.module-KbGj5-H6.js +5 -0
  32. package/dist/assets/{first-shot-handoff-msLI71g1.js → first-shot-handoff-CC4xe4HM.js} +1 -1
  33. package/dist/assets/{landing-ChUvB_zL.js → landing-DjHD8u7k.js} +1 -1
  34. package/dist/assets/scene-asset-sync-Hx_FzdOH.js +20 -0
  35. package/dist/assets/scene-asset-sync-_S8NQl5B.css +1 -0
  36. package/dist/assets/workflow-C4yKH1p4.js +179 -0
  37. package/dist/cozyclay-package.json +1 -1
  38. package/dist/index.html +3 -3
  39. package/dist/sw.js +3 -6
  40. package/dist/workflow/index.html +5 -4
  41. package/mcp/LIVE-PROTOCOL.md +7 -47
  42. package/mcp/README.md +16 -26
  43. package/mcp/live-hub.mjs +6 -10
  44. package/mcp/package.json +1 -3
  45. package/mcp/tool-handlers.mjs +4 -168
  46. package/package.json +3 -22
  47. package/src/App.jsx +150 -2624
  48. package/src/agent-progress.js +79 -0
  49. package/src/agent-turn-mark.js +30 -0
  50. package/src/analytics.js +1 -19
  51. package/src/app-context.js +5 -0
  52. package/src/app-stage.jsx +2 -34
  53. package/src/asset-pane.jsx +6 -5
  54. package/src/camera-block.js +21 -0
  55. package/src/camera-direction.js +120 -0
  56. package/src/character-kind.js +3 -9
  57. package/src/command-bus.js +8 -6
  58. package/src/commands/cast.js +5 -34
  59. package/src/commands/elements/character.js +1 -1
  60. package/src/commands/elements/motion.js +1 -6
  61. package/src/commands/elements/object.js +6 -2
  62. package/src/commands/elements/shot.js +4 -1
  63. package/src/commands/elements.js +38 -14
  64. package/src/commands/index.js +3 -1
  65. package/src/commands/kits.js +37 -0
  66. package/src/commands/motion-timing.js +57 -0
  67. package/src/commands/motion.js +9 -152
  68. package/src/commands/motion3-compile.js +65 -0
  69. package/src/commands/motion3.js +207 -0
  70. package/src/commands/objects.js +6 -4
  71. package/src/commands/shot.js +49 -11
  72. package/src/commands/stage.js +14 -1
  73. package/src/commands/view.js +4 -1
  74. package/src/document-mirrors.js +42 -0
  75. package/src/document-store.js +4 -0
  76. package/src/domains/cast.js +19 -194
  77. package/src/domains/motion/motion-range-pin-rules.js +1 -1
  78. package/src/domains/motion/take-source.js +51 -0
  79. package/src/domains/motion.js +60 -1838
  80. package/src/domains/scenes.js +12 -55
  81. package/src/domains/shots.js +2 -2
  82. package/src/domains/stage.js +4 -2
  83. package/src/draft-ghost.js +46 -0
  84. package/src/draft-ghost.jsx +129 -0
  85. package/src/dualview.jsx +4 -3
  86. package/src/effects/EffectsLayer.jsx +112 -0
  87. package/src/effects/effect-meshes.js +232 -0
  88. package/src/effects/effect-record.js +103 -0
  89. package/src/effects/emitter.js +59 -0
  90. package/src/effects/simulate.js +261 -0
  91. package/src/effects/state.js +59 -0
  92. package/src/execution-telemetry.js +2 -3
  93. package/src/first-success-guide.jsx +5 -5
  94. package/src/ghost-set.js +257 -0
  95. package/src/held-object-edit.jsx +113 -0
  96. package/src/held-object-pose.js +175 -0
  97. package/src/hierarchy-model.js +1 -1
  98. package/src/hierarchy-panel.jsx +1 -1
  99. package/src/imported-mesh.jsx +103 -0
  100. package/src/kits/held.js +84 -0
  101. package/src/kits/index.js +70 -0
  102. package/src/kits/smoking.js +57 -0
  103. package/src/light-blockers.js +93 -0
  104. package/src/motion/beat-board-model.js +84 -0
  105. package/src/motion/beat-board.css +124 -0
  106. package/src/motion/beat-board.jsx +59 -0
  107. package/src/motion/center-of-mass.js +66 -0
  108. package/src/motion/fix-collisions.js +850 -0
  109. package/src/motion/physics-review.js +118 -0
  110. package/src/{ardy → motion}/physics-support.js +1 -76
  111. package/src/motion/rig-snapshot.js +22 -0
  112. package/src/motion/take-segments.jsx +140 -0
  113. package/src/{ardy → motion}/timeline-coordinates.js +0 -21
  114. package/src/{ardy → motion}/timeline.css +1 -2
  115. package/src/{ardy → motion}/timeline.jsx +42 -413
  116. package/src/motion/waypoints.js +76 -0
  117. package/src/motion3/beats.js +83 -0
  118. package/src/motion3/body.js +158 -0
  119. package/src/motion3/bones.js +47 -0
  120. package/src/motion3/carried.js +47 -0
  121. package/src/motion3/compile-job.js +75 -0
  122. package/src/motion3/compile-worker.js +10 -0
  123. package/src/motion3/compiler/anchors.js +105 -0
  124. package/src/motion3/compiler/arm-orient.js +128 -0
  125. package/src/motion3/compiler/channels.js +125 -0
  126. package/src/motion3/compiler/compile.js +205 -0
  127. package/src/motion3/compiler/gait-rhythm.js +97 -0
  128. package/src/motion3/compiler/grip.js +171 -0
  129. package/src/motion3/compiler/head-steady.js +48 -0
  130. package/src/motion3/compiler/hold.js +134 -0
  131. package/src/motion3/compiler/idle.js +113 -0
  132. package/src/motion3/compiler/limbs.js +170 -0
  133. package/src/motion3/compiler/math.js +72 -0
  134. package/src/motion3/compiler/pose.js +162 -0
  135. package/src/motion3/compiler/quat.js +49 -0
  136. package/src/motion3/compiler/skeleton.js +74 -0
  137. package/src/motion3/compiler/validate-common.js +32 -0
  138. package/src/motion3/compiler/validate-goals.js +118 -0
  139. package/src/motion3/compiler/validate-objects.js +143 -0
  140. package/src/motion3/compiler/validate-parts.js +116 -0
  141. package/src/motion3/compiler/validate.js +122 -0
  142. package/src/motion3/compiler/vocab.js +73 -0
  143. package/src/motion3/compiler/walk-pelvis.js +275 -0
  144. package/src/motion3/compiler/walk.js +207 -0
  145. package/src/motion3/compiler/wrist-clamp.js +32 -0
  146. package/src/motion3/delta.js +47 -0
  147. package/src/motion3/describe.js +174 -0
  148. package/src/motion3/docs.js +27 -0
  149. package/src/motion3/edit-carry.js +21 -0
  150. package/src/motion3/fix/ease.js +16 -0
  151. package/src/motion3/fix/ground.js +37 -0
  152. package/src/motion3/fix/index.js +52 -0
  153. package/src/motion3/fix/push.js +71 -0
  154. package/src/motion3/fix/reach.js +46 -0
  155. package/src/motion3/fix/retime.js +79 -0
  156. package/src/motion3/fix/self.js +211 -0
  157. package/src/motion3/fix/slide.js +70 -0
  158. package/src/motion3/fix/turn.js +51 -0
  159. package/src/motion3/follow.js +39 -0
  160. package/src/motion3/free-motion.js +173 -0
  161. package/src/motion3/grip-choice.js +130 -0
  162. package/src/motion3/hold-plan.js +157 -0
  163. package/src/motion3/json-patch.js +77 -0
  164. package/src/motion3/key-aim.js +105 -0
  165. package/src/motion3/key-edit.js +82 -0
  166. package/src/motion3/lenient.js +127 -0
  167. package/src/motion3/object-keys.js +236 -0
  168. package/src/motion3/object-pose.js +182 -0
  169. package/src/motion3/object-tracks.js +12 -0
  170. package/src/motion3/pipeline.js +102 -0
  171. package/src/motion3/playback.js +51 -0
  172. package/src/motion3/probes.js +91 -0
  173. package/src/motion3/qa/body-checks.js +34 -0
  174. package/src/motion3/qa/check-names.js +14 -0
  175. package/src/motion3/qa/checks.js +121 -0
  176. package/src/motion3/qa/contact-checks.js +51 -0
  177. package/src/motion3/qa/explain.js +80 -0
  178. package/src/motion3/qa/fmt.js +5 -0
  179. package/src/motion3/qa/foot-checks.js +72 -0
  180. package/src/motion3/qa/geometry.js +77 -0
  181. package/src/motion3/qa/goals.js +191 -0
  182. package/src/motion3/qa/ground.js +60 -0
  183. package/src/motion3/qa/hints-contact.js +179 -0
  184. package/src/motion3/qa/hints-goal.js +160 -0
  185. package/src/motion3/qa/hints-knee.js +24 -0
  186. package/src/motion3/qa/hints-motion.js +209 -0
  187. package/src/motion3/qa/hints-object.js +91 -0
  188. package/src/motion3/qa/index.js +42 -0
  189. package/src/motion3/qa/key-checks.js +82 -0
  190. package/src/motion3/qa/near.js +96 -0
  191. package/src/motion3/qa/object-checks.js +135 -0
  192. package/src/motion3/qa/patch.js +161 -0
  193. package/src/motion3/qa/severity.js +38 -0
  194. package/src/motion3/qa/spec-checks.js +65 -0
  195. package/src/motion3/qa/style.js +218 -0
  196. package/src/motion3/qa/wrist-checks.js +69 -0
  197. package/src/motion3/qa/wrist-obs.js +60 -0
  198. package/src/motion3/refs.js +120 -0
  199. package/src/motion3/repair-tune.js +121 -0
  200. package/src/motion3/repair.js +83 -0
  201. package/src/motion3/report.js +67 -0
  202. package/src/motion3/rigs/index.js +14 -0
  203. package/src/motion3/rigs/x-bot-tpose-body.json +1 -0
  204. package/src/motion3/rigs/x-bot-tpose.json +1 -0
  205. package/src/motion3/rigs/y-bot-tpose-body.json +1 -0
  206. package/src/motion3/rigs/y-bot-tpose.json +1 -0
  207. package/src/motion3/scene.js +60 -0
  208. package/src/motion3/schema/brief.js +89 -0
  209. package/src/motion3/schema/fields.js +44 -0
  210. package/src/motion3/schema/spec-examples.js +46 -0
  211. package/src/motion3/schema/topics.js +111 -0
  212. package/src/motion3/schema/types.js +147 -0
  213. package/src/motion3/search.js +117 -0
  214. package/src/motion3/second-hand.js +112 -0
  215. package/src/motion3/skills/basic.js +82 -0
  216. package/src/motion3/skills/book.js +76 -0
  217. package/src/motion3/skills/car.js +145 -0
  218. package/src/motion3/skills/frames-auto.js +29 -0
  219. package/src/motion3/skills/index.js +83 -0
  220. package/src/motion3/skills/info.js +65 -0
  221. package/src/motion3/skills/smoke-kit.js +109 -0
  222. package/src/motion3/skills/smoke.js +160 -0
  223. package/src/motion3/spec-store.js +59 -0
  224. package/src/motion3/take-edit.js +155 -0
  225. package/src/motion3/take.js +63 -0
  226. package/src/motion3/timeline.js +127 -0
  227. package/src/motion3/views.js +121 -0
  228. package/src/motion3/walk-plan.js +119 -0
  229. package/src/object-catalog.jsx +7 -1
  230. package/src/object-colors.js +98 -0
  231. package/src/object-gizmo-parts.js +115 -0
  232. package/src/object-gizmo.jsx +37 -122
  233. package/src/object-glow.js +139 -0
  234. package/src/object-hidden.js +33 -0
  235. package/src/object-look.js +196 -0
  236. package/src/object-size.js +10 -0
  237. package/src/object-travel.js +71 -6
  238. package/src/panels/CameraPanel.jsx +7 -7
  239. package/src/panels/HdrEnvironmentControls.jsx +59 -0
  240. package/src/panels/LightPanel.jsx +25 -4
  241. package/src/panels/ObjectLookPanel.jsx +88 -0
  242. package/src/panels/PosePanel.jsx +2 -17
  243. package/src/panels/RigControlPanel.jsx +5 -53
  244. package/src/panels/SubjectBox.jsx +4 -1
  245. package/src/panels/details.css +14 -0
  246. package/src/posestudio.jsx +4 -27
  247. package/src/project-browser.jsx +3 -4
  248. package/src/project-resources.js +18 -31
  249. package/src/project.js +13 -104
  250. package/src/props.jsx +25 -280
  251. package/src/rail-dot-drag.js +23 -0
  252. package/src/render-passes.js +10 -0
  253. package/src/resource-status.jsx +1 -0
  254. package/src/result-modal.jsx +4 -19
  255. package/src/room.jsx +20 -12
  256. package/src/scene-assets.js +8 -6
  257. package/src/scene-light-place.js +72 -0
  258. package/src/scene-lights.jsx +148 -0
  259. package/src/scene-objects.js +31 -139
  260. package/src/scenes.js +15 -105
  261. package/src/semantic-edit.js +2 -5
  262. package/src/set-pieces.jsx +183 -0
  263. package/src/settings-menu.jsx +3 -11
  264. package/src/shell/BottomDock.jsx +9 -51
  265. package/src/shell/DetailsSlot.jsx +16 -200
  266. package/src/shell/GhostsMenuGroup.jsx +74 -0
  267. package/src/shell/LibrarySlot.jsx +1 -3
  268. package/src/shell/MenuBar.jsx +3 -3
  269. package/src/shell/PreferencesDialog.jsx +15 -264
  270. package/src/shell/PreferencesSlot.jsx +2 -8
  271. package/src/shell/TopBar.jsx +5 -84
  272. package/src/shell/ViewportToolbar.jsx +10 -70
  273. package/src/shell/agent-glass.css +19 -6
  274. package/src/shell/details-selection.js +42 -0
  275. package/src/shell/ghosts-menu.css +22 -0
  276. package/src/shell/glass-regions.css +29 -3
  277. package/src/shell/preferences.css +6 -1
  278. package/src/shot-authoring.js +4 -7
  279. package/src/soft-shadows.js +37 -0
  280. package/src/stage-attach-frames.js +12 -12
  281. package/src/stage-canvas-rigs.jsx +3 -3
  282. package/src/stage-capture.jsx +42 -8
  283. package/src/stage-characters.jsx +22 -216
  284. package/src/stage-editor-gizmos.jsx +6 -6
  285. package/src/stage-environment.js +93 -0
  286. package/src/stage-hdr-builtins.js +107 -0
  287. package/src/stage-hdr-image.js +116 -0
  288. package/src/stage-hdr-library.js +70 -0
  289. package/src/stage-hdr-map.js +94 -0
  290. package/src/stage-hdr.js +130 -0
  291. package/src/stage-lighting.js +149 -0
  292. package/src/stage-occlusion.js +210 -0
  293. package/src/stage-sky.jsx +159 -0
  294. package/src/stage-startup-defaults.js +5 -9
  295. package/src/studio-actions.js +12 -14
  296. package/src/studio-agent-commands.js +19 -16
  297. package/src/studio-agent-motion.js +9 -9
  298. package/src/studio-agent-protocol.js +41 -71
  299. package/src/studio-app-binding.js +25 -45
  300. package/src/studio-asset-list.js +17 -0
  301. package/src/studio-contact-sheet.js +37 -5
  302. package/src/studio-elements.js +27 -7
  303. package/src/studio-entity-bounds.js +18 -0
  304. package/src/studio-error-codes.js +8 -0
  305. package/src/studio-export-framing.js +152 -0
  306. package/src/studio-lighting-check.js +103 -0
  307. package/src/studio-motion-visual.js +150 -0
  308. package/src/studio-schema-errors.js +80 -0
  309. package/src/studio-support-gap.js +74 -0
  310. package/src/styles.css +1 -19
  311. package/src/timeline-defaults.js +5 -0
  312. package/src/timeline-extent.js +8 -20
  313. package/src/tutorial-walk.js +17 -0
  314. package/src/ui-scrub.js +1 -1
  315. package/src/use-shot-camera-tracks.js +12 -0
  316. package/src/view-ghosts.js +97 -0
  317. package/src/view-ghosts.jsx +49 -0
  318. package/src/workflow/AgentPanel.jsx +47 -166
  319. package/src/workflow/AgentProgressCard.jsx +107 -0
  320. package/src/workflow/AgentReplyText.jsx +37 -0
  321. package/src/workflow/AgentTurnSteps.jsx +84 -0
  322. package/src/workflow/LoginPanel.jsx +140 -0
  323. package/src/workflow/WorkflowBuilder.jsx +7 -23
  324. package/src/workflow/agent-client.js +54 -51
  325. package/src/workflow/agent-panel.css +71 -1
  326. package/src/workflow/agent-progress-card.css +167 -0
  327. package/src/workflow/agent-progress-view.js +47 -0
  328. package/src/workflow/agent-turn-steps.css +113 -0
  329. package/src/workflow/agent-turn.js +191 -0
  330. package/src/workflow/login-client.js +34 -0
  331. package/src/workflow/motion-input.js +18 -31
  332. package/src/workflow/node-schema.js +1 -1
  333. package/src/workflow/reply-markdown.js +69 -0
  334. package/src/workflow/scene-asset-sync.js +8 -8
  335. package/tools/agent-session-stats.mjs +61 -0
  336. package/tools/dev-full.mjs +5 -56
  337. package/tools/eslint-baseline.json +12 -13
  338. package/tools/knip-baseline.json +0 -179
  339. package/tools/loc-baseline.json +31 -81
  340. package/tools/motion3/extract-body.mjs +103 -0
  341. package/tools/motion3/extract-rig.mjs +85 -0
  342. package/tools/process-supervisor.mjs +0 -88
  343. package/tools/run-tests.mjs +8 -124
  344. package/bin/agent/motion-runtime.mjs +0 -76
  345. package/dist/ardy/cskel27-rest.json +0 -557
  346. package/dist/assets/analytics-C9pMpW7s.js +0 -1
  347. package/dist/assets/app-B_2XB9rw.css +0 -1
  348. package/dist/assets/app-DAuvKPP8.js +0 -4920
  349. package/dist/assets/config-Bx7pYsD9.js +0 -1
  350. package/dist/assets/config-DeoFTYzb.css +0 -1
  351. package/dist/assets/demo-CvKuBgy5.js +0 -1
  352. package/dist/assets/scene-asset-sync-DKkpjKBV.js +0 -19
  353. package/dist/assets/scene-asset-sync-DjJ_cJ2R.css +0 -1
  354. package/dist/assets/ticket-BPk2Ni95.js +0 -1
  355. package/dist/assets/workflow-jJRBR94Z.js +0 -179
  356. package/dist/demo/walk-then-stop.npz +0 -0
  357. package/mcp/ardy-prompts.mjs +0 -218
  358. package/src/ardy/auto-fix-panel.css +0 -108
  359. package/src/ardy/auto-physics.js +0 -1194
  360. package/src/ardy/client.js +0 -153
  361. package/src/ardy/convert.js +0 -313
  362. package/src/ardy/cskel27-neutral.js +0 -39
  363. package/src/ardy/cskel27.js +0 -77
  364. package/src/ardy/export.js +0 -165
  365. package/src/ardy/fix-collisions.js +0 -2257
  366. package/src/ardy/foot-lock.js +0 -230
  367. package/src/ardy/motion-calibration.js +0 -118
  368. package/src/ardy/motion-edit.js +0 -213
  369. package/src/ardy/motion-url.js +0 -55
  370. package/src/ardy/mp4-duration.js +0 -102
  371. package/src/ardy/npz.js +0 -595
  372. package/src/ardy/physics-panel.css +0 -21
  373. package/src/ardy/physics-panel.jsx +0 -89
  374. package/src/ardy/physics-refine.js +0 -111
  375. package/src/ardy/physics-review.js +0 -567
  376. package/src/ardy/physics-temporal.js +0 -54
  377. package/src/ardy/platform-fit-panel.css +0 -64
  378. package/src/ardy/platform-fit-panel.jsx +0 -41
  379. package/src/ardy/platform-fit.js +0 -326
  380. package/src/ardy/playback.js +0 -711
  381. package/src/ardy/pose-pin.js +0 -97
  382. package/src/ardy/prompt-clips.js +0 -30
  383. package/src/ardy/retime.js +0 -211
  384. package/src/ardy/root-drop.js +0 -369
  385. package/src/ardy/timeline-resize.js +0 -13
  386. package/src/ardy/to-cskel27.js +0 -225
  387. package/src/ardy/trim.js +0 -33
  388. package/src/ardy/waypoints.js +0 -369
  389. package/src/ardy-bridge-contract.js +0 -281
  390. package/src/domains/motion/motion-video-capture.js +0 -664
  391. package/src/i2v-motion-client.js +0 -77
  392. package/src/i2v-motion-studio.jsx +0 -180
  393. package/src/line-edit.js +0 -1348
  394. package/src/motion/generation.js +0 -105
  395. package/src/motion-readiness-ui.jsx +0 -67
  396. package/src/motion-readiness.js +0 -40
  397. package/src/motion-resources.js +0 -177
  398. package/src/motion-store.js +0 -65
  399. package/src/motion-trail.js +0 -601
  400. package/src/multimodel-ingest.js +0 -394
  401. package/src/panels/PromptBlocksPanel.jsx +0 -444
  402. package/src/panels/VideoCapturePanel.jsx +0 -181
  403. package/src/pose-extract/browser.js +0 -42
  404. package/src/pose-extract/detector.js +0 -100
  405. package/src/pose-extract/fit.js +0 -227
  406. package/src/pose-extract/image-frame.js +0 -51
  407. package/src/pose-extract/index.js +0 -58
  408. package/src/pose-extract/keyframes.js +0 -74
  409. package/src/pose-extract/landmarks.js +0 -162
  410. package/src/pose-extract/mirror.js +0 -153
  411. package/src/pose-extract/take.js +0 -131
  412. package/src/pose-extract/video-frames.js +0 -91
  413. package/src/take-recipe.js +0 -165
  414. package/src/trail-key-conflicts.js +0 -39
  415. package/src/trail-pick.js +0 -59
  416. package/src/use-i2v-capture-modal.js +0 -15
  417. package/tools/ardy/BRIDGE.md +0 -37
  418. package/tools/ardy/GVHMR-NPZ.md +0 -68
  419. package/tools/ardy/README.md +0 -124
  420. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  421. package/tools/ardy/artifacts.mjs +0 -34
  422. package/tools/ardy/bridge.mjs +0 -1732
  423. package/tools/ardy/bvh-cskel27.mjs +0 -1265
  424. package/tools/ardy/cclay_constrained_generate.py +0 -561
  425. package/tools/ardy/cclay_gvhmr_worker.py +0 -264
  426. package/tools/ardy/cclay_motion_edit.py +0 -510
  427. package/tools/ardy/cclay_sequence_generate.py +0 -705
  428. package/tools/ardy/cclay_worker.py +0 -304
  429. package/tools/ardy/dump-npz.py +0 -205
  430. package/tools/ardy/extract-rest.mjs +0 -299
  431. package/tools/ardy/extract.mjs +0 -507
  432. package/tools/ardy/footage.mjs +0 -467
  433. package/tools/ardy/gvhmr-floor.mjs +0 -102
  434. package/tools/ardy/gvhmr-to-cskel27.mjs +0 -21
  435. package/tools/ardy/gvhmr_fastpath.py +0 -168
  436. package/tools/ardy/gvhmr_trajectory.py +0 -240
  437. package/tools/ardy/merge-text-encoder.py +0 -160
  438. package/tools/ardy/mocap-metrics.mjs +0 -200
  439. package/tools/ardy/mocap-quality-gate.mjs +0 -110
  440. package/tools/ardy/motion-stabilize.mjs +0 -348
  441. package/tools/ardy/npz.mjs +0 -414
  442. package/tools/ardy/pose-to-npz.mjs +0 -106
  443. package/tools/ardy/prompt-limits.mjs +0 -5
  444. package/tools/ardy/run-edit-on-box.sh +0 -73
  445. package/tools/ardy/run-local.mjs +0 -233
  446. package/tools/ardy/run-on-box.sh +0 -593
  447. package/tools/ardy/run-sequence-on-box.sh +0 -177
  448. package/tools/ardy/runners/gvhmr-worker.mjs +0 -206
  449. package/tools/ardy/runners/index.mjs +0 -18
  450. package/tools/ardy/runners/local.mjs +0 -455
  451. package/tools/ardy/runners/proc.mjs +0 -125
  452. package/tools/ardy/runners/remote.mjs +0 -252
  453. package/tools/ardy/setup-local.mjs +0 -189
  454. package/tools/ardy/setup-text-encoder-on-box.sh +0 -93
  455. package/tools/ardy/setup-text-encoder.py +0 -306
  456. package/tools/ardy/smpl-cskel27.mjs +0 -178
  457. package/tools/ardy/visual-qa.mjs +0 -196
  458. package/tools/ardy/vq-car.mjs +0 -52
  459. package/tools/bench/EXP3.md +0 -61
  460. package/tools/bench/cclay_bench_extract_incam.py +0 -125
  461. package/tools/bench/cclay_bench_extract_obs.py +0 -204
  462. package/tools/bench/cclay_bench_runner.py +0 -42
  463. package/tools/bench/cube-contact.mjs +0 -162
  464. package/tools/bench/exp3.mjs +0 -152
  465. package/tools/bench/extract-bench-lib.mjs +0 -216
  466. package/tools/bench/extract-bench.mjs +0 -302
  467. package/tools/bench/fal-generate.mjs +0 -88
  468. package/tools/bench/fit/README.md +0 -189
  469. package/tools/bench/fit/camera.mjs +0 -59
  470. package/tools/bench/fit/contact.mjs +0 -419
  471. package/tools/bench/fit/footlock.mjs +0 -234
  472. package/tools/bench/fit/motion.mjs +0 -85
  473. package/tools/bench/fit/pin.mjs +0 -27
  474. package/tools/bench/fit/remote.mjs +0 -105
  475. package/tools/bench/fit-bench.mjs +0 -126
  476. package/tools/bench/fit-sanity.mjs +0 -45
  477. package/tools/bench/metrics.mjs +0 -313
  478. package/tools/bench/obs/depth.mjs +0 -258
  479. package/tools/bench/obs/extrinsics.mjs +0 -175
  480. package/tools/bench/obs/fit_mannequin_betas.py +0 -340
  481. package/tools/bench/obs/ground.mjs +0 -294
  482. package/tools/bench/obs/heading.mjs +0 -151
  483. package/tools/bench/obs/ladder.mjs +0 -555
  484. package/tools/bench/obs/mannequin-betas.json +0 -124
  485. package/tools/bench/obs/remote.mjs +0 -83
  486. package/tools/bench/obs/rest_joints.py +0 -52
  487. package/tools/bench/obs/ybot-targets.mjs +0 -49
  488. package/tools/bench/obs-bench.mjs +0 -520
  489. package/tools/bench/score.mjs +0 -343
  490. package/tools/bench/summarize.mjs +0 -86
  491. package/tools/demo-worker/README.md +0 -137
  492. package/tools/demo-worker/api-client.mjs +0 -233
  493. package/tools/demo-worker/generate.mjs +0 -301
  494. package/tools/demo-worker/index.mjs +0 -466
  495. package/tools/demo-worker/sign-core.mjs +0 -112
  496. package/tools/demo-worker/sign.mjs +0 -161
  497. package/tools/gt-render/browser.mjs +0 -182
  498. package/tools/gt-render/camera-math.mjs +0 -276
  499. package/tools/gt-render/page.mjs +0 -267
  500. package/tools/gt-render/render.mjs +0 -514
  501. package/tools/gt-render/scene-box.mjs +0 -31
  502. package/tools/gt-render/take-transform.mjs +0 -87
  503. package/tools/kimodo/compare-seam.mjs +0 -108
  504. package/tools/kimodo/constraints.mjs +0 -244
  505. package/tools/kimodo/edit.mjs +0 -259
  506. package/tools/kimodo/effector-constraints.mjs +0 -200
  507. package/tools/kimodo/generate.mjs +0 -646
  508. package/tools/kimodo/local-output.mjs +0 -265
  509. package/tools/kimodo/measure-pose.mjs +0 -65
  510. package/tools/kimodo/measure-preserve.mjs +0 -899
  511. package/tools/kimodo/measure-waypoints.mjs +0 -55
  512. package/tools/kimodo/mlx-generate.py +0 -111
  513. package/tools/kimodo/pose-constraints.mjs +0 -248
  514. package/tools/kimodo/preserve-mask.mjs +0 -543
  515. package/tools/kimodo/read-npz.mjs +0 -189
  516. package/tools/kimodo/run-edit-on-box.mjs +0 -155
  517. package/tools/kimodo/run-sequence-on-box.mjs +0 -178
  518. package/tools/kimodo/runner.mjs +0 -284
  519. package/tools/kimodo/seam-batch.mjs +0 -77
  520. package/tools/kimodo/setup-local.mjs +0 -30
  521. package/tools/kimodo/setup-on-box.sh +0 -339
  522. package/tools/kimodo/soma77-to-cskel27.mjs +0 -305
  523. package/tools/morphgs/assets/gen_truth.py +0 -39
  524. package/tools/morphgs/assets/mesh_ori_rig.txt +0 -27
  525. package/tools/morphgs/assets/playback-check.mjs +0 -30
  526. package/tools/morphgs/demo-gate.sh +0 -26
  527. package/tools/morphgs/fbx2morphgs.mjs +0 -68
  528. package/tools/morphgs/morphgs-to-cskel27.mjs +0 -105
  529. package/tools/morphgs/patches/preprocess_src-none-mode.patch +0 -76
  530. package/tools/morphgs/setup-on-cluster.sh +0 -126
  531. package/tools/projflow/driver.py +0 -1069
  532. package/tools/projflow/generate.mjs +0 -805
  533. package/tools/projflow/hml22-to-cskel27.mjs +0 -735
  534. package/tools/projflow/line-edit-job.mjs +0 -547
  535. package/tools/projflow/replay.mjs +0 -452
  536. package/tools/projflow/runner.mjs +0 -269
  537. package/tools/projflow/service.mjs +0 -742
  538. package/tools/track/DESIGN.md +0 -513
  539. package/tools/track/backfill-provenance.mjs +0 -116
  540. package/tools/track/budget.mjs +0 -8
  541. package/tools/track/check-rig.mjs +0 -209
  542. package/tools/track/diagnostics.schema.json +0 -60
  543. package/tools/track/export-rig.mjs +0 -256
  544. package/tools/track/fallback.mjs +0 -7
  545. package/tools/track/fk-parity-fixture.mjs +0 -161
  546. package/tools/track/gate.mjs +0 -349
  547. package/tools/track/masks.mjs +0 -318
  548. package/tools/track/metrics.mjs +0 -201
  549. package/tools/track/publish-obs.mjs +0 -108
  550. package/tools/track/py/check_env.py +0 -61
  551. package/tools/track/py/eval_lr.py +0 -229
  552. package/tools/track/py/lr_viterbi.py +0 -294
  553. package/tools/track/py/masks.py +0 -405
  554. package/tools/track/py/objective.py +0 -382
  555. package/tools/track/py/rig.py +0 -508
  556. package/tools/track/py/scene.py +0 -326
  557. package/tools/track/py/test_joint_indices.py +0 -123
  558. package/tools/track/py/test_lr_viterbi.py +0 -195
  559. package/tools/track/py/test_masks.py +0 -229
  560. package/tools/track/py/test_rig.py +0 -288
  561. package/tools/track/py/test_scene.py +0 -354
  562. package/tools/track/py/test_track.py +0 -328
  563. package/tools/track/py/track.py +0 -411
  564. package/tools/track/remote.mjs +0 -140
  565. package/tools/track/rig-dump.mjs +0 -227
  566. package/tools/track/run-box-tests.mjs +0 -14
  567. package/tools/track/run-box.mjs +0 -168
  568. package/tools/track/setup-box.sh +0 -59
  569. package/tools/track/study-2d.mjs +0 -728
  570. /package/src/{ardy → motion}/collision-blockers.js +0 -0
  571. /package/src/{ardy → motion}/ground.js +0 -0
  572. /package/src/{ardy → motion}/ik-drag.js +0 -0
  573. /package/src/{ardy → motion}/ik-key-json.js +0 -0
  574. /package/src/{ardy → motion}/ik.js +0 -0
  575. /package/src/{ardy → motion}/physics-surface.js +0 -0
  576. /package/src/{ardy → motion}/playback-clock.js +0 -0
  577. /package/src/{ardy → motion}/range-pin.js +0 -0
@@ -1,1194 +0,0 @@
1
- import * as THREE from "three";
2
- import { findBone, ikTouch, ikBakeKeyframe, solveHipsTranslate, solveIk } from "./ik.js";
3
-
4
- /**
5
- * AutoPhysics: a physical-plausibility pass for generated motion, modelled on
6
- * Cascadeur's ballistic-trajectory tool.
7
- *
8
- * While a character is in the air nothing but gravity acts on it, so its
9
- * CENTRE OF MASS must trace a parabola: a vertical velocity that falls off at
10
- * exactly g. Generated clips routinely break that — the character floats at
11
- * the apex, hovers for a few frames, or decelerates on the way down — and the
12
- * eye reads it instantly as "fake", even when every limb pose is fine.
13
- *
14
- * v1 fixes the ROOT height during airborne spans, which is both the cheapest
15
- * and the most effective correction available:
16
- *
17
- * - The CoM is a mass-weighted average of body-segment midpoints (Dempster's
18
- * anthropometric fractions), so it already accounts for a swinging arm or a
19
- * tucked leg without needing to touch either.
20
- * - Translating the hips rigidly moves EVERY segment by the same vector, so
21
- * the CoM moves by exactly that vector too. The correction needed at a
22
- * frame is therefore literally `fittedY − actualY` — no iteration, no
23
- * solver.
24
- * - Only airborne spans are touched. A grounded frame has a foot carrying
25
- * load, gravity is not the only force, and no parabola applies.
26
- *
27
- * Three rules earn the pass its right to run unattended on real clips, and
28
- * each exists because the naive version failed on one:
29
- *
30
- * - VERTICAL ONLY. Floating is a height artefact. Line-fitting x/z as well
31
- * turned a mis-detected span into metres of horizontal displacement, so
32
- * the lateral track is now read-only: fitted positions keep each sample's
33
- * own x and z.
34
- * - CLIP-RELATIVE GROUND. Absolute floor thresholds (bone height vs. a
35
- * measured mesh drop) misclassify standing as flight on any rig whose
36
- * soles rest a couple of centimetres above y = 0 — which is most of them.
37
- * The planted height of each foot marker is instead MEASURED from the clip
38
- * itself, as that marker's minimum over the range.
39
- * - REFUSE RATHER THAN WRECK. A span whose fit is wild is not a flight
40
- * phase, it is a detection error; those spans are reported and skipped
41
- * instead of being "corrected" by half a body length.
42
- * - ANCHORED ARCS. The parabola is pinned to the take-off and landing
43
- * samples rather than least-squares fitted, so the correction is zero
44
- * where the pass hands back to the clip and largest where the floating
45
- * actually is. That single choice is what makes the hand-off seamless and
46
- * the whole pass a fixed point; see fitBallisticArc.
47
- *
48
- * Corrections are written through ikTouch/ikBakeKeyframe, so an AutoPhysics
49
- * pass is an ordinary IK correction key: scrubbable, undoable, and eased back
50
- * into the clip outside its keyed range by ikEvaluate's blend window.
51
- */
52
-
53
- /**
54
- * Segment mass fractions of total body mass, from Dempster's cadaver study
55
- * (Dempster, W.T., "Space Requirements of the Seated Operator", WADC TR
56
- * 55-159, 1955), in the tabulation popularised by Winter, "Biomechanics and
57
- * Motor Control of Human Movement". The trunk (0.497) is split into pelvis
58
- * (0.142) and thorax+abdomen (0.355); distal pairs are merged because their
59
- * combined centroid still lands near the segment midpoint and Mixamo's hand
60
- * and toe bones are too short to place reliably:
61
- *
62
- * head+neck 0.081 · trunk 0.497 · upper arm 0.028 · forearm 0.016 +
63
- * hand 0.006 = 0.022 · thigh 0.100 · shank 0.0465 + foot 0.0145 = 0.061
64
- *
65
- * Each entry names the two Mixamo bones whose MIDPOINT carries the fraction.
66
- * Dempster's true centres of mass sit ~43% down each segment rather than at
67
- * 50%, but the difference is a couple of centimetres on a limb and cancels
68
- * almost exactly between the left and right sides — while the midpoint keeps
69
- * the table readable and needs no extra per-segment constant.
70
- *
71
- * The fractions sum to 1, so the weighted average below is a true CoM.
72
- */
73
- export const SEGMENT_MASSES = [
74
- { id: "pelvis", start: "mixamorigHips", end: "mixamorigSpine1", mass: 0.142 },
75
- { id: "torso", start: "mixamorigSpine1", end: "mixamorigNeck", mass: 0.355 },
76
- { id: "head", start: "mixamorigNeck", end: "mixamorigHead", mass: 0.081 },
77
- { id: "leftUpperArm", start: "mixamorigLeftArm", end: "mixamorigLeftForeArm", mass: 0.028 },
78
- { id: "leftForeArmHand", start: "mixamorigLeftForeArm", end: "mixamorigLeftHand", mass: 0.022 },
79
- { id: "rightUpperArm", start: "mixamorigRightArm", end: "mixamorigRightForeArm", mass: 0.028 },
80
- { id: "rightForeArmHand", start: "mixamorigRightForeArm", end: "mixamorigRightHand", mass: 0.022 },
81
- { id: "leftThigh", start: "mixamorigLeftUpLeg", end: "mixamorigLeftLeg", mass: 0.100 },
82
- { id: "leftShankFoot", start: "mixamorigLeftLeg", end: "mixamorigLeftFoot", mass: 0.061 },
83
- { id: "rightThigh", start: "mixamorigRightUpLeg", end: "mixamorigRightLeg", mass: 0.100 },
84
- { id: "rightShankFoot", start: "mixamorigRightLeg", end: "mixamorigRightFoot", mass: 0.061 },
85
- ];
86
-
87
- /**
88
- * World-space centre of mass of the rig's CURRENT pose: the mass-weighted
89
- * average of every segment's midpoint. Returns null when any segment bone is
90
- * missing — a partial CoM would silently drift the correction toward
91
- * whichever half of the body did resolve, so the caller must instead hide the
92
- * tool (the same all-or-nothing policy resolveIkRig uses).
93
- */
94
- export function computeCenterOfMass(rig) {
95
- if (!rig) return null;
96
- rig.updateMatrixWorld(true);
97
- const com = new THREE.Vector3();
98
- const start = new THREE.Vector3();
99
- const end = new THREE.Vector3();
100
- let total = 0;
101
- for (const segment of SEGMENT_MASSES) {
102
- const a = findBone(rig, segment.start);
103
- const b = findBone(rig, segment.end);
104
- if (!a || !b) return null;
105
- a.getWorldPosition(start);
106
- b.getWorldPosition(end);
107
- com.addScaledVector(start.add(end).multiplyScalar(0.5), segment.mass);
108
- total += segment.mass;
109
- }
110
- if (total <= 0) return null;
111
- return com.divideScalar(total);
112
- }
113
-
114
- /** Shortest run of airborne frames worth correcting. Anything briefer is a
115
- * one- or two-frame detection blip from a foot skimming the floor, not a
116
- * flight phase, and fitting a parabola to it would amplify the noise. */
117
- export const MIN_AIRBORNE_FRAMES = 4;
118
-
119
- /**
120
- * How far above its own PLANTED height a foot marker must rise before the
121
- * frame counts as airborne.
122
- *
123
- * This replaces the absolute floor test the first version used, which was
124
- * wrong in practice on every rig measured: characters rest with their soles a
125
- * couple of centimetres above y = 0, ankles sit 12 cm up, and measured mesh
126
- * drops disagree with both — so a plain STANDING frame cleared the absolute
127
- * thresholds and a whole clip was declared airborne. The planted height is
128
- * therefore taken from the clip (see `plantedFloor`), and this constant is
129
- * the only absolute number left: 6 cm is above the few centimetres a heel
130
- * lifts during a normal stride or a weight shift, and far below the tens of
131
- * centimetres of a real jump.
132
- */
133
- export const AIRBORNE_LIFT = 0.06;
134
-
135
- /**
136
- * The floor markers, matching ik.js's contact model: the ankle bones plus the
137
- * toe bases, because a foot rolling onto its toe keeps the ankle high while
138
- * the character is still very much on the ground.
139
- */
140
- export const FOOT_MARKERS = ["mixamorigLeftFoot", "mixamorigRightFoot", "mixamorigLeftToeBase", "mixamorigRightToeBase"];
141
-
142
- /**
143
- * World Y of every foot marker in the rig's CURRENT pose, keyed by bone name.
144
- * Returns null when a marker is missing — the caller then cannot judge
145
- * contact at all and must decline to correct anything.
146
- */
147
- export function markerHeights(rig) {
148
- if (!rig) return null;
149
- rig.updateMatrixWorld(true);
150
- const position = new THREE.Vector3();
151
- const out = {};
152
- for (const name of FOOT_MARKERS) {
153
- const bone = findBone(rig, name);
154
- if (!bone) return null;
155
- bone.getWorldPosition(position);
156
- out[name] = position.y;
157
- }
158
- return out;
159
- }
160
-
161
- /**
162
- * Each marker's PLANTED height for this clip: its minimum over every sampled
163
- * frame. That minimum is by definition a frame where the marker was as low as
164
- * this performance ever puts it, which is the only trustworthy definition of
165
- * "on the ground" available without trusting the export's floor — and it
166
- * absorbs sole float, mesh-drop measurement error and a mis-set floorY all at
167
- * once. Returns null for an empty sample list.
168
- */
169
- export function plantedFloor(perFrameHeights) {
170
- if (!perFrameHeights?.length) return null;
171
- const out = {};
172
- for (const name of FOOT_MARKERS) {
173
- let min = Infinity;
174
- for (const heights of perFrameHeights) {
175
- const y = heights?.[name];
176
- if (Number.isFinite(y) && y < min) min = y;
177
- }
178
- if (!Number.isFinite(min)) return null;
179
- out[name] = min;
180
- }
181
- return out;
182
- }
183
-
184
- /** The airborne rule itself, over PRE-MEASURED heights: every marker must
185
- * clear its own planted height by `lift`. Exported both so the driver never
186
- * re-poses the rig to re-ask a question it already has the answer to, and so
187
- * the rule can be checked against heights measured off a real character
188
- * without needing that character present. */
189
- export function heightsAirborne(heights, planted, lift = AIRBORNE_LIFT) {
190
- if (!heights || !planted) return false;
191
- for (const name of FOOT_MARKERS) {
192
- if (!(heights[name] > planted[name] + lift)) return false;
193
- }
194
- return true;
195
- }
196
-
197
- /**
198
- * Is the rig's CURRENT pose airborne, relative to this clip's planted floor?
199
- * True only when EVERY foot marker — both ankles and both toe bases — rises
200
- * more than `lift` above its own planted height. A single low toe makes the
201
- * frame grounded, which is the conservative direction: mistaking flight for
202
- * contact merely skips a correction, while mistaking contact for flight drags
203
- * a planted foot off the floor (or, as the first version managed, buries a
204
- * standing character to the chest).
205
- *
206
- * `planted` comes from `plantedFloor`. Returns false when it is missing or a
207
- * marker bone cannot be found — an unprovable claim of flight, again the safe
208
- * answer.
209
- */
210
- export function frameAirborne(rig, planted, lift = AIRBORNE_LIFT) {
211
- return heightsAirborne(markerHeights(rig), planted, lift);
212
- }
213
-
214
- /** Contact must stay close to its clip-relative low and vertically settled.
215
- * The final run gate below is stricter still: an ankle that wanders more than
216
- * three centimetres was walking, not planted, and is never snapped backward. */
217
- export const GROUND_CONTACT_LIFT = 0.06;
218
- const GROUND_CONTACT_VERTICAL_SPEED = 0.6;
219
- export const GROUND_CONTACT_HORIZONTAL_SPEED = 0.12;
220
- const GROUND_CONTACT_MIN_SECONDS = 0.10;
221
- export const GROUND_LOCK_MAX_PULL = 0.03;
222
- export const GROUND_LOCK_EPSILON = 0.005;
223
- export const GROUND_LOCK_REACH_LIFT = 0.005;
224
- const GROUND_FLOOR_MAX_LIFT = 0.15;
225
- const GROUND_FLOOR_EPSILON = 0.005;
226
-
227
- const FOOT_SIDES = [
228
- { side: "left", chainId: "leftFoot", ankle: "mixamorigLeftFoot", toe: "mixamorigLeftToeBase", height: "LeftFoot" },
229
- { side: "right", chainId: "rightFoot", ankle: "mixamorigRightFoot", toe: "mixamorigRightToeBase", height: "RightFoot" },
230
- ];
231
-
232
- /** World positions of the same four markers markerHeights reads. */
233
- export function markerPositions(rig) {
234
- if (!rig) return null;
235
- rig.updateMatrixWorld(true);
236
- const out = {};
237
- for (const name of FOOT_MARKERS) {
238
- const bone = findBone(rig, name);
239
- if (!bone) return null;
240
- out[name] = bone.getWorldPosition(new THREE.Vector3());
241
- }
242
- return out;
243
- }
244
-
245
- function median(values) {
246
- if (!values.length) return 0;
247
- const sorted = [...values].sort((a, b) => a - b);
248
- const middle = Math.floor(sorted.length / 2);
249
- return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) * 0.5;
250
- }
251
-
252
- function trueRuns(flags, minFrames) {
253
- const runs = [];
254
- let open = -1;
255
- for (let index = 0; index <= flags.length; index += 1) {
256
- const on = index < flags.length && flags[index];
257
- if (on && open < 0) open = index;
258
- if (on || open < 0) continue;
259
- if (index - open >= minFrames) runs.push({ start: open, end: index - 1, length: index - open });
260
- open = -1;
261
- }
262
- return runs;
263
- }
264
-
265
- /**
266
- * Find contact runs that are safe to lock. Height and vertical speed find a
267
- * stance candidate; total XZ wander then separates a planted foot from a slow
268
- * step. Runs outside the pull limit are reported as rejected instead of being
269
- * chopped into artificial mini-plants with visible jumps between anchors.
270
- */
271
- export function detectGroundContactSpans({
272
- positions,
273
- planted,
274
- fps = 30,
275
- startFrame = 0,
276
- contactLift = GROUND_CONTACT_LIFT,
277
- verticalSpeed = GROUND_CONTACT_VERTICAL_SPEED,
278
- horizontalSpeed = GROUND_CONTACT_HORIZONTAL_SPEED,
279
- minSeconds = GROUND_CONTACT_MIN_SECONDS,
280
- maxPull = GROUND_LOCK_MAX_PULL,
281
- } = {}) {
282
- if (!positions?.length || !planted) return { spans: [], rejected: [] };
283
- const rate = Number.isFinite(fps) && fps > 0 ? fps : 30;
284
- const minFrames = Math.max(3, Math.round(minSeconds * rate));
285
- const half = Math.max(1, Math.round((2 / 30) * rate));
286
- const spans = [];
287
- const rejected = [];
288
- for (const foot of FOOT_SIDES) {
289
- const flags = positions.map((frame, index) => {
290
- const ankle = frame?.[foot.ankle];
291
- const toe = frame?.[foot.toe];
292
- if (!ankle || !toe) return false;
293
- const before = positions[Math.max(0, index - half)]?.[foot.ankle];
294
- const after = positions[Math.min(positions.length - 1, index + half)]?.[foot.ankle];
295
- if (!before || !after) return false;
296
- const seconds = Math.max(1 / rate, (Math.min(positions.length - 1, index + half) - Math.max(0, index - half)) / rate);
297
- const vy = Math.abs(after.y - before.y) / seconds;
298
- const vxz = Math.hypot(after.x - before.x, after.z - before.z) / seconds;
299
- const low = Math.min(ankle.y - planted[foot.ankle], toe.y - planted[foot.toe]);
300
- // A low foot can still be in the sliding/swinging part of a step.
301
- // Horizontal speed breaks a long low run into its real plant phases;
302
- // maxPull below remains the second guard against slow large drifts.
303
- return low <= contactLift && vy <= verticalSpeed && vxz <= horizontalSpeed;
304
- });
305
- for (const run of trueRuns(flags, minFrames)) {
306
- const ankles = positions.slice(run.start, run.end + 1).map((frame) => frame[foot.ankle]);
307
- const anchor = new THREE.Vector3(
308
- median(ankles.map((point) => point.x)),
309
- median(ankles.map((point) => point.y)),
310
- median(ankles.map((point) => point.z)),
311
- );
312
- const pull = Math.max(...ankles.map((point) => Math.hypot(point.x - anchor.x, point.z - anchor.z)));
313
- const span = {
314
- side: foot.side,
315
- chainId: foot.chainId,
316
- start: startFrame + run.start,
317
- end: startFrame + run.end,
318
- length: run.length,
319
- anchor,
320
- maxPull: pull,
321
- };
322
- if (pull <= maxPull + 1e-9) spans.push(span);
323
- else rejected.push({ ...span, reason: "foot-moving" });
324
- }
325
- }
326
- return { spans, rejected };
327
- }
328
-
329
- /**
330
- * Group per-frame airborne flags into contiguous spans of at least
331
- * `minLength` frames. `isAirborne` is either an array indexed from
332
- * `startFrame` or a predicate called with the absolute frame number.
333
- * Returned spans carry ABSOLUTE frame numbers, inclusive at both ends.
334
- */
335
- export function detectAirborneSpans({ frames, isAirborne, startFrame = 0, minLength = MIN_AIRBORNE_FRAMES } = {}) {
336
- const count = Math.max(0, Math.round(frames) || 0);
337
- const flagAt = typeof isAirborne === "function"
338
- ? (index) => Boolean(isAirborne(startFrame + index))
339
- : (index) => Boolean(isAirborne?.[index]);
340
- const spans = [];
341
- let open = -1;
342
- for (let index = 0; index <= count; index += 1) {
343
- const flag = index < count && flagAt(index);
344
- if (flag && open < 0) open = index;
345
- if (flag || open < 0) continue;
346
- const length = index - open;
347
- if (length >= minLength) spans.push({ start: startFrame + open, end: startFrame + index - 1, length });
348
- open = -1;
349
- }
350
- return spans;
351
- }
352
-
353
- /**
354
- * The clearance a marker needs to STAY inside a span it is already part of —
355
- * the low half of a hysteresis pair with AIRBORNE_LIFT.
356
- *
357
- * A single threshold cannot both reject a stride and accept a take-off. The
358
- * 6 cm entry lift is set high enough to ignore a heel lift, which means the
359
- * first and last frames of a real jump — the ones where the foot has only
360
- * just left the ground — fall below it and the span is truncated at both
361
- * ends. A truncated span then gets a zero pin one frame from a full-strength
362
- * correction, and the result is a one-frame snap: on the QA clip the CoM's
363
- * vertical acceleration jumped from −8.6 to −36.5 m/s², roughly 4 g, at the
364
- * frame after the pin. Growing each span back out at 2 cm recovers exactly
365
- * those take-off and landing frames without letting a stride start one.
366
- */
367
- export const EXIT_LIFT = 0.02;
368
-
369
- /**
370
- * How deep an interior dip in the centre of mass must be, measured against
371
- * the lower of the two peaks around it, before it is read as a landing
372
- * between two separate arcs rather than noise inside one.
373
- *
374
- * Two hops in quick succession never plant a foot low enough to break the
375
- * span, so detection hands over one span holding two parabolas. Fitting a
376
- * single arc to that asks for 73 cm corrections at a 42 cm RMS — the sanity
377
- * gate then refuses the whole thing and the clip's actual jump goes
378
- * uncorrected. 8 cm of prominence is far more than a CoM ripple inside one
379
- * flight and far less than the trough between two real hops.
380
- */
381
- export const SPLIT_DEPTH = 0.08;
382
-
383
- /** Frames at each end of an arc over which the correction eases in and out.
384
- * See `edgeTaperWeight`. */
385
- export const EDGE_TAPER = 2;
386
-
387
- /**
388
- * Widen each span outward while every marker still clears its planted height
389
- * by `exitLift`, without ever reaching into a neighbouring span.
390
- *
391
- * `canExtend` is a per-frame boolean array indexed from `startFrame` — the
392
- * same airborne test run at the lower threshold. Spans are processed in
393
- * order, and each one's left growth stops at the previous span's ALREADY
394
- * EXPANDED end, so two spans can end up adjacent but never overlapping or
395
- * merging.
396
- */
397
- export function expandAirborneSpans({ spans, canExtend, startFrame = 0 } = {}) {
398
- const list = spans ?? [];
399
- const flags = canExtend ?? [];
400
- const flagAt = (frame) => Boolean(flags[frame - startFrame]);
401
- const lastFrame = startFrame + flags.length - 1;
402
- const out = [];
403
- for (let index = 0; index < list.length; index += 1) {
404
- const span = list[index];
405
- const floorLimit = index > 0 ? out[index - 1].end + 1 : startFrame;
406
- const ceilLimit = index + 1 < list.length ? list[index + 1].start - 1 : lastFrame;
407
- let from = span.start;
408
- let to = span.end;
409
- while (from - 1 >= floorLimit && flagAt(from - 1)) from -= 1;
410
- while (to + 1 <= ceilLimit && flagAt(to + 1)) to += 1;
411
- out.push({ start: from, end: to, length: to - from + 1 });
412
- }
413
- return out;
414
- }
415
-
416
- /** The deepest interior dip in `values[from..to]` whose prominence — its depth
417
- * below the LOWER of the highest point on each side — clears `splitDepth`, or
418
- * −1 when the range holds no such dip. Prominence rather than a bare local
419
- * minimum is what distinguishes a landing from a wobble: a 2 mm ripple at the
420
- * apex is a local minimum too. */
421
- function deepestTrough(values, from, to, splitDepth) {
422
- const span = to - from + 1;
423
- if (span < 3) return -1;
424
- const leftMax = new Array(span);
425
- const rightMax = new Array(span);
426
- leftMax[0] = values[from];
427
- for (let index = 1; index < span; index += 1) leftMax[index] = Math.max(leftMax[index - 1], values[from + index]);
428
- rightMax[span - 1] = values[to];
429
- for (let index = span - 2; index >= 0; index -= 1) rightMax[index] = Math.max(rightMax[index + 1], values[from + index]);
430
- let best = -1;
431
- let bestProminence = splitDepth;
432
- for (let index = 1; index < span - 1; index += 1) {
433
- const prominence = Math.min(leftMax[index], rightMax[index]) - values[from + index];
434
- if (prominence > bestProminence) {
435
- bestProminence = prominence;
436
- best = from + index;
437
- }
438
- }
439
- return best;
440
- }
441
-
442
- /**
443
- * Cut a span's centre-of-mass height track into single ballistic arcs at its
444
- * interior troughs, recursively, and return the resulting index ranges as
445
- * inclusive `[from, to]` pairs (relative to the input array).
446
- *
447
- * The trough frame ENDS its left arc: it is the lowest point of the descent
448
- * that precedes it, and the arc after it launches from the next frame. No
449
- * range is discarded here — a caller that needs a minimum length filters the
450
- * result, because "too short to fit" and "not an arc" are different verdicts
451
- * worth reporting differently.
452
- */
453
- export function splitAtTroughs(heights, { splitDepth = SPLIT_DEPTH } = {}) {
454
- const values = heights ?? [];
455
- const out = [];
456
- const recurse = (from, to) => {
457
- const cut = deepestTrough(values, from, to, splitDepth);
458
- if (cut < 0) {
459
- out.push([from, to]);
460
- return;
461
- }
462
- recurse(from, cut);
463
- recurse(cut + 1, to);
464
- };
465
- if (values.length) recurse(0, values.length - 1);
466
- return out;
467
- }
468
-
469
- /**
470
- * Correction strength at `index` within an arc of `length` frames: 1 across
471
- * the middle, easing to a fraction over the first and last `taper` frames.
472
- *
473
- * Applying a correction at full strength on the very first frame of an arc,
474
- * one frame after a zero pin, is a step change in position — which is an
475
- * impulse in acceleration, and reads as a one-frame snap however correct the
476
- * arc either side of it is. Ramping in over two frames spreads that step
477
- * across three, and the smoothstep shape means the ramp meets the flat middle
478
- * without a corner of its own. The interior — where the physics claim
479
- * actually lives — is untouched at weight 1.
480
- */
481
- export function edgeTaperWeight(index, length, taper = EDGE_TAPER) {
482
- if (!(taper > 0) || length <= 2) return 1;
483
- const ramp = Math.min(Math.round(taper), Math.floor((length - 1) / 2));
484
- if (ramp <= 0) return 1;
485
- const steps = Math.min(index, length - 1 - index);
486
- if (steps >= ramp) return 1;
487
- const x = (steps + 1) / (ramp + 1);
488
- return x * x * (3 - 2 * x);
489
- }
490
-
491
- /** Standard gravity (m/s²). */
492
- export const GRAVITY = 9.81;
493
-
494
- /**
495
- * Least-squares line through (t, value) pairs, solved from the 2×2 normal
496
- * equations directly — two unknowns need no matrix library, and writing the
497
- * closed form keeps the module dependency-free.
498
- *
499
- * [ n Σt ] [ a ] [ Σv ]
500
- * [ Σt Σt² ] [ b ] = [ Σt·v ]
501
- *
502
- * Returns { a, b } for value ≈ a + b·t. A single sample (or a degenerate
503
- * determinant, i.e. every t identical) yields the mean with zero slope,
504
- * which is the correct limit rather than a divide-by-zero.
505
- */
506
- function fitLine(times, values) {
507
- const n = times.length;
508
- if (n === 0) return { a: 0, b: 0 };
509
- let sumT = 0;
510
- let sumTT = 0;
511
- let sumV = 0;
512
- let sumTV = 0;
513
- for (let index = 0; index < n; index += 1) {
514
- const t = times[index];
515
- const v = values[index];
516
- sumT += t;
517
- sumTT += t * t;
518
- sumV += v;
519
- sumTV += t * v;
520
- }
521
- const det = n * sumTT - sumT * sumT;
522
- if (Math.abs(det) < 1e-12) return { a: sumV / n, b: 0 };
523
- return {
524
- a: (sumTT * sumV - sumT * sumTV) / det,
525
- b: (n * sumTV - sumT * sumV) / det,
526
- };
527
- }
528
-
529
- /**
530
- * Fit a ballistic HEIGHT curve to consecutive CoM samples.
531
- *
532
- * Gravity is not negotiable, so it is FIXED at `g` and the only free
533
- * parameters are the launch height y0 and the launch velocity v0.
534
- * Substituting d = y + ½·g·t² turns y = y0 + v0·t − ½·g·t² into the straight
535
- * line d = y0 + v0·t, which the 2×2 normal equations above solve. Leaving g
536
- * free would let the fitter "explain" a hover as weak gravity — exactly the
537
- * artefact this pass exists to remove.
538
- *
539
- * The horizontal track is deliberately NOT fitted. In principle x and z are
540
- * straight lines in flight; in practice fitting them meant that any span the
541
- * detector got wrong came back with metres of sideways displacement, while
542
- * the artefact users actually see — floating — is purely vertical. Each
543
- * fitted position therefore carries its own sample's x and z unchanged, so
544
- * `fitted − actual` is a pure Y vector by construction.
545
- *
546
- * `samples` are CoM positions at CONSECUTIVE frames; times come from `fps`.
547
- *
548
- * `fitFrom`/`fitTo` restrict WHICH samples steer the solve, while positions
549
- * are still produced for every sample by evaluating the curve. The driver uses
550
- * this to exclude the frames it intends to taper: those frames keep part of
551
- * the clip's own trajectory on purpose, so letting them pull the fit would
552
- * both bias the arc and — because the next run would then fit a slightly
553
- * different curve — cost the pass its idempotence.
554
- *
555
- * Returns the fitted per-frame positions, the recovered launch state, and the
556
- * RMS vertical residual OVER THE FITTED SUBSET (the honest "how ballistic was
557
- * this already?" number the span gate reads).
558
- */
559
- export function fitBallistic(samples, fps, g = GRAVITY, { fitFrom = 0, fitTo = null } = {}) {
560
- const list = samples ?? [];
561
- const rate = Number.isFinite(fps) && fps > 0 ? fps : 30;
562
- const times = list.map((_, index) => index / rate);
563
- const from = Math.max(0, Math.min(fitFrom, list.length - 1));
564
- const to = Math.min(list.length - 1, Number.isFinite(fitTo) ? fitTo : list.length - 1);
565
- const used = [];
566
- for (let index = from; index <= to; index += 1) used.push(index);
567
- // d = y + ½·g·t² is linear in t with slope v0 and intercept y0.
568
- const vertical = fitLine(
569
- used.map((index) => times[index]),
570
- used.map((index) => list[index].y + 0.5 * g * times[index] * times[index]),
571
- );
572
- const positions = list.map((sample, index) => {
573
- const t = times[index];
574
- return new THREE.Vector3(sample.x ?? 0, vertical.a + vertical.b * t - 0.5 * g * t * t, sample.z ?? 0);
575
- });
576
- let sumSq = 0;
577
- for (const index of used) {
578
- const error = list[index].y - positions[index].y;
579
- sumSq += error * error;
580
- }
581
- return {
582
- positions,
583
- y0: vertical.a,
584
- v0: vertical.b,
585
- g,
586
- fitFrom: used.length ? used[0] : 0,
587
- fitTo: used.length ? used[used.length - 1] : 0,
588
- residual: used.length ? Math.sqrt(sumSq / used.length) : 0,
589
- };
590
- }
591
-
592
- /**
593
- * The parabola a flight between two known contacts MUST follow: gravity is
594
- * fixed at `g` and the curve is pinned to the first and last sample, so the
595
- * launch state is determined outright rather than fitted.
596
- *
597
- * y(0) = yA, y(T) = yB, y(t) = yA + v0·t − ½·g·t² ⟹ v0 = (yB − yA + ½gT²)/T
598
- *
599
- * This is the right model for an ARC — as opposed to `fitBallistic`, which
600
- * answers the different question "what parabola best explains these samples".
601
- * Least squares spreads its error evenly, which sounds fairer and behaves
602
- * worse in every way that matters here:
603
- *
604
- * - Its correction is LARGEST at the arc's ends, exactly where the pass has
605
- * to hand back to the clip, so the frames beside a grounded pin take the
606
- * biggest step. Anchoring makes the correction identically zero there, and
607
- * the hand-off seamless.
608
- * - The take-off and landing frames are the trustworthy ones: the feet were
609
- * on the ground a frame ago, so the clip's height there is constrained by
610
- * something real. The floating is in the middle. Averaging the reliable
611
- * ends together with the unreliable middle throws that information away.
612
- * - It makes the pass a FIXED POINT. The endpoints are never moved, so a
613
- * second run derives the identical curve and finds nothing left to do.
614
- *
615
- * The trade is deliberate: an arc whose take-off or landing height is itself
616
- * wrong keeps that error, because this model treats contact frames as ground
617
- * truth. The sanity gate in the driver is what catches the case where they
618
- * are wrong enough to matter.
619
- *
620
- * `residual` is the RMS distance of the samples from that curve — how
621
- * un-ballistic the arc was to begin with.
622
- */
623
- function fitBallisticArc(samples, fps, g = GRAVITY) {
624
- const list = samples ?? [];
625
- const rate = Number.isFinite(fps) && fps > 0 ? fps : 30;
626
- const times = list.map((_, index) => index / rate);
627
- if (list.length === 0) return { positions: [], y0: 0, v0: 0, g, residual: 0 };
628
- const y0 = list[0].y;
629
- const duration = times[times.length - 1];
630
- const v0 = duration > 0
631
- ? (list[list.length - 1].y - y0 + 0.5 * g * duration * duration) / duration
632
- : 0;
633
- const positions = list.map((sample, index) => {
634
- const t = times[index];
635
- return new THREE.Vector3(sample.x ?? 0, y0 + v0 * t - 0.5 * g * t * t, sample.z ?? 0);
636
- });
637
- let sumSq = 0;
638
- for (let index = 0; index < list.length; index += 1) {
639
- const error = list[index].y - positions[index].y;
640
- sumSq += error * error;
641
- }
642
- return { positions, y0, v0, g, residual: Math.sqrt(sumSq / list.length) };
643
- }
644
-
645
- /** Deviation from the fitted parabola a span must reach before it is worth
646
- * correcting. 5 mm is well under the eye's threshold on a character-sized
647
- * silhouette and comfortably above the noise in a generated root track. */
648
- export const AUTO_PHYSICS_EPSILON = 0.005;
649
-
650
- /** Largest root correction the pass will ever apply. Half a metre is already
651
- * a hip-height move; anything beyond it means the span is not a flight phase
652
- * and the "fix" would be a catastrophe, so such a span is skipped and
653
- * reported instead. */
654
- export const MAX_CORRECTION = 0.5;
655
-
656
- /** Worst RMS a fit may have and still be believed. A real flight phase fits a
657
- * parabola to a couple of centimetres; a mis-detected span (a whole walk
658
- * misread as one jump) fits to a metre or more. */
659
- const MAX_FIT_RESIDUAL = 0.25;
660
-
661
- /** Clearance kept between the lowest foot marker and the floor when a
662
- * downward correction is applied. */
663
- export const FLOOR_CLEARANCE = 0.005;
664
-
665
- /**
666
- * Run AutoPhysics over a frame range: find the airborne spans and force each
667
- * one's centre-of-mass HEIGHT onto its best-fit ballistic curve.
668
- *
669
- * `applyFrame(frame)` must pose the rig (motion apply + ikEvaluate) exactly
670
- * as the fix-collisions range driver expects — App owns that plumbing, this
671
- * owns the loop. The pass runs in two sweeps, and the split is what makes it
672
- * repeatable:
673
- *
674
- * 1. Sample sweep. Every frame is posed once and its CoM, foot-marker
675
- * heights and CLEAN hips transform are recorded. Nothing is written, so
676
- * this is the clip as authored plus whatever IK keys the user already
677
- * made — never a trajectory this pass is halfway through editing.
678
- * 2. Correction sweep. Each span is fitted from those samples. Before every
679
- * write the hips are RESET to the sweep-1 transform, so the correction
680
- * applied at a frame never depends on the keys written at earlier frames.
681
- * Without that reset a second button press re-measured its own blended
682
- * output and ratcheted the correction upward run after run.
683
- *
684
- * Between the two sweeps the raw detection is turned into ARCS, because a
685
- * span and a ballistic arc are not the same thing:
686
- *
687
- * - HYSTERESIS. Spans are found at `lift` and then grown outward at the much
688
- * lower `exitLift` (see expandAirborneSpans), recovering the take-off and
689
- * landing frames a stride-proof entry threshold necessarily clips off.
690
- * - SPLITTING. A span that never plants a foot can still hold two hops; it is
691
- * cut at its interior centre-of-mass troughs (see splitAtTroughs) so each
692
- * arc gets its own parabola. One fit spanning two hops needs corrections
693
- * big enough to trip the sanity gate, which used to throw away the clip's
694
- * only real jump.
695
- * - TAPERING. Each arc's correction eases in and out over `edgeTaper` frames
696
- * (see edgeTaperWeight), so it meets the clip's own trajectory instead of
697
- * stepping onto it beside a zero pin.
698
- *
699
- * An arc is corrected only when its FULL-WEIGHT core misses the parabola
700
- * vertically by more than `epsilon`; an arc whose worst correction exceeds
701
- * `correctionLimit`, or whose fit residual exceeds `maxFitResidual`, is
702
- * refused outright and reported in `skippedSpans`. Every applied ΔY is
703
- * clamped so no foot marker is pushed below `floorY + floorClearance`.
704
- *
705
- * `spans` reports the ARCS that were considered, after expansion and
706
- * splitting — the units this pass actually reasons about.
707
- *
708
- * Every frame of a corrected span is keyed. So is every frame BETWEEN the
709
- * first and last of them that is not itself corrected — the boundary frames
710
- * either side of a span, and any grounded gap between two spans. Those pins
711
- * carry the clip's own pose and exist because ikEvaluate evaluates the whole
712
- * keyed range at full weight: without them the layer interpolates the hips
713
- * straight across the gaps and drags grounded frames along with it. A pin
714
- * changes nothing visually; it states that nothing changes.
715
- *
716
- * Returns { supported, reason, spans, keyedFrames, maxCorrection, corrected,
717
- * pinnedFrames, skippedSpans, planted }.
718
- *
719
- * `supported` separates "ran and found nothing to do" from "could not run".
720
- * Every refusal below — a rig with no hips joint, a range too short to hold a
721
- * span, a skeleton whose centre of mass or foot markers cannot be measured —
722
- * used to return the same empty result as a perfectly good clip that simply
723
- * never leaves the ground, and the UI read all of them as "no airborne frames
724
- * in the clip". That sentence is a lie for four of the five, and the one it
725
- * fits is the only one the user can do nothing about. `reason` names which.
726
- */
727
- export function autoPhysicsRange({
728
- rig,
729
- motion = null,
730
- chains,
731
- fkJoints = null,
732
- ikState,
733
- applyFrame,
734
- floorY = 0,
735
- fps = 30,
736
- gravity = GRAVITY,
737
- lift = AIRBORNE_LIFT,
738
- exitLift = EXIT_LIFT,
739
- splitDepth = SPLIT_DEPTH,
740
- edgeTaper = EDGE_TAPER,
741
- margin = null,
742
- minSpanFrames = MIN_AIRBORNE_FRAMES,
743
- epsilon = AUTO_PHYSICS_EPSILON,
744
- correctionLimit = MAX_CORRECTION,
745
- maxFitResidual = MAX_FIT_RESIDUAL,
746
- floorClearance = FLOOR_CLEARANCE,
747
- contactHeights = null,
748
- supportFrames = null,
749
- grounding = false,
750
- startFrame = 0,
751
- endFrame = null,
752
- } = {}) {
753
- const refuse = (reason) => ({
754
- supported: false, reason,
755
- spans: [], keyedFrames: [], maxCorrection: 0, corrected: 0, pinnedFrames: [], skippedSpans: [], planted: null,
756
- groundContactSpans: [], rejectedGroundSpans: [], groundedKeyedFrames: [], maxFootCorrection: 0,
757
- maxFootSlideBefore: 0, maxFootSlideAfter: 0, meanFootSlideBefore: 0, meanFootSlideAfter: 0,
758
- floorPenetrationBefore: 0, floorPenetrationAfter: 0, skippedFloorFrames: [],
759
- });
760
- if (!rig || !chains || !ikState || typeof applyFrame !== "function") return refuse("no-driver");
761
- // The whole correction is a root translation, so without the hips FK joint
762
- // (the body-root control that owns the hips' local position) there is
763
- // nothing this pass can write.
764
- const hips = fkJoints?.get?.("hips");
765
- if (!hips) return refuse("no-hips-joint");
766
- // `margin` is the old option name from the absolute-threshold version; it
767
- // meant the same thing (clearance a foot must have) against a different
768
- // reference, so honouring it as an alias keeps old callers working.
769
- const liftThreshold = Number.isFinite(margin) ? margin : lift;
770
- void contactHeights; // accepted for API compatibility; the clip-relative
771
- // planted floor below is strictly better and must dominate, so measured
772
- // mesh drops no longer take part in detection at all.
773
- const start = Math.max(0, Math.round(startFrame) || 0);
774
- const lastFrame = Number.isFinite(endFrame) ? Math.round(endFrame) : (motion?.frames ?? 0) - 1;
775
- const end = Math.max(start, Math.round(lastFrame));
776
- const count = end - start + 1;
777
- if (count < minSpanFrames) return refuse("range-too-short");
778
-
779
- /* --- sweep 1: measure the clip, touching nothing ----------------------- */
780
- const samples = [];
781
- const heights = [];
782
- const footPositions = [];
783
- const clipFootPositions = [];
784
- const basePose = [];
785
- const footIds = FOOT_SIDES.map((foot) => foot.chainId).filter((id) => chains.has(id));
786
- const readFootQuats = () => new Map(footIds.map((id) => [id, chains.get(id).bones.map((bone) => bone.quaternion.clone())]));
787
- const layerFootQuats = [];
788
- const clipFootQuats = [];
789
- const layerActive = ikState.tracked.size > 0;
790
- for (let frame = start; frame <= end; frame += 1) {
791
- if (layerActive) {
792
- const tracked = ikState.tracked;
793
- ikState.tracked = new Set();
794
- try {
795
- applyFrame(frame);
796
- clipFootQuats.push(readFootQuats());
797
- clipFootPositions.push(markerPositions(rig));
798
- } finally {
799
- ikState.tracked = tracked;
800
- }
801
- }
802
- applyFrame(frame);
803
- const com = computeCenterOfMass(rig);
804
- const marks = markerHeights(rig);
805
- const points = markerPositions(rig);
806
- if (!com || !marks || !points) return refuse("rig-not-measurable"); // cannot be judged — decline
807
- samples.push(com);
808
- heights.push(marks);
809
- footPositions.push(points);
810
- basePose.push({ position: hips.bone.position.clone(), quaternion: hips.bone.quaternion.clone() });
811
- layerFootQuats.push(readFootQuats());
812
- if (!layerActive) {
813
- clipFootQuats.push(readFootQuats());
814
- clipFootPositions.push(points);
815
- }
816
- }
817
- const planted = plantedFloor(heights);
818
- const ground = grounding
819
- ? detectGroundContactSpans({
820
- positions: clipFootPositions,
821
- planted: plantedFloor(clipFootPositions.map((frame) => Object.fromEntries(FOOT_MARKERS.map((name) => [name, frame[name].y])))),
822
- fps,
823
- startFrame: start,
824
- })
825
- : { spans: [], rejected: [] };
826
- const flags = heights.map((marks, i) => !supportFrames?.has(start + i) && heightsAirborne(marks, planted, liftThreshold));
827
- const lowest = heights.map((marks) => Math.min(...FOOT_MARKERS.map((name) => marks[name])));
828
- // Detect strictly, then grow: the entry lift has to be high enough to
829
- // ignore a stride, which necessarily clips the take-off and landing frames
830
- // off a real jump. The exit lift puts them back.
831
- const detected = detectAirborneSpans({ frames: count, isAirborne: flags, startFrame: start, minLength: minSpanFrames });
832
- const exitFlags = heights.map((marks, i) => !supportFrames?.has(start + i) && heightsAirborne(marks, planted, Math.min(exitLift, liftThreshold)));
833
- const expanded = expandAirborneSpans({ spans: detected, canExtend: exitFlags, startFrame: start });
834
-
835
- /* --- split each span into single arcs ---------------------------------- */
836
- const skippedSpans = [];
837
- const spans = [];
838
- for (const span of expanded) {
839
- const first = span.start - start;
840
- const track = samples.slice(first, first + span.length).map((sample) => sample.y);
841
- for (const [from, to] of splitAtTroughs(track, { splitDepth })) {
842
- const arc = { start: span.start + from, end: span.start + to, length: to - from + 1 };
843
- // A sub-arc too short to fit is not evidence of anything; report it
844
- // rather than fitting two points and calling the result physics.
845
- if (arc.length < minSpanFrames) {
846
- skippedSpans.push({ ...arc, reason: "too-short", maxDelta: 0, residual: 0 });
847
- continue;
848
- }
849
- spans.push(arc);
850
- }
851
- }
852
-
853
- /** Restore the hips to the pose sweep 1 saw, so every write starts from
854
- * the clip rather than from this pass's own partial output. */
855
- const restoreBase = (frame) => {
856
- const base = basePose[frame - start];
857
- hips.bone.position.copy(base.position);
858
- hips.bone.quaternion.copy(base.quaternion);
859
- hips.bone.updateMatrixWorld(true);
860
- };
861
-
862
- /* --- sweep 2: correct the arcs that deserve it ------------------------- */
863
- const keyedFrames = [];
864
- const pinnedFrames = [];
865
- const corrections = new Map();
866
- let maxCorrection = 0;
867
- for (const span of spans) {
868
- const first = span.start - start;
869
- const spanSamples = samples.slice(first, first + span.length);
870
- const weights = spanSamples.map((_, index) => edgeTaperWeight(index, span.length, edgeTaper));
871
- // Anchored to the take-off and landing samples, so the correction is
872
- // zero at both ends by construction and the arc hands back to the clip
873
- // without a step. See fitBallisticArc for why not least squares.
874
- const fit = fitBallisticArc(spanSamples, fps, gravity);
875
- const raw = fit.positions.map((fitted, index) => fitted.y - spanSamples[index].y);
876
- // Only the FULL-WEIGHT core votes on whether the arc needs work. The
877
- // tapered frames deliberately keep a share of the clip, so they are
878
- // permanently a little off the curve and would otherwise ask for the
879
- // same correction on every run for ever.
880
- let coreFrom = weights.findIndex((weight) => weight >= 1);
881
- let coreTo = weights.length - 1 - [...weights].reverse().findIndex((weight) => weight >= 1);
882
- if (coreFrom < 0 || coreTo < coreFrom) {
883
- coreFrom = 0;
884
- coreTo = span.length - 1;
885
- }
886
- let worstCore = 0;
887
- for (let index = coreFrom; index <= coreTo; index += 1) worstCore = Math.max(worstCore, Math.abs(raw[index]));
888
- if (worstCore <= epsilon) continue; // already ballistic — leave it keyless
889
-
890
- // Floor guard: never push a foot marker through the ground. Only a
891
- // downward correction can, so this is a one-sided clamp, applied AFTER
892
- // the taper so the clamp governs what actually lands on the bone.
893
- const applied = raw.map((value, index) =>
894
- Math.max(value * weights[index], floorY + floorClearance - lowest[first + index]));
895
- const worstApplied = applied.reduce((max, value) => Math.max(max, Math.abs(value)), 0);
896
- const worstRaw = raw.reduce((max, value) => Math.max(max, Math.abs(value)), 0);
897
- // Deltas, not the clamped result, decide whether the arc is believable:
898
- // a floor clamp could shrink an absurd correction into the accepted
899
- // range and hide exactly the failure this gate exists to catch.
900
- if (worstRaw > correctionLimit || worstApplied > correctionLimit || fit.residual > maxFitResidual) {
901
- skippedSpans.push({
902
- start: span.start,
903
- end: span.end,
904
- length: span.length,
905
- reason: fit.residual > maxFitResidual ? "fit-too-poor" : "correction-too-large",
906
- maxDelta: Math.max(worstRaw, worstApplied),
907
- residual: fit.residual,
908
- });
909
- continue;
910
- }
911
- for (let index = 0; index < span.length; index += 1) {
912
- corrections.set(span.start + index, applied[index]);
913
- maxCorrection = Math.max(maxCorrection, Math.abs(applied[index]));
914
- }
915
- }
916
-
917
- /* --- write: corrections, then zero-delta pins across the keyed range --- */
918
- const lift3 = new THREE.Vector3();
919
- /** The pass writes exactly one thing: the hips' local position (and, as a
920
- * pin, the clip's own hips rotation). Naming that in the bake keeps it from
921
- * re-keying every limb the user has ever dragged — and the clip's own hips
922
- * position rides along as `basePos`, so ikEvaluate applies the correction as
923
- * a DELTA over the clip instead of splicing this frame's absolute height
924
- * onto its neighbours. A pin's delta is then exactly zero by construction,
925
- * not merely zero at the pinned frame. */
926
- const writeFrame = (frame, delta) => {
927
- applyFrame(frame);
928
- restoreBase(frame);
929
- const clipPosition = hips.bone.position.clone();
930
- if (delta) {
931
- lift3.set(0, delta, 0);
932
- solveHipsTranslate(hips, lift3, clipPosition.clone());
933
- }
934
- ikTouch(ikState, "hips");
935
- ikBakeKeyframe(chains, ikState, frame, fkJoints, ["hips"], new Map([["hips", clipPosition]]));
936
- };
937
- if (corrections.size > 0) {
938
- for (const [frame, delta] of [...corrections.entries()].sort((a, b) => a[0] - b[0])) {
939
- writeFrame(frame, delta);
940
- keyedFrames.push(frame);
941
- }
942
- const pinFrom = Math.max(start, Math.min(...corrections.keys()) - 1);
943
- const pinTo = Math.min(end, Math.max(...corrections.keys()) + 1);
944
- for (let frame = pinFrom; frame <= pinTo; frame += 1) {
945
- if (corrections.has(frame)) continue;
946
- writeFrame(frame, 0);
947
- pinnedFrames.push(frame);
948
- }
949
- }
950
-
951
- /* --- grounded feet: lock safe stance runs, lift only real penetration --- */
952
- const groundedKeyedFrames = [];
953
- let maxFootCorrection = 0;
954
- let floorPenetrationBefore = 0;
955
- let floorPenetrationAfter = 0;
956
- let maxFootSlideBefore = ground.spans.reduce((max, span) => Math.max(max, span.maxPull), 0);
957
- let maxFootSlideAfter = 0;
958
- let slideSumBefore = 0;
959
- let slideSumAfter = 0;
960
- let slideSampleCount = 0;
961
- for (const span of ground.spans) {
962
- const foot = FOOT_SIDES.find((item) => item.chainId === span.chainId);
963
- if (!foot) continue;
964
- for (let frame = span.start; frame <= span.end; frame += 1) {
965
- const point = footPositions[frame - start]?.[foot.ankle];
966
- if (!point) continue;
967
- slideSumBefore += Math.hypot(point.x - span.anchor.x, point.z - span.anchor.z);
968
- slideSampleCount += 1;
969
- }
970
- }
971
- const contactHeight = (foot) => chains.get(foot.chainId)?.contactHeights?.[foot.height] ?? 0;
972
- const penetrationAt = (points) => {
973
- let worst = 0;
974
- for (const foot of FOOT_SIDES) {
975
- const ankle = points?.[foot.ankle];
976
- if (!ankle) continue;
977
- // Use the bind-pose MEASURED mesh drop below the ankle. ToeBase is a
978
- // useful contact marker but not a sole: on X-Bot its bone sits about
979
- // 12 cm below Y-Bot's relative to the visible foot, which made the old
980
- // absolute toe test lift an otherwise grounded body by a false 12 cm.
981
- worst = Math.max(worst, floorY + contactHeight(foot) - ankle.y);
982
- }
983
- return Math.max(0, worst);
984
- };
985
- const contactFrames = new Set();
986
- for (const span of ground.spans) for (let frame = span.start; frame <= span.end; frame += 1) contactFrames.add(frame);
987
- const floorLifts = new Map();
988
- const skippedFloorFrames = new Set();
989
- for (const frame of contactFrames) {
990
- const penetration = penetrationAt(footPositions[frame - start]);
991
- floorPenetrationBefore = Math.max(floorPenetrationBefore, penetration);
992
- if (penetration > GROUND_FLOOR_MAX_LIFT) skippedFloorFrames.add(frame);
993
- else if (penetration > GROUND_FLOOR_EPSILON) floorLifts.set(frame, penetration);
994
- }
995
- // A floor offset is a body placement error, not a bent-leg error. Lift the
996
- // hips/root rigidly so the entire character clears the floor without
997
- // changing either knee. The foot solve below then handles XZ only.
998
- for (const [frame, lift] of floorLifts) {
999
- writeFrame(frame, lift);
1000
- groundedKeyedFrames.push(frame);
1001
- maxCorrection = Math.max(maxCorrection, lift);
1002
- }
1003
-
1004
- const groundWrites = new Map();
1005
- for (const span of ground.spans) {
1006
- const foot = FOOT_SIDES.find((item) => item.chainId === span.chainId);
1007
- if (!foot) continue;
1008
- for (let frame = span.start; frame <= span.end; frame += 1) {
1009
- const index = frame - start;
1010
- const ankle = footPositions[index]?.[foot.ankle];
1011
- if (!ankle) continue;
1012
- const target = ankle.clone();
1013
- target.x = span.anchor.x;
1014
- target.z = span.anchor.z;
1015
- // The parent root has already been lifted on a penetrated frame. Move
1016
- // the ankle by the same amount so the leg keeps its vertical pose.
1017
- target.y += floorLifts.get(frame) ?? 0;
1018
- const delta = Math.hypot(target.x - ankle.x, target.z - ankle.z);
1019
- if (delta <= GROUND_LOCK_EPSILON) continue;
1020
- if (!groundWrites.has(frame)) groundWrites.set(frame, []);
1021
- groundWrites.get(frame).push({ foot, target, delta });
1022
- }
1023
- }
1024
- for (const [frame, writes] of [...groundWrites.entries()].sort((a, b) => a[0] - b[0])) {
1025
- applyFrame(frame);
1026
- const index = frame - start;
1027
- // Restore the sweep-1 layer pose before solving either leg. This keeps
1028
- // a key written on frame N from becoming frame N+1's starting pose.
1029
- for (const id of footIds) {
1030
- const chain = chains.get(id);
1031
- const quats = layerFootQuats[index]?.get(id);
1032
- if (!chain || !quats) continue;
1033
- chain.bones.forEach((bone, boneIndex) => bone.quaternion.copy(quats[boneIndex]));
1034
- }
1035
- rig.updateMatrixWorld(true);
1036
- const successful = [];
1037
- for (const write of writes) {
1038
- const { foot, target } = write;
1039
- const chain = chains.get(foot.chainId);
1040
- solveIk(chain, target);
1041
- rig.updateMatrixWorld(true);
1042
- let achieved = findBone(rig, foot.ankle)?.getWorldPosition(new THREE.Vector3());
1043
- let residual = achieved ? Math.hypot(achieved.x - target.x, achieved.z - target.z) : Infinity;
1044
- // A fully straight leg has no sideways reach even for a two-centimetre
1045
- // plant. On failure only, permit five millimetres of sole clearance to
1046
- // create knee bend, then retry from the untouched clip pose once.
1047
- if (residual > GROUND_LOCK_EPSILON) {
1048
- const quats = layerFootQuats[index]?.get(foot.chainId);
1049
- if (quats) chain.bones.forEach((bone, boneIndex) => bone.quaternion.copy(quats[boneIndex]));
1050
- rig.updateMatrixWorld(true);
1051
- target.y += GROUND_LOCK_REACH_LIFT;
1052
- solveIk(chain, target);
1053
- rig.updateMatrixWorld(true);
1054
- achieved = findBone(rig, foot.ankle)?.getWorldPosition(new THREE.Vector3());
1055
- residual = achieved ? Math.hypot(achieved.x - target.x, achieved.z - target.z) : Infinity;
1056
- }
1057
- // A straight or fully stretched leg may not reach a sideways plant.
1058
- // Do not bake a half-fix that a second run would ratchet further.
1059
- if (residual <= GROUND_LOCK_EPSILON) {
1060
- successful.push(write);
1061
- maxFootCorrection = Math.max(maxFootCorrection, write.delta);
1062
- } else {
1063
- const quats = layerFootQuats[index]?.get(foot.chainId);
1064
- if (quats) chain.bones.forEach((bone, boneIndex) => bone.quaternion.copy(quats[boneIndex]));
1065
- rig.updateMatrixWorld(true);
1066
- }
1067
- }
1068
- for (const { foot } of successful) {
1069
- ikBakeKeyframe(chains, ikState, frame, fkJoints, [foot.chainId], null, clipFootQuats[index]);
1070
- }
1071
- if (successful.length) groundedKeyedFrames.push(frame);
1072
- }
1073
-
1074
- // A root lift can turn a previously straight, unreachable leg into a
1075
- // solvable one. Re-sample the FINISHED layer and take at most three bounded
1076
- // refinement sweeps now, so pressing the button again cannot discover a
1077
- // second batch and ratchet the take. Each sweep is still snapshotted before
1078
- // it writes, so neighbouring keys never become one another's input.
1079
- for (let refinement = 0; grounding && refinement < 3; refinement += 1) {
1080
- const currentPositions = [];
1081
- const currentQuats = [];
1082
- for (let frame = start; frame <= end; frame += 1) {
1083
- applyFrame(frame);
1084
- currentPositions.push(markerPositions(rig));
1085
- currentQuats.push(readFootQuats());
1086
- }
1087
- let wrote = 0;
1088
- for (let frame = start; frame <= end; frame += 1) {
1089
- const active = ground.spans.filter((span) => frame >= span.start && frame <= span.end);
1090
- if (!active.length) continue;
1091
- const index = frame - start;
1092
- const candidates = [];
1093
- for (const span of active) {
1094
- const foot = FOOT_SIDES.find((item) => item.chainId === span.chainId);
1095
- const ankle = foot && currentPositions[index]?.[foot.ankle];
1096
- if (!foot || !ankle) continue;
1097
- const delta = Math.hypot(span.anchor.x - ankle.x, span.anchor.z - ankle.z);
1098
- if (delta > GROUND_LOCK_EPSILON) candidates.push({ foot, delta, target: ankle.clone().setX(span.anchor.x).setZ(span.anchor.z) });
1099
- }
1100
- if (!candidates.length) continue;
1101
- applyFrame(frame);
1102
- for (const { foot } of candidates) {
1103
- const chain = chains.get(foot.chainId);
1104
- const quats = currentQuats[index]?.get(foot.chainId);
1105
- if (chain && quats) chain.bones.forEach((bone, boneIndex) => bone.quaternion.copy(quats[boneIndex]));
1106
- }
1107
- rig.updateMatrixWorld(true);
1108
- for (const candidate of candidates) {
1109
- const { foot, target } = candidate;
1110
- const chain = chains.get(foot.chainId);
1111
- solveIk(chain, target);
1112
- rig.updateMatrixWorld(true);
1113
- const achieved = findBone(rig, foot.ankle)?.getWorldPosition(new THREE.Vector3());
1114
- const residual = achieved ? Math.hypot(achieved.x - target.x, achieved.z - target.z) : Infinity;
1115
- if (residual <= GROUND_LOCK_EPSILON) {
1116
- ikBakeKeyframe(chains, ikState, frame, fkJoints, [foot.chainId], null, clipFootQuats[index]);
1117
- groundedKeyedFrames.push(frame);
1118
- maxFootCorrection = Math.max(maxFootCorrection, candidate.delta);
1119
- wrote += 1;
1120
- } else {
1121
- const quats = currentQuats[index]?.get(foot.chainId);
1122
- if (quats) chain.bones.forEach((bone, boneIndex) => bone.quaternion.copy(quats[boneIndex]));
1123
- rig.updateMatrixWorld(true);
1124
- }
1125
- }
1126
- }
1127
- if (!wrote) break;
1128
- }
1129
-
1130
- // Foot IK can lower an ankle slightly after the first root lift. Re-check
1131
- // the evaluated layer and add only the remaining clearance to that frame's
1132
- // existing root correction. Two bounded passes settle interpolation without
1133
- // ever accumulating beyond the measured need or the 15 cm safety cap.
1134
- if (grounding) {
1135
- for (let pass = 0; pass < 2; pass += 1) {
1136
- let wrote = 0;
1137
- for (const frame of contactFrames) {
1138
- applyFrame(frame);
1139
- const residual = penetrationAt(markerPositions(rig));
1140
- if (residual <= GROUND_FLOOR_EPSILON) continue;
1141
- const total = (floorLifts.get(frame) ?? 0) + residual;
1142
- if (total > GROUND_FLOOR_MAX_LIFT) {
1143
- skippedFloorFrames.add(frame);
1144
- continue;
1145
- }
1146
- floorLifts.set(frame, total);
1147
- writeFrame(frame, total);
1148
- groundedKeyedFrames.push(frame);
1149
- maxCorrection = Math.max(maxCorrection, total);
1150
- wrote += 1;
1151
- }
1152
- if (!wrote) break;
1153
- }
1154
- // Read back the final evaluated result, not the solver's momentary pose:
1155
- // this is the same truth the viewer sees after all keys and blend ramps.
1156
- const afterPositions = [];
1157
- for (let frame = start; frame <= end; frame += 1) {
1158
- applyFrame(frame);
1159
- const points = markerPositions(rig);
1160
- if (!points) continue;
1161
- afterPositions.push(points);
1162
- if (contactFrames.has(frame)) floorPenetrationAfter = Math.max(floorPenetrationAfter, penetrationAt(points));
1163
- }
1164
- for (const span of ground.spans) {
1165
- const foot = FOOT_SIDES.find((item) => item.chainId === span.chainId);
1166
- if (!foot) continue;
1167
- for (let frame = span.start; frame <= span.end; frame += 1) {
1168
- const point = afterPositions[frame - start]?.[foot.ankle];
1169
- if (point) {
1170
- const distance = Math.hypot(point.x - span.anchor.x, point.z - span.anchor.z);
1171
- maxFootSlideAfter = Math.max(maxFootSlideAfter, distance);
1172
- slideSumAfter += distance;
1173
- }
1174
- }
1175
- }
1176
- }
1177
- const allKeyed = [...new Set([...keyedFrames, ...groundedKeyedFrames])].sort((a, b) => a - b);
1178
- return {
1179
- supported: true, reason: "",
1180
- spans, keyedFrames: allKeyed, airborneKeyedFrames: keyedFrames, maxCorrection,
1181
- corrected: allKeyed.length, pinnedFrames, skippedSpans, planted,
1182
- groundContactSpans: ground.spans,
1183
- rejectedGroundSpans: ground.rejected,
1184
- groundedKeyedFrames: [...new Set(groundedKeyedFrames)].sort((a, b) => a - b),
1185
- maxFootCorrection,
1186
- maxFootSlideBefore,
1187
- maxFootSlideAfter,
1188
- meanFootSlideBefore: slideSampleCount ? slideSumBefore / slideSampleCount : 0,
1189
- meanFootSlideAfter: slideSampleCount ? slideSumAfter / slideSampleCount : 0,
1190
- floorPenetrationBefore,
1191
- floorPenetrationAfter,
1192
- skippedFloorFrames: [...skippedFloorFrames].sort((a, b) => a - b),
1193
- };
1194
- }