cozyclay 2.1.0 → 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 (583) hide show
  1. package/CHANGELOG.md +65 -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 +22 -6
  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-BlT33CGf.js → first-shot-handoff-CC4xe4HM.js} +1 -1
  33. package/dist/assets/{landing-BSesrCpL.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/llms.txt +14 -0
  40. package/dist/sw.js +3 -6
  41. package/dist/workflow/index.html +5 -4
  42. package/mcp/LIVE-PROTOCOL.md +7 -47
  43. package/mcp/README.md +16 -26
  44. package/mcp/live-hub.mjs +6 -10
  45. package/mcp/package.json +1 -3
  46. package/mcp/tool-handlers.mjs +4 -168
  47. package/package.json +3 -22
  48. package/src/App.jsx +174 -2639
  49. package/src/agent-progress.js +79 -0
  50. package/src/agent-turn-mark.js +30 -0
  51. package/src/analytics.js +1 -19
  52. package/src/app-context.js +5 -0
  53. package/src/app-stage.jsx +2 -34
  54. package/src/asset-pane.jsx +6 -5
  55. package/src/camera-block.js +21 -0
  56. package/src/camera-direction.js +120 -0
  57. package/src/character-kind.js +3 -9
  58. package/src/command-bus.js +8 -6
  59. package/src/commands/cast.js +5 -34
  60. package/src/commands/elements/character.js +1 -1
  61. package/src/commands/elements/motion.js +1 -6
  62. package/src/commands/elements/object.js +6 -2
  63. package/src/commands/elements/shot.js +4 -1
  64. package/src/commands/elements.js +38 -14
  65. package/src/commands/index.js +3 -1
  66. package/src/commands/kits.js +37 -0
  67. package/src/commands/motion-timing.js +57 -0
  68. package/src/commands/motion.js +9 -152
  69. package/src/commands/motion3-compile.js +65 -0
  70. package/src/commands/motion3.js +207 -0
  71. package/src/commands/objects.js +6 -4
  72. package/src/commands/shot.js +49 -11
  73. package/src/commands/stage.js +14 -1
  74. package/src/commands/view.js +4 -1
  75. package/src/document-mirrors.js +42 -0
  76. package/src/document-store.js +4 -0
  77. package/src/domains/cast.js +19 -194
  78. package/src/domains/motion/motion-range-pin-rules.js +1 -1
  79. package/src/domains/motion/take-source.js +51 -0
  80. package/src/domains/motion.js +60 -1838
  81. package/src/domains/scenes.js +12 -55
  82. package/src/domains/shots.js +2 -2
  83. package/src/domains/stage.js +4 -2
  84. package/src/draft-ghost.js +46 -0
  85. package/src/draft-ghost.jsx +129 -0
  86. package/src/dualview.jsx +4 -3
  87. package/src/effects/EffectsLayer.jsx +112 -0
  88. package/src/effects/effect-meshes.js +232 -0
  89. package/src/effects/effect-record.js +103 -0
  90. package/src/effects/emitter.js +59 -0
  91. package/src/effects/simulate.js +261 -0
  92. package/src/effects/state.js +59 -0
  93. package/src/execution-telemetry.js +2 -3
  94. package/src/first-success-guide.jsx +5 -5
  95. package/src/ghost-set.js +257 -0
  96. package/src/held-object-edit.jsx +113 -0
  97. package/src/held-object-pose.js +175 -0
  98. package/src/hierarchy-model.js +1 -1
  99. package/src/hierarchy-panel.jsx +1 -1
  100. package/src/imported-mesh.jsx +103 -0
  101. package/src/kits/held.js +84 -0
  102. package/src/kits/index.js +70 -0
  103. package/src/kits/smoking.js +57 -0
  104. package/src/light-blockers.js +93 -0
  105. package/src/motion/beat-board-model.js +84 -0
  106. package/src/motion/beat-board.css +124 -0
  107. package/src/motion/beat-board.jsx +59 -0
  108. package/src/motion/center-of-mass.js +66 -0
  109. package/src/motion/fix-collisions.js +850 -0
  110. package/src/motion/physics-review.js +118 -0
  111. package/src/{ardy → motion}/physics-support.js +1 -76
  112. package/src/motion/rig-snapshot.js +22 -0
  113. package/src/motion/take-segments.jsx +140 -0
  114. package/src/{ardy → motion}/timeline-coordinates.js +0 -21
  115. package/src/{ardy → motion}/timeline.css +1 -2
  116. package/src/{ardy → motion}/timeline.jsx +48 -419
  117. package/src/motion/waypoints.js +76 -0
  118. package/src/motion3/beats.js +83 -0
  119. package/src/motion3/body.js +158 -0
  120. package/src/motion3/bones.js +47 -0
  121. package/src/motion3/carried.js +47 -0
  122. package/src/motion3/compile-job.js +75 -0
  123. package/src/motion3/compile-worker.js +10 -0
  124. package/src/motion3/compiler/anchors.js +105 -0
  125. package/src/motion3/compiler/arm-orient.js +128 -0
  126. package/src/motion3/compiler/channels.js +125 -0
  127. package/src/motion3/compiler/compile.js +205 -0
  128. package/src/motion3/compiler/gait-rhythm.js +97 -0
  129. package/src/motion3/compiler/grip.js +171 -0
  130. package/src/motion3/compiler/head-steady.js +48 -0
  131. package/src/motion3/compiler/hold.js +134 -0
  132. package/src/motion3/compiler/idle.js +113 -0
  133. package/src/motion3/compiler/limbs.js +170 -0
  134. package/src/motion3/compiler/math.js +72 -0
  135. package/src/motion3/compiler/pose.js +162 -0
  136. package/src/motion3/compiler/quat.js +49 -0
  137. package/src/motion3/compiler/skeleton.js +74 -0
  138. package/src/motion3/compiler/validate-common.js +32 -0
  139. package/src/motion3/compiler/validate-goals.js +118 -0
  140. package/src/motion3/compiler/validate-objects.js +143 -0
  141. package/src/motion3/compiler/validate-parts.js +116 -0
  142. package/src/motion3/compiler/validate.js +122 -0
  143. package/src/motion3/compiler/vocab.js +73 -0
  144. package/src/motion3/compiler/walk-pelvis.js +275 -0
  145. package/src/motion3/compiler/walk.js +207 -0
  146. package/src/motion3/compiler/wrist-clamp.js +32 -0
  147. package/src/motion3/delta.js +47 -0
  148. package/src/motion3/describe.js +174 -0
  149. package/src/motion3/docs.js +27 -0
  150. package/src/motion3/edit-carry.js +21 -0
  151. package/src/motion3/fix/ease.js +16 -0
  152. package/src/motion3/fix/ground.js +37 -0
  153. package/src/motion3/fix/index.js +52 -0
  154. package/src/motion3/fix/push.js +71 -0
  155. package/src/motion3/fix/reach.js +46 -0
  156. package/src/motion3/fix/retime.js +79 -0
  157. package/src/motion3/fix/self.js +211 -0
  158. package/src/motion3/fix/slide.js +70 -0
  159. package/src/motion3/fix/turn.js +51 -0
  160. package/src/motion3/follow.js +39 -0
  161. package/src/motion3/free-motion.js +173 -0
  162. package/src/motion3/grip-choice.js +130 -0
  163. package/src/motion3/hold-plan.js +157 -0
  164. package/src/motion3/json-patch.js +77 -0
  165. package/src/motion3/key-aim.js +105 -0
  166. package/src/motion3/key-edit.js +82 -0
  167. package/src/motion3/lenient.js +127 -0
  168. package/src/motion3/object-keys.js +236 -0
  169. package/src/motion3/object-pose.js +182 -0
  170. package/src/motion3/object-tracks.js +12 -0
  171. package/src/motion3/pipeline.js +102 -0
  172. package/src/motion3/playback.js +51 -0
  173. package/src/motion3/probes.js +91 -0
  174. package/src/motion3/qa/body-checks.js +34 -0
  175. package/src/motion3/qa/check-names.js +14 -0
  176. package/src/motion3/qa/checks.js +121 -0
  177. package/src/motion3/qa/contact-checks.js +51 -0
  178. package/src/motion3/qa/explain.js +80 -0
  179. package/src/motion3/qa/fmt.js +5 -0
  180. package/src/motion3/qa/foot-checks.js +72 -0
  181. package/src/motion3/qa/geometry.js +77 -0
  182. package/src/motion3/qa/goals.js +191 -0
  183. package/src/motion3/qa/ground.js +60 -0
  184. package/src/motion3/qa/hints-contact.js +179 -0
  185. package/src/motion3/qa/hints-goal.js +160 -0
  186. package/src/motion3/qa/hints-knee.js +24 -0
  187. package/src/motion3/qa/hints-motion.js +209 -0
  188. package/src/motion3/qa/hints-object.js +91 -0
  189. package/src/motion3/qa/index.js +42 -0
  190. package/src/motion3/qa/key-checks.js +82 -0
  191. package/src/motion3/qa/near.js +96 -0
  192. package/src/motion3/qa/object-checks.js +135 -0
  193. package/src/motion3/qa/patch.js +161 -0
  194. package/src/motion3/qa/severity.js +38 -0
  195. package/src/motion3/qa/spec-checks.js +65 -0
  196. package/src/motion3/qa/style.js +218 -0
  197. package/src/motion3/qa/wrist-checks.js +69 -0
  198. package/src/motion3/qa/wrist-obs.js +60 -0
  199. package/src/motion3/refs.js +120 -0
  200. package/src/motion3/repair-tune.js +121 -0
  201. package/src/motion3/repair.js +83 -0
  202. package/src/motion3/report.js +67 -0
  203. package/src/motion3/rigs/index.js +14 -0
  204. package/src/motion3/rigs/x-bot-tpose-body.json +1 -0
  205. package/src/motion3/rigs/x-bot-tpose.json +1 -0
  206. package/src/motion3/rigs/y-bot-tpose-body.json +1 -0
  207. package/src/motion3/rigs/y-bot-tpose.json +1 -0
  208. package/src/motion3/scene.js +60 -0
  209. package/src/motion3/schema/brief.js +89 -0
  210. package/src/motion3/schema/fields.js +44 -0
  211. package/src/motion3/schema/spec-examples.js +46 -0
  212. package/src/motion3/schema/topics.js +111 -0
  213. package/src/motion3/schema/types.js +147 -0
  214. package/src/motion3/search.js +117 -0
  215. package/src/motion3/second-hand.js +112 -0
  216. package/src/motion3/skills/basic.js +82 -0
  217. package/src/motion3/skills/book.js +76 -0
  218. package/src/motion3/skills/car.js +145 -0
  219. package/src/motion3/skills/frames-auto.js +29 -0
  220. package/src/motion3/skills/index.js +83 -0
  221. package/src/motion3/skills/info.js +65 -0
  222. package/src/motion3/skills/smoke-kit.js +109 -0
  223. package/src/motion3/skills/smoke.js +160 -0
  224. package/src/motion3/spec-store.js +59 -0
  225. package/src/motion3/take-edit.js +155 -0
  226. package/src/motion3/take.js +63 -0
  227. package/src/motion3/timeline.js +127 -0
  228. package/src/motion3/views.js +121 -0
  229. package/src/motion3/walk-plan.js +119 -0
  230. package/src/object-catalog.jsx +7 -1
  231. package/src/object-colors.js +98 -0
  232. package/src/object-gizmo-parts.js +115 -0
  233. package/src/object-gizmo.jsx +37 -122
  234. package/src/object-glow.js +139 -0
  235. package/src/object-hidden.js +33 -0
  236. package/src/object-look.js +196 -0
  237. package/src/object-size.js +10 -0
  238. package/src/object-travel.js +71 -6
  239. package/src/panels/CameraPanel.jsx +7 -7
  240. package/src/panels/HdrEnvironmentControls.jsx +59 -0
  241. package/src/panels/LightPanel.jsx +25 -4
  242. package/src/panels/ObjectLookPanel.jsx +88 -0
  243. package/src/panels/PoseImagePanel.jsx +1 -1
  244. package/src/panels/PosePanel.jsx +2 -17
  245. package/src/panels/RigControlPanel.jsx +5 -53
  246. package/src/panels/SubjectBox.jsx +4 -1
  247. package/src/panels/details.css +14 -0
  248. package/src/posestudio.jsx +4 -27
  249. package/src/project-browser.jsx +3 -4
  250. package/src/project-resources.js +18 -31
  251. package/src/project.js +13 -104
  252. package/src/props.jsx +25 -280
  253. package/src/rail-dot-drag.js +23 -0
  254. package/src/render-passes.js +10 -0
  255. package/src/resource-status.jsx +1 -0
  256. package/src/result-modal.jsx +4 -19
  257. package/src/room.jsx +20 -12
  258. package/src/scene-assets.js +8 -6
  259. package/src/scene-light-place.js +72 -0
  260. package/src/scene-lights.jsx +148 -0
  261. package/src/scene-objects.js +31 -139
  262. package/src/scenes.js +15 -105
  263. package/src/semantic-edit.js +2 -5
  264. package/src/set-pieces.jsx +183 -0
  265. package/src/settings-menu.jsx +3 -11
  266. package/src/shell/BottomDock.jsx +11 -53
  267. package/src/shell/CompositionGuideButton.jsx +20 -0
  268. package/src/shell/DetailsSlot.jsx +16 -200
  269. package/src/shell/GhostsMenuGroup.jsx +74 -0
  270. package/src/shell/LibrarySlot.jsx +1 -3
  271. package/src/shell/MenuBar.jsx +3 -3
  272. package/src/shell/PreferencesDialog.jsx +15 -264
  273. package/src/shell/PreferencesSlot.jsx +2 -8
  274. package/src/shell/ShotPreviewHeader.jsx +17 -0
  275. package/src/shell/TopBar.jsx +5 -83
  276. package/src/shell/ViewportToolbar.jsx +23 -93
  277. package/src/shell/agent-glass.css +19 -6
  278. package/src/shell/details-selection.js +42 -0
  279. package/src/shell/ghosts-menu.css +22 -0
  280. package/src/shell/glass-regions.css +29 -3
  281. package/src/shell/preferences.css +6 -1
  282. package/src/shell/viewport.css +12 -0
  283. package/src/shot-authoring.js +4 -7
  284. package/src/soft-shadows.js +37 -0
  285. package/src/stage-attach-frames.js +12 -12
  286. package/src/stage-canvas-rigs.jsx +3 -3
  287. package/src/stage-capture.jsx +42 -8
  288. package/src/stage-characters.jsx +22 -216
  289. package/src/stage-editor-gizmos.jsx +6 -6
  290. package/src/stage-environment.js +93 -0
  291. package/src/stage-hdr-builtins.js +107 -0
  292. package/src/stage-hdr-image.js +116 -0
  293. package/src/stage-hdr-library.js +70 -0
  294. package/src/stage-hdr-map.js +94 -0
  295. package/src/stage-hdr.js +130 -0
  296. package/src/stage-lighting.js +149 -0
  297. package/src/stage-occlusion.js +210 -0
  298. package/src/stage-sky.jsx +159 -0
  299. package/src/stage-startup-defaults.js +5 -9
  300. package/src/studio-actions.js +12 -14
  301. package/src/studio-agent-commands.js +46 -16
  302. package/src/studio-agent-motion.js +9 -9
  303. package/src/studio-agent-protocol.js +46 -73
  304. package/src/studio-app-binding.js +29 -45
  305. package/src/studio-asset-list.js +17 -0
  306. package/src/studio-contact-sheet.js +37 -5
  307. package/src/studio-elements.js +27 -7
  308. package/src/studio-entity-bounds.js +18 -0
  309. package/src/studio-error-codes.js +8 -0
  310. package/src/studio-export-framing.js +152 -0
  311. package/src/studio-lighting-check.js +103 -0
  312. package/src/studio-motion-visual.js +150 -0
  313. package/src/studio-schema-errors.js +80 -0
  314. package/src/studio-support-gap.js +74 -0
  315. package/src/styles.css +1 -19
  316. package/src/timeline-defaults.js +5 -0
  317. package/src/timeline-extent.js +8 -20
  318. package/src/tutorial-walk.js +17 -0
  319. package/src/ui-scrub.js +1 -1
  320. package/src/use-shot-camera-tracks.js +12 -0
  321. package/src/view-ghosts.js +97 -0
  322. package/src/view-ghosts.jsx +49 -0
  323. package/src/workflow/AgentPanel.jsx +47 -166
  324. package/src/workflow/AgentProgressCard.jsx +107 -0
  325. package/src/workflow/AgentReplyText.jsx +37 -0
  326. package/src/workflow/AgentTurnSteps.jsx +84 -0
  327. package/src/workflow/LoginPanel.jsx +140 -0
  328. package/src/workflow/WorkflowBuilder.jsx +7 -23
  329. package/src/workflow/agent-client.js +54 -51
  330. package/src/workflow/agent-panel.css +71 -1
  331. package/src/workflow/agent-progress-card.css +167 -0
  332. package/src/workflow/agent-progress-view.js +47 -0
  333. package/src/workflow/agent-turn-steps.css +113 -0
  334. package/src/workflow/agent-turn.js +191 -0
  335. package/src/workflow/login-client.js +34 -0
  336. package/src/workflow/motion-input.js +18 -31
  337. package/src/workflow/node-schema.js +1 -1
  338. package/src/workflow/reply-markdown.js +69 -0
  339. package/src/workflow/scene-asset-sync.js +8 -8
  340. package/tools/agent-session-stats.mjs +61 -0
  341. package/tools/dev-full.mjs +5 -56
  342. package/tools/eslint-baseline.json +12 -13
  343. package/tools/headless-studio.mjs +85 -0
  344. package/tools/knip-baseline.json +0 -179
  345. package/tools/loc-baseline.json +33 -83
  346. package/tools/motion3/extract-body.mjs +103 -0
  347. package/tools/motion3/extract-rig.mjs +85 -0
  348. package/tools/process-supervisor.mjs +0 -88
  349. package/tools/run-tests.mjs +9 -124
  350. package/bin/agent/motion-runtime.mjs +0 -76
  351. package/dist/ardy/cskel27-rest.json +0 -557
  352. package/dist/assets/analytics-B41FmPYI.js +0 -1
  353. package/dist/assets/app-Do-Og5Pu.css +0 -1
  354. package/dist/assets/app-DwP-tl2x.js +0 -4920
  355. package/dist/assets/config-Bx7pYsD9.js +0 -1
  356. package/dist/assets/config-DeoFTYzb.css +0 -1
  357. package/dist/assets/demo-Dt0qZfS7.js +0 -1
  358. package/dist/assets/scene-asset-sync-BCWDJB1d.js +0 -19
  359. package/dist/assets/scene-asset-sync-DjJ_cJ2R.css +0 -1
  360. package/dist/assets/ticket-jqMhqL88.js +0 -1
  361. package/dist/assets/workflow-Br-K7Srs.js +0 -179
  362. package/dist/demo/walk-then-stop.npz +0 -0
  363. package/mcp/ardy-prompts.mjs +0 -218
  364. package/src/ardy/auto-fix-panel.css +0 -108
  365. package/src/ardy/auto-physics.js +0 -1194
  366. package/src/ardy/client.js +0 -153
  367. package/src/ardy/convert.js +0 -313
  368. package/src/ardy/cskel27-neutral.js +0 -39
  369. package/src/ardy/cskel27.js +0 -77
  370. package/src/ardy/export.js +0 -165
  371. package/src/ardy/fix-collisions.js +0 -2257
  372. package/src/ardy/foot-lock.js +0 -230
  373. package/src/ardy/motion-calibration.js +0 -118
  374. package/src/ardy/motion-edit.js +0 -213
  375. package/src/ardy/motion-url.js +0 -55
  376. package/src/ardy/mp4-duration.js +0 -102
  377. package/src/ardy/npz.js +0 -595
  378. package/src/ardy/physics-panel.css +0 -21
  379. package/src/ardy/physics-panel.jsx +0 -89
  380. package/src/ardy/physics-refine.js +0 -111
  381. package/src/ardy/physics-review.js +0 -567
  382. package/src/ardy/physics-temporal.js +0 -54
  383. package/src/ardy/platform-fit-panel.css +0 -64
  384. package/src/ardy/platform-fit-panel.jsx +0 -41
  385. package/src/ardy/platform-fit.js +0 -326
  386. package/src/ardy/playback.js +0 -711
  387. package/src/ardy/pose-pin.js +0 -97
  388. package/src/ardy/prompt-clips.js +0 -30
  389. package/src/ardy/retime.js +0 -211
  390. package/src/ardy/root-drop.js +0 -369
  391. package/src/ardy/timeline-resize.js +0 -13
  392. package/src/ardy/to-cskel27.js +0 -225
  393. package/src/ardy/trim.js +0 -33
  394. package/src/ardy/waypoints.js +0 -369
  395. package/src/ardy-bridge-contract.js +0 -281
  396. package/src/domains/motion/motion-video-capture.js +0 -664
  397. package/src/i2v-motion-client.js +0 -77
  398. package/src/i2v-motion-studio.jsx +0 -180
  399. package/src/line-edit.js +0 -1348
  400. package/src/motion/generation.js +0 -105
  401. package/src/motion-readiness-ui.jsx +0 -67
  402. package/src/motion-readiness.js +0 -40
  403. package/src/motion-resources.js +0 -177
  404. package/src/motion-store.js +0 -65
  405. package/src/motion-trail.js +0 -601
  406. package/src/multimodel-ingest.js +0 -394
  407. package/src/panels/PromptBlocksPanel.jsx +0 -444
  408. package/src/panels/VideoCapturePanel.jsx +0 -181
  409. package/src/pose-extract/browser.js +0 -42
  410. package/src/pose-extract/detector.js +0 -100
  411. package/src/pose-extract/fit.js +0 -227
  412. package/src/pose-extract/image-frame.js +0 -51
  413. package/src/pose-extract/index.js +0 -58
  414. package/src/pose-extract/keyframes.js +0 -74
  415. package/src/pose-extract/landmarks.js +0 -162
  416. package/src/pose-extract/mirror.js +0 -153
  417. package/src/pose-extract/take.js +0 -131
  418. package/src/pose-extract/video-frames.js +0 -91
  419. package/src/take-recipe.js +0 -165
  420. package/src/trail-key-conflicts.js +0 -39
  421. package/src/trail-pick.js +0 -59
  422. package/src/use-i2v-capture-modal.js +0 -15
  423. package/tools/ardy/BRIDGE.md +0 -37
  424. package/tools/ardy/GVHMR-NPZ.md +0 -68
  425. package/tools/ardy/README.md +0 -124
  426. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  427. package/tools/ardy/artifacts.mjs +0 -34
  428. package/tools/ardy/bridge.mjs +0 -1732
  429. package/tools/ardy/bvh-cskel27.mjs +0 -1265
  430. package/tools/ardy/cclay_constrained_generate.py +0 -561
  431. package/tools/ardy/cclay_gvhmr_worker.py +0 -264
  432. package/tools/ardy/cclay_motion_edit.py +0 -510
  433. package/tools/ardy/cclay_sequence_generate.py +0 -705
  434. package/tools/ardy/cclay_worker.py +0 -304
  435. package/tools/ardy/dump-npz.py +0 -205
  436. package/tools/ardy/extract-rest.mjs +0 -299
  437. package/tools/ardy/extract.mjs +0 -507
  438. package/tools/ardy/footage.mjs +0 -467
  439. package/tools/ardy/gvhmr-floor.mjs +0 -102
  440. package/tools/ardy/gvhmr-to-cskel27.mjs +0 -21
  441. package/tools/ardy/gvhmr_fastpath.py +0 -168
  442. package/tools/ardy/gvhmr_trajectory.py +0 -240
  443. package/tools/ardy/merge-text-encoder.py +0 -160
  444. package/tools/ardy/mocap-metrics.mjs +0 -200
  445. package/tools/ardy/mocap-quality-gate.mjs +0 -110
  446. package/tools/ardy/motion-stabilize.mjs +0 -348
  447. package/tools/ardy/npz.mjs +0 -414
  448. package/tools/ardy/pose-to-npz.mjs +0 -106
  449. package/tools/ardy/prompt-limits.mjs +0 -5
  450. package/tools/ardy/run-edit-on-box.sh +0 -73
  451. package/tools/ardy/run-local.mjs +0 -233
  452. package/tools/ardy/run-on-box.sh +0 -593
  453. package/tools/ardy/run-sequence-on-box.sh +0 -177
  454. package/tools/ardy/runners/gvhmr-worker.mjs +0 -206
  455. package/tools/ardy/runners/index.mjs +0 -18
  456. package/tools/ardy/runners/local.mjs +0 -455
  457. package/tools/ardy/runners/proc.mjs +0 -125
  458. package/tools/ardy/runners/remote.mjs +0 -252
  459. package/tools/ardy/setup-local.mjs +0 -189
  460. package/tools/ardy/setup-text-encoder-on-box.sh +0 -93
  461. package/tools/ardy/setup-text-encoder.py +0 -306
  462. package/tools/ardy/smpl-cskel27.mjs +0 -178
  463. package/tools/ardy/visual-qa.mjs +0 -196
  464. package/tools/ardy/vq-car.mjs +0 -52
  465. package/tools/bench/EXP3.md +0 -61
  466. package/tools/bench/cclay_bench_extract_incam.py +0 -125
  467. package/tools/bench/cclay_bench_extract_obs.py +0 -204
  468. package/tools/bench/cclay_bench_runner.py +0 -42
  469. package/tools/bench/cube-contact.mjs +0 -162
  470. package/tools/bench/exp3.mjs +0 -152
  471. package/tools/bench/extract-bench-lib.mjs +0 -216
  472. package/tools/bench/extract-bench.mjs +0 -302
  473. package/tools/bench/fal-generate.mjs +0 -88
  474. package/tools/bench/fit/README.md +0 -189
  475. package/tools/bench/fit/camera.mjs +0 -59
  476. package/tools/bench/fit/contact.mjs +0 -419
  477. package/tools/bench/fit/footlock.mjs +0 -234
  478. package/tools/bench/fit/motion.mjs +0 -85
  479. package/tools/bench/fit/pin.mjs +0 -27
  480. package/tools/bench/fit/remote.mjs +0 -105
  481. package/tools/bench/fit-bench.mjs +0 -126
  482. package/tools/bench/fit-sanity.mjs +0 -45
  483. package/tools/bench/metrics.mjs +0 -313
  484. package/tools/bench/obs/depth.mjs +0 -258
  485. package/tools/bench/obs/extrinsics.mjs +0 -175
  486. package/tools/bench/obs/fit_mannequin_betas.py +0 -340
  487. package/tools/bench/obs/ground.mjs +0 -294
  488. package/tools/bench/obs/heading.mjs +0 -151
  489. package/tools/bench/obs/ladder.mjs +0 -555
  490. package/tools/bench/obs/mannequin-betas.json +0 -124
  491. package/tools/bench/obs/remote.mjs +0 -83
  492. package/tools/bench/obs/rest_joints.py +0 -52
  493. package/tools/bench/obs/ybot-targets.mjs +0 -49
  494. package/tools/bench/obs-bench.mjs +0 -520
  495. package/tools/bench/score.mjs +0 -343
  496. package/tools/bench/summarize.mjs +0 -86
  497. package/tools/demo-worker/README.md +0 -137
  498. package/tools/demo-worker/api-client.mjs +0 -233
  499. package/tools/demo-worker/generate.mjs +0 -301
  500. package/tools/demo-worker/index.mjs +0 -466
  501. package/tools/demo-worker/sign-core.mjs +0 -112
  502. package/tools/demo-worker/sign.mjs +0 -161
  503. package/tools/gt-render/browser.mjs +0 -159
  504. package/tools/gt-render/camera-math.mjs +0 -276
  505. package/tools/gt-render/page.mjs +0 -267
  506. package/tools/gt-render/render.mjs +0 -514
  507. package/tools/gt-render/scene-box.mjs +0 -31
  508. package/tools/gt-render/take-transform.mjs +0 -87
  509. package/tools/kimodo/compare-seam.mjs +0 -108
  510. package/tools/kimodo/constraints.mjs +0 -244
  511. package/tools/kimodo/edit.mjs +0 -259
  512. package/tools/kimodo/effector-constraints.mjs +0 -200
  513. package/tools/kimodo/generate.mjs +0 -646
  514. package/tools/kimodo/local-output.mjs +0 -265
  515. package/tools/kimodo/measure-pose.mjs +0 -65
  516. package/tools/kimodo/measure-preserve.mjs +0 -899
  517. package/tools/kimodo/measure-waypoints.mjs +0 -55
  518. package/tools/kimodo/mlx-generate.py +0 -111
  519. package/tools/kimodo/pose-constraints.mjs +0 -248
  520. package/tools/kimodo/preserve-mask.mjs +0 -543
  521. package/tools/kimodo/read-npz.mjs +0 -189
  522. package/tools/kimodo/run-edit-on-box.mjs +0 -155
  523. package/tools/kimodo/run-sequence-on-box.mjs +0 -178
  524. package/tools/kimodo/runner.mjs +0 -284
  525. package/tools/kimodo/seam-batch.mjs +0 -77
  526. package/tools/kimodo/setup-local.mjs +0 -30
  527. package/tools/kimodo/setup-on-box.sh +0 -339
  528. package/tools/kimodo/soma77-to-cskel27.mjs +0 -305
  529. package/tools/morphgs/assets/gen_truth.py +0 -39
  530. package/tools/morphgs/assets/mesh_ori_rig.txt +0 -27
  531. package/tools/morphgs/assets/playback-check.mjs +0 -30
  532. package/tools/morphgs/demo-gate.sh +0 -26
  533. package/tools/morphgs/fbx2morphgs.mjs +0 -68
  534. package/tools/morphgs/morphgs-to-cskel27.mjs +0 -105
  535. package/tools/morphgs/patches/preprocess_src-none-mode.patch +0 -76
  536. package/tools/morphgs/setup-on-cluster.sh +0 -126
  537. package/tools/projflow/driver.py +0 -1069
  538. package/tools/projflow/generate.mjs +0 -805
  539. package/tools/projflow/hml22-to-cskel27.mjs +0 -735
  540. package/tools/projflow/line-edit-job.mjs +0 -547
  541. package/tools/projflow/replay.mjs +0 -452
  542. package/tools/projflow/runner.mjs +0 -269
  543. package/tools/projflow/service.mjs +0 -742
  544. package/tools/track/DESIGN.md +0 -513
  545. package/tools/track/backfill-provenance.mjs +0 -116
  546. package/tools/track/budget.mjs +0 -8
  547. package/tools/track/check-rig.mjs +0 -209
  548. package/tools/track/diagnostics.schema.json +0 -60
  549. package/tools/track/export-rig.mjs +0 -256
  550. package/tools/track/fallback.mjs +0 -7
  551. package/tools/track/fk-parity-fixture.mjs +0 -161
  552. package/tools/track/gate.mjs +0 -349
  553. package/tools/track/masks.mjs +0 -318
  554. package/tools/track/metrics.mjs +0 -201
  555. package/tools/track/publish-obs.mjs +0 -108
  556. package/tools/track/py/check_env.py +0 -61
  557. package/tools/track/py/eval_lr.py +0 -229
  558. package/tools/track/py/lr_viterbi.py +0 -294
  559. package/tools/track/py/masks.py +0 -405
  560. package/tools/track/py/objective.py +0 -382
  561. package/tools/track/py/rig.py +0 -508
  562. package/tools/track/py/scene.py +0 -326
  563. package/tools/track/py/test_joint_indices.py +0 -123
  564. package/tools/track/py/test_lr_viterbi.py +0 -195
  565. package/tools/track/py/test_masks.py +0 -229
  566. package/tools/track/py/test_rig.py +0 -288
  567. package/tools/track/py/test_scene.py +0 -354
  568. package/tools/track/py/test_track.py +0 -328
  569. package/tools/track/py/track.py +0 -411
  570. package/tools/track/remote.mjs +0 -140
  571. package/tools/track/rig-dump.mjs +0 -227
  572. package/tools/track/run-box-tests.mjs +0 -14
  573. package/tools/track/run-box.mjs +0 -168
  574. package/tools/track/setup-box.sh +0 -59
  575. package/tools/track/study-2d.mjs +0 -728
  576. /package/src/{ardy → motion}/collision-blockers.js +0 -0
  577. /package/src/{ardy → motion}/ground.js +0 -0
  578. /package/src/{ardy → motion}/ik-drag.js +0 -0
  579. /package/src/{ardy → motion}/ik-key-json.js +0 -0
  580. /package/src/{ardy → motion}/ik.js +0 -0
  581. /package/src/{ardy → motion}/physics-surface.js +0 -0
  582. /package/src/{ardy → motion}/playback-clock.js +0 -0
  583. /package/src/{ardy → motion}/range-pin.js +0 -0
@@ -1,2257 +0,0 @@
1
- import * as THREE from "three";
2
- import {
3
- findBone,
4
- solveIk,
5
- solveMidJoint,
6
- solveSwingAngle,
7
- ikTouch,
8
- ikBakeKeyframe,
9
- measureContactRadii,
10
- hasBindPose,
11
- bindWorldPosition,
12
- restWorldPosition,
13
- } from "./ik.js";
14
-
15
- /**
16
- * Self-collision cleanup for generated motion: detect body parts that pass
17
- * through each other (a hand through the thigh, a forearm through the
18
- * chest) and push them apart with the existing two-bone IK solver.
19
- *
20
- * Standard character-cleanup technique (capsule proxies + iterative
21
- * projection, as in classic mocap-clean literature and DCC plugins), built
22
- * entirely on CozyClay's own pieces:
23
- *
24
- * - Each body segment gets a CAPSULE proxy (bone → child bone axis, radius
25
- * measured from the bind-pose skinned mesh by measureContactRadii).
26
- * - Torso/head capsules are STATIC blockers; limb capsules are MOVABLE.
27
- * - A penetration is resolved by moving the limb — mid joint (elbow/knee)
28
- * for the upper segment, effector (wrist/ankle) for the lower — out by
29
- * (depth + offset) along the separation normal BIASED TOWARD THE REST POSE,
30
- * so a deep hit escapes to the side the limb belongs on instead of taking
31
- * the shortest way out (see REST-BIASED ESCAPE). solveIk's bend continuity
32
- * keeps the elbow/knee on its own side, so the fix never flips a hinge.
33
- * - Passes repeat until nothing penetrates or maxIterations is hit — the
34
- * same projected-gauss-seidel loop physics engines use, but with IK as
35
- * the projection step.
36
- *
37
- * The result is baked through ikBakeKeyframe, so a fix is an ordinary IK
38
- * correction key: undoable, scrubbable, and blended back to the clip
39
- * outside its keyed range by ikEvaluate's blend window.
40
- */
41
-
42
- /**
43
- * Body capsules. `movable.chain`/`movable.joint` name the IK chain and the
44
- * joint of that chain to drive ("mid" = elbow/knee, "effector" = wrist/
45
- * ankle). Torso, chest and head have no chain: they are blockers the limbs
46
- * get pushed out of. `radiusJoint` keys into the measured contact radii.
47
- *
48
- * `endOptional` marks an end bone the rig is allowed not to have: the hands
49
- * span wrist → middle-finger base so a fingertip driven into a thigh is
50
- * judged at the FINGERS, not at the wrist a palm's length behind them (a
51
- * wrist-only sphere reads that pose as clean). Rigs exported without finger
52
- * bones simply fall back to the wrist sphere instead of disqualifying the
53
- * whole tool.
54
- */
55
- // `maxRadius` caps the measured radius where the mesh extent is not a
56
- // thickness: the head measurement runs crown-to-chin (a length), the foot
57
- // measurement follows the toe (also a length). Uncapped, those fat capsules
58
- // flag permanent false contacts with the torso and with the other foot at
59
- // any normal stance.
60
- const CAPSULE_DEFS = [
61
- { id: "torso", start: "mixamorigHips", end: "mixamorigSpine1", radiusJoint: "Spine", maxRadius: 0.17, movable: null },
62
- { id: "chest", start: "mixamorigSpine1", end: "mixamorigSpine2", radiusJoint: "Spine", maxRadius: 0.16, movable: null },
63
- { id: "head", start: "mixamorigNeck", end: "mixamorigHead", radiusJoint: "Head", maxRadius: 0.11, movable: null },
64
- { id: "leftUpperArm", start: "mixamorigLeftArm", end: "mixamorigLeftForeArm", radiusJoint: "LeftArm", movable: { chain: "leftHand", joint: "mid" }, priority: 2 },
65
- { id: "leftForeArm", start: "mixamorigLeftForeArm", end: "mixamorigLeftHand", radiusJoint: "LeftForeArm", movable: { chain: "leftHand", joint: "effector" }, priority: 2 },
66
- { id: "leftHand", start: "mixamorigLeftHand", end: "mixamorigLeftHandMiddle1", endOptional: true, radiusJoint: "LeftHand", maxRadius: 0.06, movable: { chain: "leftHand", joint: "effector" }, priority: 2 },
67
- { id: "rightUpperArm", start: "mixamorigRightArm", end: "mixamorigRightForeArm", radiusJoint: "RightArm", movable: { chain: "rightHand", joint: "mid" }, priority: 2 },
68
- { id: "rightForeArm", start: "mixamorigRightForeArm", end: "mixamorigRightHand", radiusJoint: "RightForeArm", movable: { chain: "rightHand", joint: "effector" }, priority: 2 },
69
- { id: "rightHand", start: "mixamorigRightHand", end: "mixamorigRightHandMiddle1", endOptional: true, radiusJoint: "RightHand", maxRadius: 0.06, movable: { chain: "rightHand", joint: "effector" }, priority: 2 },
70
- { id: "leftThigh", start: "mixamorigLeftUpLeg", end: "mixamorigLeftLeg", radiusJoint: "LeftUpLeg", movable: { chain: "leftFoot", joint: "mid" }, priority: 1 },
71
- { id: "leftShin", start: "mixamorigLeftLeg", end: "mixamorigLeftFoot", radiusJoint: "LeftLeg", movable: { chain: "leftFoot", joint: "effector" }, priority: 1 },
72
- { id: "leftFoot", start: "mixamorigLeftFoot", end: "mixamorigLeftToeBase", radiusJoint: "LeftFoot", maxRadius: 0.07, movable: { chain: "leftFoot", joint: "effector" }, priority: 1 },
73
- { id: "rightThigh", start: "mixamorigRightUpLeg", end: "mixamorigRightLeg", radiusJoint: "RightUpLeg", movable: { chain: "rightFoot", joint: "mid" }, priority: 1 },
74
- { id: "rightShin", start: "mixamorigRightLeg", end: "mixamorigRightFoot", radiusJoint: "RightLeg", movable: { chain: "rightFoot", joint: "effector" }, priority: 1 },
75
- { id: "rightFoot", start: "mixamorigRightFoot", end: "mixamorigRightToeBase", radiusJoint: "RightFoot", maxRadius: 0.07, movable: { chain: "rightFoot", joint: "effector" }, priority: 1 },
76
- ];
77
-
78
- /**
79
- * FINGER CAPSULES. The hand capsule spans wrist → middle-finger base, which is
80
- * a palm: it still reads a pose where the FINGERS are buried in a thigh and the
81
- * palm is not as clean, because the fingers stick out another 8 cm past its end
82
- * cap. Mixamo hands carry mixamorig{Side}Hand{Finger}1..4, so each finger gets
83
- * its own thin capsule from the base joint to the tip.
84
- *
85
- * OPTIONAL, ONE FINGER AT A TIME. Game rigs ship every mixture of these bones:
86
- * all four joints, three, a thumb only, none at all. A finger falls back from
87
- * 1→4 to 1→3 and, failing both, is simply left out — `optional` means a MISSING
88
- * finger is not a missing capsule but a missing entry, so a fingerless rig is
89
- * still fully supported (supportsCollisionCleanup ignores optional defs) and
90
- * every other proxy is built exactly as before.
91
- *
92
- * RADIUS is a constant. measureContactRadii's CONTACT_JOINTS carry no finger
93
- * entries — there is nothing to look up — and a finger is the one body part
94
- * whose thickness barely varies between characters.
95
- */
96
- export const FINGER_RADIUS = 0.011;
97
-
98
- const FINGER_NAMES = ["Thumb", "Index", "Middle", "Ring", "Pinky"];
99
-
100
- for (const side of ["Left", "Right"]) {
101
- const lower = side === "Left" ? "left" : "right";
102
- for (const finger of FINGER_NAMES) {
103
- CAPSULE_DEFS.push({
104
- id: `${lower}Hand${finger}`,
105
- start: `mixamorig${side}Hand${finger}1`,
106
- // First spelling that resolves wins: the tip, else the last knuckle.
107
- ends: [`mixamorig${side}Hand${finger}4`, `mixamorig${side}Hand${finger}3`],
108
- optional: true,
109
- fixedRadius: FINGER_RADIUS,
110
- // The two ids a finger must never be tested against beyond its own
111
- // kind — see pairExcluded.
112
- handId: `${lower}Hand`,
113
- foreArmId: `${lower}ForeArm`,
114
- movable: { chain: `${lower}Hand`, joint: "effector" },
115
- priority: 2,
116
- });
117
- }
118
- }
119
-
120
- /**
121
- * A pair whose two capsules ride the SAME two-bone chain: the elbow folded shut,
122
- * the knee folded shut. Every push moves both ends together, so no solver run
123
- * can open the gap — the fixer must never act on one (it spends its passes
124
- * rearranging the limb, and on QA's yawed box it chased a fold the fix itself
125
- * had created and drove the hand 574 mm, 55 mm INTO the box). Detection still
126
- * REPORTS it, because a viewer can see a folded limb; what it must not do is
127
- * count as a frame the tool failed to clean.
128
- */
129
- export function isHingeFold(pen) {
130
- const a = pen?.a?.def?.movable?.chain;
131
- const b = pen?.b?.def?.movable?.chain;
132
- return Boolean(a && b && a === b);
133
- }
134
-
135
- /** Fallback radius when no measurement exists (matches ik.js's floor). */
136
- const RADIUS_FALLBACK = 0.01;
137
-
138
- /** Soft-tissue compression allowance, as a fraction of the thinner capsule.
139
- * See detectPenetrations for why this is 0.25 and not something roomier. */
140
- export const DEFAULT_SLACK_FACTOR = 0.25;
141
-
142
- /** How much closer than its BIND pose a pair must come before the extra
143
- * closeness counts as a collision. See restPairOverlaps. 5 mm is under the
144
- * amplitude of the proxy error itself and far under any visible interpenetration. */
145
- export const PAIR_TOLERANCE = 0.005;
146
-
147
- /**
148
- * REPORTING FLOOR. A capsule proxy is a coarse stand-in for a specific mesh, and
149
- * its agreement with that mesh is worth a few millimetres at best; the pushes it
150
- * takes to resolve one are not small (a 0.2 mm thigh × thigh reading during a
151
- * normal walk stride moved the pose 16 mm and keyed the frame). So a pair under
152
- * this depth is not reported at all — not by detection, not to the fixer, not to
153
- * the readout — and the tool spends its passes on contact a viewer can actually
154
- * see.
155
- */
156
- export const MIN_DEPTH = 0.004;
157
-
158
- /** Clearance the pushed joint keeps above the floor. A separation normal has
159
- * no idea the ground exists, so an otherwise correct push can drive an ankle
160
- * or a knee underground; the fix trades a little residual for never solving one
161
- * artefact by creating a worse one. */
162
- export const PUSH_FLOOR_CLEARANCE = 0.005;
163
-
164
- /** Foot markers the accept gate keeps above the floor. The push guard clamps
165
- * the joint it DRIVES, which is the ankle; the toe hangs off the far side of
166
- * the foot and goes under the deck while the ankle sits happily at its
167
- * clearance. Judging the markers catches what the push guard structurally
168
- * cannot. */
169
- const FLOOR_MARKERS = [
170
- "mixamorigLeftFoot", "mixamorigRightFoot", "mixamorigLeftToeBase", "mixamorigRightToeBase",
171
- ];
172
-
173
- /**
174
- * TORSO/HEAD YIELD. Torso, chest and head are static blockers, which is right
175
- * for the geometry and wrong for the failure mode: when a limb is reach-limited
176
- * — a leg pinned by the floor guard, an arm at full stretch — the passes run
177
- * out with the penetration still there and the tool reports a residual it had no
178
- * way to spend. A body leans out of the way of its own knee; the rig should be
179
- * allowed to as well.
180
- *
181
- * So, only AFTER the limb passes have done what they can, the blocker gives a
182
- * little: a small FK swing on the spine (Spine1, the `chest` FK joint) or the
183
- * neck, away from the contact. Capped hard in both directions — per step and per
184
- * run — because a yield that could grow without bound would turn every hard case
185
- * into a bent-over character, and because the cap is what keeps the loop finite.
186
- * Every yielded joint id lands in `touched`, so the caller's bake keys the spine
187
- * exactly as it keys a limb.
188
- */
189
- export const YIELD_STEP_RAD = (6 * Math.PI) / 180;
190
- export const YIELD_TOTAL_RAD = (12 * Math.PI) / 180;
191
-
192
- /**
193
- * Which FK joint carries each static blocker's yield — the joint whose rotation
194
- * actually MOVES that capsule. A capsule is drawn between two bone POSITIONS,
195
- * and rotating a bone never moves its own origin: swinging Spine1 turns the
196
- * chest (Spine1 → Spine2) but leaves the torso capsule (Hips → Spine1) exactly
197
- * where it was, so the torso's yield belongs one joint lower, at Spine, whose
198
- * swing carries Spine1 — and the chest with it.
199
- */
200
- const YIELD_JOINTS = { torso: "spine", chest: "chest", head: "neck" };
201
-
202
- /** The bone behind each of those ids, for a caller that has no FK joint map to
203
- * lend. Shaped exactly like resolveIkRig's entries — track id, bone, bind local
204
- * position — because solveSwingAngle and ikBakeKeyframe both read them that way,
205
- * and because the ids have to match the App's FK tracks for the bake to land. */
206
- const YIELD_BONES = { spine: "mixamorigSpine", chest: "mixamorigSpine1", neck: "mixamorigNeck" };
207
- function fallbackYieldJoints(rig) {
208
- const joints = new Map();
209
- for (const [id, name] of Object.entries(YIELD_BONES)) {
210
- const bone = findBone(rig, name);
211
- if (!bone) continue;
212
- const saved = rig.userData?.poseBind?.get(bone)?.position;
213
- joints.set(id, {
214
- track: { id },
215
- bone,
216
- bindPos: saved ? new THREE.Vector3(saved.x, saved.y, saved.z) : bone.position.clone(),
217
- });
218
- }
219
- return joints;
220
- }
221
-
222
- /**
223
- * True when the rig carries every bone the capsule table REQUIRES — the
224
- * cheap pre-flight the UI runs to decide whether to offer the tool at all.
225
- * Bone lookups only: no mesh measurement, so it is safe to call per render.
226
- * Optional ends (the finger bones) never count against a rig.
227
- */
228
- export function supportsCollisionCleanup(rig) {
229
- if (!rig) return false;
230
- for (const def of CAPSULE_DEFS) {
231
- // An OPTIONAL capsule (the fingers) is an extra proxy, never a
232
- // requirement: a rig without them is described just as well without them.
233
- if (def.optional) continue;
234
- if (!findBone(rig, def.start)) return false;
235
- if (def.end && !def.endOptional && !findBone(rig, def.end)) return false;
236
- }
237
- return true;
238
- }
239
-
240
- /** The capsule table against an arbitrary source of bone positions — the
241
- * current pose for detection, the bind pose for calibration. Returns null when
242
- * a REQUIRED bone is missing. */
243
- function buildCapsules(rig, radiiMap, positionOf) {
244
- const capsules = new Map();
245
- for (const def of CAPSULE_DEFS) {
246
- const start = findBone(rig, def.start);
247
- // An OPTIONAL capsule the rig has no bones for is left out, not fatal.
248
- if (!start) {
249
- if (def.optional) continue;
250
- return null;
251
- }
252
- let end = null;
253
- if (def.ends) {
254
- for (const name of def.ends) {
255
- end = findBone(rig, name);
256
- if (end) break;
257
- }
258
- // A finger with a base joint but no further joint has no segment worth
259
- // testing: skip it rather than park a knuckle-sized sphere on the hand.
260
- if (!end) continue;
261
- } else if (def.end) {
262
- end = findBone(rig, def.end);
263
- // A missing OPTIONAL end degrades to the wrist sphere; a missing
264
- // required end means this is not a rig we can build proxies for.
265
- if (!end && !def.endOptional) return null;
266
- }
267
- const a = positionOf(start);
268
- const b = end ? positionOf(end) : a.clone();
269
- const measured = Math.max(RADIUS_FALLBACK, radiiMap[def.radiusJoint] ?? RADIUS_FALLBACK);
270
- const radius = def.fixedRadius ?? (def.maxRadius ? Math.min(measured, def.maxRadius) : measured);
271
- capsules.set(def.id, {
272
- def,
273
- bones: end ? [start, end] : [start],
274
- a,
275
- b,
276
- radius,
277
- });
278
- }
279
- return capsules;
280
- }
281
-
282
- /**
283
- * Build world-space capsules for the rig's CURRENT pose. Cheap (a bone
284
- * lookup + two world positions per entry), so callers rebuild per frame.
285
- * `radii` overrides the measured map (tests, or rigs without skin weights).
286
- * Returns null when a REQUIRED bone is missing — a non-Mixamo rig the tool
287
- * cannot describe, same policy as resolveIkRig. Callers must tell that apart
288
- * from "found nothing to fix": see fixCollisions's `supported` flag.
289
- */
290
- export function buildCollisionCapsules(rig, radii = null) {
291
- if (!rig) return null;
292
- rig.updateMatrixWorld(true);
293
- const radiiMap = radii ?? measureContactRadii(rig);
294
- const capsules = buildCapsules(rig, radiiMap, (bone) => bone.getWorldPosition(new THREE.Vector3()));
295
- // Ride the pair calibration along on the map so detectPenetrations gets it
296
- // for free — it is a property of the RIG, which detection alone cannot see.
297
- if (capsules) capsules.pairAllowances = restPairOverlaps(rig, radiiMap);
298
- return capsules;
299
- }
300
-
301
- /** Stable key for an unordered capsule pair. */
302
- function pairKey(idA, idB) {
303
- return idA < idB ? `${idA}|${idB}` : `${idB}|${idA}`;
304
- }
305
-
306
- // rig → (radii object identity → pair overlap map). The reference poses never
307
- // change, so this is measured once per character.
308
- const restOverlapCache = new WeakMap();
309
-
310
- /** Every testable pair's overlap in one reference pose, positive entries only,
311
- * merged into `into` by taking the larger of the two. Measured through the SAME
312
- * pairGeometry detection uses, so a trimmed (hinged) pair is calibrated on the
313
- * portions it will actually be judged on — the natural fold a character rests
314
- * in is a legitimate overlap there exactly as it is anywhere else. */
315
- function collectPairOverlaps(rig, radiiMap, positionOf, into) {
316
- const capsules = buildCapsules(rig, radiiMap, positionOf);
317
- if (!capsules) return into;
318
- const list = [...capsules.values()];
319
- const pa = new THREE.Vector3();
320
- const pb = new THREE.Vector3();
321
- for (let i = 0; i < list.length; i += 1) {
322
- for (let j = i + 1; j < list.length; j += 1) {
323
- const geo = pairGeometry(list[i], list[j]);
324
- if (!geo) continue;
325
- closestPointsSegmentSegment(geo.a0, geo.a1, geo.b0, geo.b1, pa, pb);
326
- const overlap = list[i].radius + list[j].radius - pa.distanceTo(pb);
327
- if (overlap <= 0) continue;
328
- const key = pairKey(list[i].def.id, list[j].def.id);
329
- if (overlap > (into.get(key) ?? 0)) into.set(key, overlap);
330
- }
331
- }
332
- return into;
333
- }
334
-
335
- /**
336
- * How much each non-adjacent capsule pair ALREADY overlaps in a pose the
337
- * character legitimately holds, in metres (positive entries only; a pair that is
338
- * clear in every reference pose has no entry). Returns null on a rig with no
339
- * bind snapshot to compose from.
340
- *
341
- * Capsules are a coarse proxy for a specific mesh, and on real characters the
342
- * proxy error is not small: on the shipped default rig the REST pose reports
343
- * torso × upper arm at 9.2 mm of "penetration" (torso 0.17 + upper arm 0.05447
344
- * + offset 0.002 against a 0.19547 axis distance, minus a 21.8 mm slack
345
- * allowance). One press of Fix collisions on a brand-new project baked a phantom
346
- * key and moved the rig 13.7 mm. Slack cannot fix this: the rig would need
347
- * slackFactor ≥ 0.569 to read clean, and a global allowance that loose re-masks
348
- * every real contact on every other pair.
349
- *
350
- * So the pair's own reference overlap becomes its zero point — the same
351
- * clip-relative-ground move auto-physics made for the floor.
352
- *
353
- * TWO REFERENCE POSES, and the second is the one that matters. `poseBind` is a
354
- * T-POSE: arms straight out, nowhere near the torso (LeftForeArm at x = 0.4616).
355
- * The pose the app actually shows is DEFAULT_POSE — arms hanging at the sides,
356
- * the same forearm at x = 0.1876 — and that is where the arm capsules graze the
357
- * torso. Calibrating against bind alone therefore recorded only leftThigh ×
358
- * rightThigh and left torso × upper arm at zero allowance, which is exactly the
359
- * pair that false-positives. Both poses are measured and the LARGER allowance
360
- * wins, so a T-posed character and a standing one both read clean, while a pair
361
- * that overlaps in neither (a hand driven into a thigh) still fires at full
362
- * sensitivity. Both are composed virtually through ik.js, so measuring never
363
- * disturbs — or depends on — the pose currently on screen.
364
- *
365
- * The cost, stated plainly: contact that the rest pose already has is invisible
366
- * here. An arm pressed flat against the ribs cannot be distinguished from an arm
367
- * hanging beside them, because the capsule proxies genuinely cannot tell those
368
- * apart. Excluding those pairs outright (the alternative) would lose the deep
369
- * hits too; this keeps them.
370
- */
371
- export function restPairOverlaps(rig, radii = null) {
372
- if (!rig || !hasBindPose(rig)) return null;
373
- const radiiMap = radii ?? measureContactRadii(rig);
374
- let perRadii = restOverlapCache.get(rig);
375
- if (!perRadii) restOverlapCache.set(rig, (perRadii = new Map()));
376
- const cached = perRadii.get(radiiMap);
377
- if (cached) return cached;
378
- rig.updateMatrixWorld(true);
379
- const overlaps = new Map();
380
- collectPairOverlaps(rig, radiiMap, (bone) => bindWorldPosition(rig, bone, new THREE.Vector3()), overlaps);
381
- collectPairOverlaps(rig, radiiMap, (bone) => restWorldPosition(rig, bone, new THREE.Vector3()), overlaps);
382
- perRadii.set(radiiMap, overlaps);
383
- return overlaps;
384
- }
385
-
386
- /** Closest points between two segments (Ericson, Real-Time Collision
387
- * Detection — the textbook segment/segment distance). Writes into outA/outB
388
- * and returns the squared distance. */
389
- function closestPointsSegmentSegment(p1, q1, p2, q2, outA, outB) {
390
- const d1 = q1.clone().sub(p1);
391
- const d2 = q2.clone().sub(p2);
392
- const r = p1.clone().sub(p2);
393
- const a = d1.dot(d1);
394
- const e = d2.dot(d2);
395
- const f = d2.dot(r);
396
- let s;
397
- let t;
398
- if (a <= 1e-12 && e <= 1e-12) {
399
- outA.copy(p1); outB.copy(p2);
400
- return r.dot(r);
401
- }
402
- if (a <= 1e-12) {
403
- s = 0;
404
- t = Math.max(0, Math.min(1, f / e));
405
- } else {
406
- const c = d1.dot(r);
407
- if (e <= 1e-12) {
408
- t = 0;
409
- s = Math.max(0, Math.min(1, -c / a));
410
- } else {
411
- const b = d1.dot(d2);
412
- const denom = a * e - b * b;
413
- s = denom > 1e-12 ? Math.max(0, Math.min(1, (b * f - c * e) / denom)) : 0;
414
- t = (b * s + f) / e;
415
- if (t < 0) { t = 0; s = Math.max(0, Math.min(1, -c / a)); }
416
- else if (t > 1) { t = 1; s = Math.max(0, Math.min(1, (b - c) / a)); }
417
- }
418
- }
419
- outA.copy(p1).addScaledVector(d1, s);
420
- outB.copy(p2).addScaledVector(d2, t);
421
- return outA.distanceToSquared(outB);
422
- }
423
-
424
- /** Ancestor hop count between two bones, or Infinity when neither is an
425
- * ancestor of the other within `limit` hops. */
426
- const HOP_SEARCH = 8;
427
- function boneHops(b0, b1, limit = HOP_SEARCH) {
428
- let hops = 0;
429
- for (let node = b0; node && hops <= limit; node = node.parent, hops += 1) {
430
- if (node === b1) return hops;
431
- }
432
- hops = 0;
433
- for (let node = b1; node && hops <= limit; node = node.parent, hops += 1) {
434
- if (node === b0) return hops;
435
- }
436
- return Infinity;
437
- }
438
-
439
- /** Capsules whose endpoints sit within HOP_LIMIT hops on the skeleton are
440
- * NEIGHBOURS (upper arm against chest at the shoulder, thigh against the
441
- * pelvis, forearm against upper arm at the elbow): they always overlap AT the
442
- * joint they share, so they get the trimmed test below rather than the plain
443
- * one. */
444
- const HOP_LIMIT = 2;
445
- function bonesRelated(b0, b1) {
446
- return boneHops(b0, b1, HOP_LIMIT) <= HOP_LIMIT;
447
- }
448
-
449
- function capsulesAdjacent(ca, cb) {
450
- return ca.bones.some((ba) => cb.bones.some((bb) => bonesRelated(ba, bb)));
451
- }
452
-
453
- /**
454
- * HINGED PAIRS. A blanket "skip everything within HOP_LIMIT" made whole classes
455
- * of real interpenetration invisible: a knee driven into the belly is
456
- * thigh × torso, an arm folded past its own elbow is forearm × upper arm, and
457
- * neither pair was ever tested. The skip existed for a good reason — the two
458
- * capsules MEET at the joint they share, so an untrimmed test reports a
459
- * permanent collision at every pose and pins the fixer forever — but that
460
- * reason only covers the part of each capsule NEXT TO the joint.
461
- *
462
- * So the pair is tested on the portions FAR from the shared joint: TRIM_FRACTION
463
- * of each segment is cut away from the joint side, and the segment-segment test
464
- * runs on what is left. A normally bent elbow keeps its far portions well apart;
465
- * a fully folded one lays them on top of each other, which is exactly the pose
466
- * that has to be caught.
467
- *
468
- * The joint side is the endpoint CLOSER to the other capsule in the hierarchy
469
- * (fewest ancestor hops), so the trim always eats the shared end and never the
470
- * free one.
471
- */
472
- export const TRIM_FRACTION = 0.45;
473
-
474
- /**
475
- * Compression allowance for a pair that SHARES a bone — the two segments of one
476
- * hinge (elbow, knee, ankle, wrist, waist). Flesh-to-flesh contact is the normal
477
- * state of a deep fold there, and the capsule proxies overlap by construction
478
- * long before the pose is wrong, so the thin-capsule slack rule is the wrong
479
- * ruler: this one is a fraction of the COMBINED radii, and only a fold that puts
480
- * the far end of one segment inside the body of the other clears it.
481
- */
482
- export const HINGE_SLACK_FACTOR = 0.65;
483
-
484
- /** Which end of `ca` faces `cb` on the skeleton: true = the START endpoint. */
485
- function jointSideIsStart(ca, cb) {
486
- const hopsOf = (bone) => Math.min(...cb.bones.map((other) => boneHops(bone, other)));
487
- const first = hopsOf(ca.bones[0]);
488
- const last = ca.bones.length > 1 ? hopsOf(ca.bones[ca.bones.length - 1]) : first;
489
- return first <= last;
490
- }
491
-
492
- /** `ca` with the joint-side TRIM_FRACTION cut off, written into out0/out1. */
493
- function trimTowardJoint(ca, cb, out0, out1) {
494
- if (jointSideIsStart(ca, cb)) {
495
- out0.copy(ca.a).lerp(ca.b, TRIM_FRACTION);
496
- out1.copy(ca.b);
497
- } else {
498
- out0.copy(ca.a);
499
- out1.copy(ca.b).lerp(ca.a, TRIM_FRACTION);
500
- }
501
- }
502
-
503
- /** How many bones the two capsules have in common (0, 1 or 2). */
504
- function sharedBoneCount(ca, cb) {
505
- return ca.bones.filter((bone) => cb.bones.includes(bone)).length;
506
- }
507
-
508
- /**
509
- * Pairs that must never be tested, whatever the pose.
510
- *
511
- * Adjacency already covers most of the finger cases (a finger base is one hop
512
- * from the hand, two from the forearm), but the trimmed test above means
513
- * "adjacent" no longer means "skipped", so the finger rules are spelled out
514
- * rather than inherited: a finger against its own hand, its own forearm, or any
515
- * other finger is a pose the proxies cannot judge — fingers touch each other and
516
- * lie against the palm constantly — while a finger against a thigh, a shin, the
517
- * torso, the chest, the head or the opposite arm is exactly what the finger
518
- * capsules were added to catch.
519
- */
520
- function pairExcluded(ca, cb) {
521
- const fa = ca.def.handId;
522
- const fb = cb.def.handId;
523
- if (fa && fb) return true; // finger × finger, either hand
524
- if (fa && (cb.def.id === fa || cb.def.id === ca.def.foreArmId)) return true;
525
- if (fb && (ca.def.id === fb || ca.def.id === cb.def.foreArmId)) return true;
526
- // SAME CHAIN, no shared joint: a hand against its own upper arm, a foot
527
- // against its own thigh, a finger against its own forearm. Two things are
528
- // true of every such pair and neither is true of a hinge: the pose is
529
- // legitimate (a hand rests on its own shoulder, a heel touches its own
530
- // thigh) and the fixer could not separate them anyway, because ONE solver
531
- // drives both sides and each push undoes the other — measured as a fold
532
- // that oscillated to maxIterations without converging. The fold that IS
533
- // impossible — the segments either side of the elbow or the knee — shares a
534
- // bone, and stays tested (trimmed) by the rule above.
535
- const chainA = ca.def.movable?.chain;
536
- const chainB = cb.def.movable?.chain;
537
- if (chainA && chainA === chainB && sharedBoneCount(ca, cb) === 0) return true;
538
- return false;
539
- }
540
-
541
- /** Scratch segments for the pair geometry below. One pair is measured at a time
542
- * (detection is a plain double loop), so these can be module-level. */
543
- const geoA0 = new THREE.Vector3();
544
- const geoA1 = new THREE.Vector3();
545
- const geoB0 = new THREE.Vector3();
546
- const geoB1 = new THREE.Vector3();
547
- const geometry = { a0: geoA0, a1: geoA1, b0: geoB0, b1: geoB1, hinge: false };
548
-
549
- /**
550
- * The segments to test for one capsule pair, or null when the pair is not
551
- * testable at all. Unrelated capsules are tested whole; neighbours are tested
552
- * on their trimmed portions (see HINGED PAIRS). The returned object is REUSED
553
- * between calls — read it before the next call.
554
- */
555
- function pairGeometry(ca, cb) {
556
- if (pairExcluded(ca, cb)) return null;
557
- if (!capsulesAdjacent(ca, cb)) {
558
- geoA0.copy(ca.a); geoA1.copy(ca.b);
559
- geoB0.copy(cb.a); geoB1.copy(cb.b);
560
- geometry.hinge = false;
561
- return geometry;
562
- }
563
- // Same segment, or two capsules over the same two bones: there is no "far
564
- // portion" to compare and nothing a fixer could do about it either way.
565
- const shared = sharedBoneCount(ca, cb);
566
- if (shared >= 2 || ca.bones.length === 0 || cb.bones.length === 0) return null;
567
- // Two static blockers meeting at the spine (torso × chest, chest × head) are
568
- // permanent by construction and unfixable by anything: no limb chain drives
569
- // them. Reporting them would only burn passes — the same reason
570
- // fixCollisions filters static × static out of its own pass list.
571
- if (!ca.def.movable && !cb.def.movable) return null;
572
- trimTowardJoint(ca, cb, geoA0, geoA1);
573
- trimTowardJoint(cb, ca, geoB0, geoB1);
574
- geometry.hinge = shared === 1;
575
- return geometry;
576
- }
577
-
578
- /**
579
- * EXTERNAL BLOCKERS. Everything above is the character against itself; a hand
580
- * swinging through a chair is the same problem against the SET. Callers pass
581
- * `blockers`: world-space static shapes, already posed for the frame being
582
- * solved (the caller owns their animation — this owns the push).
583
- *
584
- * capsule: { id, kind: "capsule", a: Vector3, b: Vector3, radius }
585
- * box: { id, kind: "box", center: Vector3, halfExtents: Vector3, yaw }
586
- *
587
- * `yaw` is radians about world +Y, the only rotation a prop standing on a floor
588
- * normally needs and the only one the closest-point test below has to invert.
589
- *
590
- * They behave exactly like torso and head: never movable, never adjacent to
591
- * anything, and never rest-calibrated — a character's rest pose says nothing
592
- * about where the furniture is. The full slack rule applies, with one wrinkle: a
593
- * box has no radius to take the min against, so the movable capsule's own slack
594
- * is used (a box is a surface, not a soft body).
595
- *
596
- * A blocker's records carry its id VERBATIM, so the existing pair readouts
597
- * ("leftHand×obj:chair") identify which prop was hit without any caller change.
598
- * The id is the caller's to namespace — collision-blockers.js already ships
599
- * "obj:cube" and "char:char-2:torso" — and a prefix added here would only double
600
- * it up ("obj:obj:cube", as QA read out).
601
- */
602
- function blockerEntry(shape) {
603
- if (!shape || typeof shape.id !== "string") return null;
604
- const point = (value) => (value && Number.isFinite(value.x) && Number.isFinite(value.y) && Number.isFinite(value.z)
605
- ? new THREE.Vector3(value.x, value.y, value.z)
606
- : null);
607
- const def = { id: shape.id, movable: null, blocker: true };
608
- if (shape.kind === "capsule") {
609
- const a = point(shape.a);
610
- const b = point(shape.b);
611
- if (!a || !b || !Number.isFinite(shape.radius) || shape.radius < 0) return null;
612
- return { def, kind: "capsule", bones: [], a, b, radius: shape.radius };
613
- }
614
- if (shape.kind === "box") {
615
- const center = point(shape.center);
616
- const halfExtents = point(shape.halfExtents);
617
- if (!center || !halfExtents) return null;
618
- halfExtents.set(Math.abs(halfExtents.x), Math.abs(halfExtents.y), Math.abs(halfExtents.z));
619
- return {
620
- def,
621
- kind: "box",
622
- bones: [],
623
- center,
624
- halfExtents,
625
- yaw: Number.isFinite(shape.yaw) ? shape.yaw : 0,
626
- // The slack rule reads `radius`; a box surface has none.
627
- radius: 0,
628
- };
629
- }
630
- return null;
631
- }
632
-
633
- /** The capsule whose START is a chain's root joint (shoulder / hip) — the
634
- * anchor a box escape aims back toward. */
635
- const CHAIN_ROOT_CAPSULE = {
636
- leftHand: "leftUpperArm",
637
- rightHand: "rightUpperArm",
638
- leftFoot: "leftThigh",
639
- rightFoot: "rightThigh",
640
- };
641
-
642
- function normalizeBlockers(blockers) {
643
- if (!Array.isArray(blockers) || blockers.length === 0) return [];
644
- const out = [];
645
- for (const shape of blockers) {
646
- const entry = blockerEntry(shape);
647
- if (entry) out.push(entry);
648
- }
649
- return out;
650
- }
651
-
652
- /**
653
- * Signed distance from `point` to an oriented box: positive outside, NEGATIVE
654
- * inside (the depth to the nearest face). Writes the closest SURFACE point into
655
- * `outSurface` and the outward surface normal there into `outNormal`.
656
- *
657
- * The box is axis-aligned once the yaw is undone, so the whole thing is a clamp
658
- * in box space plus a rotation back.
659
- */
660
- const boxLocal = new THREE.Vector3();
661
- const boxClamped = new THREE.Vector3();
662
- const boxPrefer = new THREE.Vector3();
663
- function boxSignedDistance(box, point, outSurface, outNormal, prefer = null) {
664
- const cos = Math.cos(box.yaw);
665
- const sin = Math.sin(box.yaw);
666
- const dx = point.x - box.center.x;
667
- const dy = point.y - box.center.y;
668
- const dz = point.z - box.center.z;
669
- // world → box: rotate by −yaw about Y.
670
- boxLocal.set(cos * dx - sin * dz, dy, sin * dx + cos * dz);
671
- const h = box.halfExtents;
672
- const outward = (local) => {
673
- // box → world.
674
- outSurface.set(cos * local.x + sin * local.z, local.y, -sin * local.x + cos * local.z).add(box.center);
675
- };
676
- const outsideX = Math.abs(boxLocal.x) - h.x;
677
- const outsideY = Math.abs(boxLocal.y) - h.y;
678
- const outsideZ = Math.abs(boxLocal.z) - h.z;
679
- if (outsideX > 0 || outsideY > 0 || outsideZ > 0) {
680
- boxClamped.set(
681
- Math.max(-h.x, Math.min(h.x, boxLocal.x)),
682
- Math.max(-h.y, Math.min(h.y, boxLocal.y)),
683
- Math.max(-h.z, Math.min(h.z, boxLocal.z)),
684
- );
685
- const distance = boxLocal.distanceTo(boxClamped);
686
- outward(boxClamped);
687
- if (distance > 1e-9) {
688
- const nx = (boxLocal.x - boxClamped.x) / distance;
689
- const ny = (boxLocal.y - boxClamped.y) / distance;
690
- const nz = (boxLocal.z - boxClamped.z) / distance;
691
- outNormal.set(cos * nx + sin * nz, ny, -sin * nx + cos * nz).normalize();
692
- } else {
693
- // Exactly ON the surface: fall through to the face normal.
694
- const gaps = [h.x - Math.abs(boxLocal.x), h.y - Math.abs(boxLocal.y), h.z - Math.abs(boxLocal.z)];
695
- const axis = gaps.indexOf(Math.min(...gaps));
696
- const sign = [boxLocal.x, boxLocal.y, boxLocal.z][axis] < 0 ? -1 : 1;
697
- const local = [0, 0, 0];
698
- local[axis] = sign;
699
- outNormal.set(cos * local[0] + sin * local[2], local[1], -sin * local[0] + cos * local[2]).normalize();
700
- }
701
- return distance;
702
- }
703
- // Inside: THE EXIT FACE, which is not always the nearest one.
704
- //
705
- // A limb buried in a crate is nearest to whichever face it happened to sink
706
- // past, and for a deep hit that is regularly the FAR one — a push out through
707
- // it drags the whole arm through the box (QA measured 16 bones inside after a
708
- // "fix"). So of the six faces the exit prefers one the limb's own ROOT is on:
709
- // the shallowest whose outward normal points back toward the shoulder or hip.
710
- // With no root hint — or with the root itself inside the box — the nearest
711
- // face is still the answer, which is the plain signed distance.
712
- //
713
- // `prefer` therefore changes the DIRECTION and the depth of the escape, never
714
- // the geometry: the plain nearest-face value is what the segment search below
715
- // minimises (a max of linear functions, so convex, which is what makes the
716
- // ternary search exact), and the preference is applied once, to the point that
717
- // search returns.
718
- const components = [boxLocal.x, boxLocal.y, boxLocal.z];
719
- const extents = [h.x, h.y, h.z];
720
- let bestAxis = 0;
721
- let bestSign = 1;
722
- let bestExit = Infinity;
723
- let bestAligned = false;
724
- for (let axis = 0; axis < 3; axis += 1) {
725
- for (const sign of [-1, 1]) {
726
- const exit = extents[axis] - sign * components[axis];
727
- let aligned = false;
728
- if (prefer) {
729
- boxPrefer.set(
730
- cos * (axis === 0 ? sign : 0) + sin * (axis === 2 ? sign : 0),
731
- axis === 1 ? sign : 0,
732
- -sin * (axis === 0 ? sign : 0) + cos * (axis === 2 ? sign : 0),
733
- );
734
- aligned = boxPrefer.dot(prefer) > 0;
735
- }
736
- // An aligned face beats an unaligned one outright; within a class the
737
- // shallower exit wins.
738
- if ((aligned && !bestAligned) || (aligned === bestAligned && exit < bestExit)) {
739
- bestAxis = axis;
740
- bestSign = sign;
741
- bestExit = exit;
742
- bestAligned = aligned;
743
- }
744
- }
745
- }
746
- boxClamped.copy(boxLocal);
747
- if (bestAxis === 0) boxClamped.x = bestSign * h.x;
748
- else if (bestAxis === 1) boxClamped.y = bestSign * h.y;
749
- else boxClamped.z = bestSign * h.z;
750
- outward(boxClamped);
751
- outNormal.set(
752
- cos * (bestAxis === 0 ? bestSign : 0) + sin * (bestAxis === 2 ? bestSign : 0),
753
- bestAxis === 1 ? bestSign : 0,
754
- -sin * (bestAxis === 0 ? bestSign : 0) + cos * (bestAxis === 2 ? bestSign : 0),
755
- ).normalize();
756
- return -bestExit;
757
- }
758
-
759
- /** How many ternary-search steps the segment/box solve takes. Each step keeps
760
- * 2/3 of the interval, so 48 of them pin the parameter to ~1e-8 of the segment
761
- * — far under the millimetre the depths are reported in. */
762
- const BOX_SEARCH_STEPS = 48;
763
-
764
- /**
765
- * Closest approach between a segment and an oriented box. The distance from a
766
- * point to a convex set is convex along a line, and the SIGNED distance of a
767
- * convex set is convex too (which is what makes the inside case work), so a
768
- * ternary search on the parameter finds the minimum without any case analysis of
769
- * edges, faces and corners.
770
- *
771
- * Writes the segment point into `outA` and the box's surface point into `outB`,
772
- * with the outward surface normal in `outNormal`, and returns the SIGNED
773
- * distance (negative when the segment runs through the box).
774
- */
775
- const boxProbe = new THREE.Vector3();
776
- const boxSurface = new THREE.Vector3();
777
- const boxNormal = new THREE.Vector3();
778
- function closestSegmentBox(p, q, box, outA, outB, outNormal, prefer = null) {
779
- const at = (t) => {
780
- boxProbe.copy(p).lerp(q, t);
781
- return boxSignedDistance(box, boxProbe, boxSurface, boxNormal);
782
- };
783
- let lo = 0;
784
- let hi = 1;
785
- for (let step = 0; step < BOX_SEARCH_STEPS; step += 1) {
786
- const third = (hi - lo) / 3;
787
- const m1 = lo + third;
788
- const m2 = hi - third;
789
- if (at(m1) < at(m2)) hi = m2;
790
- else lo = m1;
791
- }
792
- const t = (lo + hi) / 2;
793
- let distance = at(t);
794
- if (distance < 0 && prefer) {
795
- // Deepest point found; now pick the face it leaves by.
796
- boxProbe.copy(p).lerp(q, t);
797
- distance = boxSignedDistance(box, boxProbe, boxSurface, boxNormal, prefer);
798
- }
799
- outA.copy(p).lerp(q, t);
800
- outB.copy(boxSurface);
801
- outNormal.copy(boxNormal);
802
- return distance;
803
- }
804
-
805
- /**
806
- * One capsule against one blocker. Returns { gap, normal, pointA, pointB } with
807
- * `gap` the signed distance from the capsule's AXIS to the blocker's SURFACE and
808
- * `normal` pointing from the blocker toward the capsule — the same b → a
809
- * convention detectPenetrations uses everywhere else.
810
- */
811
- const blockNormal = new THREE.Vector3();
812
- const blockPrefer = new THREE.Vector3();
813
- function blockerContact(capsule, blocker, outA, outB, rootAnchor = null) {
814
- if (blocker.kind === "box") {
815
- // The way OUT of a box is the way the limb came in, and the limb's root is
816
- // the only anchor that knows which way that was.
817
- let prefer = null;
818
- if (rootAnchor) {
819
- blockPrefer.copy(rootAnchor).sub(capsule.a.clone().lerp(capsule.b, 0.5));
820
- if (blockPrefer.lengthSq() > 1e-12) prefer = blockPrefer.normalize();
821
- }
822
- const gap = closestSegmentBox(capsule.a, capsule.b, blocker, outA, outB, blockNormal, prefer);
823
- return { gap, normal: blockNormal.clone() };
824
- }
825
- closestPointsSegmentSegment(capsule.a, capsule.b, blocker.a, blocker.b, outA, outB);
826
- const axisGap = outA.distanceTo(outB);
827
- const normal = outA.clone().sub(outB);
828
- if (normal.lengthSq() < 1e-12) {
829
- normal.set(0, 0, 1).cross(capsule.b.clone().sub(capsule.a));
830
- if (normal.lengthSq() < 1e-12) normal.set(0, 1, 0);
831
- }
832
- normal.normalize();
833
- // Report the contact ON the blocker's surface, not on its axis.
834
- outB.addScaledVector(normal, blocker.radius);
835
- return { gap: axisGap - blocker.radius, normal };
836
- }
837
-
838
- /**
839
- * How many capsule endpoints sit INSIDE a box blocker. The one measure of "made
840
- * it worse" a box escape cannot argue with: a limb pushed out through the far
841
- * face is off the surface and reports no contact while more of it than ever is
842
- * in the crate. Counted over endpoints because they are the extremities the
843
- * viewer sees poking through.
844
- */
845
- export function blockerInsideCount(capsules, blockers) {
846
- const shapes = normalizeBlockers(blockers).filter((shape) => shape.kind === "box");
847
- if (!capsules || shapes.length === 0) return 0;
848
- let inside = 0;
849
- const surface = new THREE.Vector3();
850
- const normal = new THREE.Vector3();
851
- for (const capsule of capsules.values()) {
852
- if (!capsule.def.movable) continue;
853
- for (const point of [capsule.a, capsule.b]) {
854
- for (const box of shapes) {
855
- if (boxSignedDistance(box, point, surface, normal) < 0) inside += 1;
856
- }
857
- }
858
- }
859
- return inside;
860
- }
861
-
862
- /**
863
- * Find every penetrating capsule pair in the current pose. Returns records
864
- * { a, b, depth, normal, pointA, pointB } sorted deepest-first. `normal`
865
- * points from b toward a. `offset` inflates the contact distance HERE, in
866
- * the test itself — a pair counts as penetrating once it is closer than
867
- * rA + rB + offset — so the same gap the fixer pushes out to is the gap it
868
- * checks for. Honouring offset only in the push distance would make the
869
- * fixer chase a target detection never asked for, and the very gap it just
870
- * opened would read as clean at exactly the surface-grazing pose it exists
871
- * to avoid.
872
- *
873
- * SLACK: capsules are rigid proxies for soft tissue — an upper arm resting
874
- * against the torso, or thighs touching, legitimately overlaps the proxies.
875
- * A pair only counts once it penetrates past `slackFactor × min(radius)`,
876
- * the soft-tissue compression allowance; `depth` is the amount PAST that
877
- * allowance, which is also the distance the fixer pushes.
878
- *
879
- * The default is 0.25, and it used to be 0.4. The looser value was doing two
880
- * jobs: covering genuine soft-tissue compression AND covering the overlaps a
881
- * character legitimately holds at rest, which the proxies cannot tell from a
882
- * real hit. The rest/bind calibration below now does the second job precisely,
883
- * per pair, so the global allowance no longer has to be blunt enough for the
884
- * worst pair on the rig — and at 0.4 it hid a centimetre and a half of forearm
885
- * inside a thigh. (Loosening it further was never an option either: at 0.6 a
886
- * hand sunk most of the way into a thigh still fit inside the allowance and the
887
- * tool answered "no collisions".)
888
- *
889
- * HINGED PAIRS use their own, wider allowance instead — see HINGE_SLACK_FACTOR —
890
- * because two segments meeting at a joint are in flesh-to-flesh contact at any
891
- * deep fold. BLOCKERS use the plain rule, with a box's missing radius standing
892
- * in as the movable capsule's own.
893
- *
894
- * BIND CALIBRATION: on top of the global slack, a pair that already overlaps in
895
- * the rig's bind pose gets that overlap plus PAIR_TOLERANCE as its own
896
- * allowance (see restPairOverlaps), so a rest or bind pose can never report a
897
- * collision. `pairAllowances` overrides the map buildCollisionCapsules attached;
898
- * pass null to measure a pose against nothing but the slack rule.
899
- *
900
- * `blockers` adds the world-space static shapes described under EXTERNAL
901
- * BLOCKERS; they are tested against every MOVABLE capsule and named `obj:<id>`
902
- * in the records.
903
- */
904
- export function detectPenetrations(capsules, {
905
- offset = 0.002,
906
- slackFactor = DEFAULT_SLACK_FACTOR,
907
- pairAllowances,
908
- blockers = null,
909
- } = {}) {
910
- if (!capsules) return [];
911
- const calibration = pairAllowances === undefined ? (capsules.pairAllowances ?? null) : pairAllowances;
912
- const list = [...capsules.values()];
913
- const out = [];
914
- const pa = new THREE.Vector3();
915
- const pb = new THREE.Vector3();
916
- for (let i = 0; i < list.length; i += 1) {
917
- for (let j = i + 1; j < list.length; j += 1) {
918
- const ca = list[i];
919
- const cb = list[j];
920
- const geo = pairGeometry(ca, cb);
921
- if (!geo) continue;
922
- const hinge = geo.hinge;
923
- closestPointsSegmentSegment(geo.a0, geo.a1, geo.b0, geo.b1, pa, pb);
924
- const overlap = ca.radius + cb.radius + offset - pa.distanceTo(pb);
925
- const slack = hinge
926
- ? HINGE_SLACK_FACTOR * (ca.radius + cb.radius)
927
- : slackFactor * Math.min(ca.radius, cb.radius);
928
- const bindOverlap = calibration?.get(pairKey(ca.def.id, cb.def.id)) ?? 0;
929
- const allowance = Math.max(slack, bindOverlap > 0 ? bindOverlap + PAIR_TOLERANCE : 0);
930
- const depth = overlap - allowance;
931
- // MIN_DEPTH, not epsilon: see the constant. A pair under it is not a
932
- // contact anyone can see and not one worth moving a limb for.
933
- if (depth < MIN_DEPTH) continue;
934
- const normal = pa.clone().sub(pb);
935
- if (normal.lengthSq() < 1e-12) {
936
- // Coincident axes: any perpendicular direction separates them.
937
- normal.set(0, 0, 1).cross(ca.b.clone().sub(ca.a));
938
- if (normal.lengthSq() < 1e-12) normal.set(0, 1, 0);
939
- }
940
- normal.normalize();
941
- out.push({ a: ca, b: cb, depth, normal, pointA: pa.clone(), pointB: pb.clone() });
942
- }
943
- }
944
- // External blockers: only MOVABLE capsules are worth testing — a blocker
945
- // against the torso is two immovable things touching, which no pass can fix.
946
- const shapes = normalizeBlockers(blockers);
947
- for (const blocker of shapes) {
948
- for (const capsule of list) {
949
- if (!capsule.def.movable) continue;
950
- const rootId = CHAIN_ROOT_CAPSULE[capsule.def.movable.chain];
951
- const rootAnchor = rootId ? capsules.get(rootId)?.a ?? null : null;
952
- const contact = blockerContact(capsule, blocker, pa, pb, rootAnchor);
953
- const overlap = capsule.radius + offset - contact.gap;
954
- // A box has no radius to take the min against, so the limb's own
955
- // slack is the whole allowance. No rest calibration: the character's
956
- // reference poses say nothing about where the set dressing is.
957
- const slack = slackFactor * (blocker.kind === "box"
958
- ? capsule.radius
959
- : Math.min(capsule.radius, blocker.radius));
960
- const depth = overlap - slack;
961
- if (depth < MIN_DEPTH) continue;
962
- out.push({
963
- a: capsule,
964
- b: blocker,
965
- depth,
966
- normal: contact.normal.clone(),
967
- pointA: pa.clone(),
968
- pointB: pb.clone(),
969
- });
970
- }
971
- }
972
- out.sort((x, y) => y.depth - x.depth);
973
- return out;
974
- }
975
-
976
- /**
977
- * The penetrations a fixer is allowed to ACT on, out of everything detection
978
- * reports: no static × static (two immovable things touching, which no pass can
979
- * separate), no same-chain hinge fold (see isHingeFold), and, with a filter,
980
- * only pairs naming a permitted chain. Shared so a caller — the range walker's
981
- * continuity pass — can read a frame with exactly the eyes fixCollisions uses,
982
- * rather than a second, subtly different filter.
983
- */
984
- function fixablePenetrations(capsules, { offset, slackFactor, blockers, onlyChains } = {}) {
985
- if (!capsules) return [];
986
- return detectPenetrations(capsules, { offset, slackFactor, blockers }).filter((pen) => {
987
- if (!pen.a.def.movable && !pen.b.def.movable) return false;
988
- if (isHingeFold(pen)) return false;
989
- if (!onlyChains) return true;
990
- const chainsOf = [pen.a, pen.b].map((c) => c.def.movable?.chain).filter(Boolean);
991
- return chainsOf.some((id) => onlyChains.has(id));
992
- });
993
- }
994
-
995
- /** Every chain named by a penetration list — the cast a frame's own pose puts
996
- * in play, which is what the provenance gate is allowed to move. */
997
- function chainsInPlay(pens) {
998
- const ids = new Set();
999
- for (const pen of pens) {
1000
- for (const side of [pen.a, pen.b]) {
1001
- if (side.def?.movable?.chain) ids.add(side.def.movable.chain);
1002
- }
1003
- }
1004
- return ids;
1005
- }
1006
-
1007
- /**
1008
- * REST-BIASED ESCAPE. The separation normal is the SHORTEST way out, which for
1009
- * a deep hit is regularly the wrong side of the blocker: a forearm driven into
1010
- * the chest is closest to the far side of the torso axis, so the shortest
1011
- * escape carries the hand ACROSS the chest and parks it at the opposite
1012
- * shoulder; a hand 10.8 cm inside the head exits forward, and "hand to face"
1013
- * becomes "hand held out in front of the face".
1014
- *
1015
- * A limb has somewhere it belongs, and the rig already knows where: the REST
1016
- * pose. So the push direction is the contact normal blended with a HOME
1017
- * direction — contact point → the driven joint's rest-pose position — which
1018
- * bends the escape back toward the limb's own side of the body without
1019
- * abandoning the normal.
1020
- *
1021
- * The blend alone would under-separate: a direction at 60° to the normal that
1022
- * travels (depth + offset) only clears 0.5 × that much, and the next pass would
1023
- * find the pair still penetrating. So the push LENGTH is divided by the
1024
- * alignment, `dot(biased, normal)`, which makes the component ALONG THE NORMAL
1025
- * exactly the separation the unbiased push would have achieved — the bias
1026
- * chooses the side, never the amount of clearance.
1027
- *
1028
- * MIN_ALIGN is the floor on that division: a home direction that opposes the
1029
- * normal would demand an unbounded push, or (past 90°) push the limb DEEPER, so
1030
- * below the floor the pass falls back to the plain normal. The rule is
1031
- * therefore never worse than the old one — at worst it is exactly the old one.
1032
- *
1033
- * No bind snapshot means no rest pose to bias toward (restWorldPosition would
1034
- * compose the CURRENT pose, i.e. the penetrating one), so those rigs keep the
1035
- * unbiased normal.
1036
- *
1037
- * The home direction is a HINT, not a target, and it is deliberately the rest
1038
- * pose's ABSOLUTE world position: a clip that has walked or lowered the hips a
1039
- * long way from the rest pose points it less precisely (a crouch aims it a
1040
- * little high). MIN_ALIGN is what makes that safe — a hint pointing the wrong
1041
- * way is dropped rather than followed, so the worst case is the old rule.
1042
- */
1043
- export const HOME_BIAS = 0.6;
1044
-
1045
- /** Floor on dot(biasedDir, normal): below it the bias is dropped for that push.
1046
- * 0.35 ≈ 70°, and caps the push at 1/0.35 ≈ 2.9 × the unbiased distance. */
1047
- export const MIN_ALIGN = 0.35;
1048
-
1049
- /**
1050
- * ESCAPE CONSISTENCY. A deep hit usually has more than one honest way out — a
1051
- * hand buried in the head can leave up, forward or across — and which one a
1052
- * frame picks turns on millimetres of contact geometry. Solved frame by frame
1053
- * that is a coin toss per frame, and QA measured the result on the crouch clip:
1054
- * frame 115 escaped with the arm straight up, 116 with the hands in front of the
1055
- * face, 117 with the arm down across the body, on source poses that barely move
1056
- * (22 mm of hand travel a frame became 520 mm).
1057
- *
1058
- * So a chain corrected on the PREVIOUS frame carries its push direction into
1059
- * this one, as a third term in the same blend the rest bias uses: the normal
1060
- * still owns the amount of clearance (see the alignment division above), the
1061
- * biases only choose the side. Weighted like HOME_BIAS, because it is the same
1062
- * kind of hint and answers the same question — where does this limb belong.
1063
- */
1064
- const CONTINUITY_BIAS = 0.6;
1065
-
1066
- /** The weight the STEP CAP's retry uses when the plain one produced a jump: the
1067
- * previous frame's direction outvotes the rest pose rather than tying with it. */
1068
- const CONTINUITY_BIAS_STRONG = 1.6;
1069
-
1070
- /**
1071
- * THE STEP CAP, as a multiple of the clip's own worst adjacent step for the
1072
- * effector being judged. The clip is the only honest ruler: 8 cm in a frame is
1073
- * a flail on a crouch and unremarkable in a sprint, and a fixed millimetre
1074
- * budget would have to be re-tuned per take. 2.5× leaves room for a correction
1075
- * to lead or lag the clip's own motion by a good margin while a 20× jump — the
1076
- * flail QA measured — cannot fit inside it.
1077
- */
1078
- const STEP_CAP_FACTOR = 2.5;
1079
-
1080
- /** Ceiling on that cap, in metres. A clip whose hand already crosses the frame
1081
- * in one step must not thereby license a half-metre correction jump; ~24 frames
1082
- * a second, so 0.25 m is 6 m/s of hand — past any human hand speed a viewer
1083
- * would read as continuous. */
1084
- const STEP_CAP_ABS = 0.25;
1085
-
1086
- /** Floor on that cap, in metres. A perfectly static clip has a zero worst step,
1087
- * and a cap of zero would refuse every frame that follows a corrected one. 5 mm
1088
- * is the millimetre noise of the proxies themselves. */
1089
- const STEP_CAP_FLOOR = 0.005;
1090
-
1091
- /** The bone a capsule's push actually drives: mid joint (elbow/knee) for an
1092
- * upper segment, effector (wrist/ankle) for a lower segment or an end sphere. */
1093
- function drivenBone(capsule, chains) {
1094
- const target = capsule.def.movable;
1095
- if (!target) return null;
1096
- const chain = chains?.get(target.chain);
1097
- if (!chain) return null;
1098
- return target.joint === "mid" ? chain.bones[1] : chain.bones[2];
1099
- }
1100
-
1101
- /** Unit vector from the contact point toward where the driven joint sits in the
1102
- * rig's REST pose (bind pose if the rest composition is unavailable), or null
1103
- * when the rig carries no bind snapshot to compose either from. */
1104
- function homeDirection(rig, capsule, chains, contactPoint) {
1105
- if (!hasBindPose(rig)) return null;
1106
- const bone = drivenBone(capsule, chains);
1107
- if (!bone) return null;
1108
- const home = restWorldPosition(rig, bone, new THREE.Vector3())
1109
- ?? bindWorldPosition(rig, bone, new THREE.Vector3());
1110
- if (!home) return null;
1111
- const dir = home.sub(contactPoint);
1112
- if (dir.lengthSq() < 1e-12) return null;
1113
- return dir.normalize();
1114
- }
1115
-
1116
- /**
1117
- * The world push for one side of one penetration: `normal` already points the
1118
- * way THIS capsule must go, `distance` is the separation it owes along that
1119
- * normal, and `weight` scales the home bias (0 disables it).
1120
- *
1121
- * `continuity` is the ESCAPE CONSISTENCY hint — { dirs: Map(chainId → unit
1122
- * Vector3), weight } — and rides in the same blend: a chain the previous frame
1123
- * pushed somewhere leans that way again. Both hints are optional and either can
1124
- * be absent; with neither, this is the plain separation normal it always was.
1125
- */
1126
- function escapeVector(rig, capsule, chains, normal, contactPoint, distance, weight, continuity = null) {
1127
- const straight = normal.clone().multiplyScalar(distance);
1128
- const carry = continuity?.weight > 0
1129
- ? continuity.dirs?.get(capsule.def.movable?.chain) ?? null
1130
- : null;
1131
- if (!(weight > 0) && !carry) return straight;
1132
- const home = weight > 0 ? homeDirection(rig, capsule, chains, contactPoint) : null;
1133
- if (!home && !carry) return straight;
1134
- const biased = normal.clone();
1135
- if (home) biased.addScaledVector(home, weight);
1136
- if (carry) biased.addScaledVector(carry, continuity.weight);
1137
- if (biased.lengthSq() < 1e-12) return straight;
1138
- biased.normalize();
1139
- const align = biased.dot(normal);
1140
- // Opposing (or near-perpendicular) home direction: keep the normal rather
1141
- // than trade a sideways slide for a shallower — or negative — separation.
1142
- if (align < MIN_ALIGN) return straight;
1143
- return biased.multiplyScalar(distance / Math.max(MIN_ALIGN, align));
1144
- }
1145
-
1146
- /** Chains whose end joint must not change sides of the body because of a fix. */
1147
- const SIDE_CHAINS = ["leftHand", "rightHand"];
1148
-
1149
- /** How far off the midline a wrist must be, both before and after, before a
1150
- * sign change counts as a crossing. Hands legitimately work at the midline
1151
- * (clasped, folded); 2 cm keeps that noise out of the guard. */
1152
- const SIDE_EPSILON = 0.02;
1153
-
1154
- /**
1155
- * Signed distance from the body's sagittal plane — positive on the character's
1156
- * LEFT. The plane is the hips' own YZ plane: origin at the hips, normal = the
1157
- * hips' local X axis in world space, which is the hips→spine frame's lateral
1158
- * axis (ik.js's toe-derived facing helper is not exported, and the hips basis
1159
- * agrees with it on any upright rig). Returns null on a rig with no hips.
1160
- */
1161
- function sagittalSide(rig, point) {
1162
- const hips = findBone(rig, "mixamorigHips");
1163
- if (!hips) return null;
1164
- const lateral = new THREE.Vector3();
1165
- hips.matrixWorld.extractBasis(lateral, new THREE.Vector3(), new THREE.Vector3());
1166
- if (lateral.lengthSq() < 1e-12) return null;
1167
- const origin = new THREE.Vector3().setFromMatrixPosition(hips.matrixWorld);
1168
- return point.clone().sub(origin).dot(lateral.normalize());
1169
- }
1170
-
1171
- const NO_PUSH = Object.freeze({ ran: false, moved: false });
1172
-
1173
- /**
1174
- * Push one movable capsule by `push` (world vector). Mid joint drives the
1175
- * elbow/knee (upper segment), effector drives the wrist/ankle (lower segment
1176
- * and end spheres). Returns { ran, moved }: `ran` means a solver was invoked on
1177
- * this chain (so the chain's pose may have changed and must be keyed), `moved`
1178
- * means the driven joint actually went somewhere.
1179
- *
1180
- * `onlyChains` is enforced HERE, not only at the detection filter. A pair is
1181
- * kept when EITHER side names a permitted chain, so a movable×movable pair with
1182
- * one side listed used to push the unlisted side too (3 cm of unrequested
1183
- * motion in "filter only selected" mode) — a filter honoured by the query but
1184
- * not by the write.
1185
- *
1186
- * DIRECTION is the caller's business: `push` arrives already blended toward the
1187
- * limb's rest pose by escapeVector — see the REST-BIASED ESCAPE note there for
1188
- * why the raw separation normal is not enough on a deep hit.
1189
- */
1190
- function pushCapsule(capsule, chains, push, ikState, { onlyChains = null, floorY = 0 } = {}) {
1191
- const target = capsule.def.movable;
1192
- if (!target) return NO_PUSH;
1193
- if (onlyChains && !onlyChains.has(target.chain)) return NO_PUSH;
1194
- const chain = chains?.get(target.chain);
1195
- if (!chain) return NO_PUSH;
1196
- // Detection, target and solve all use the clip's current translations.
1197
- // A bind reset here would reintroduce length compensation into the key.
1198
- const rotations = chain.bones.map((bone) => bone.quaternion.clone());
1199
- const driven = target.joint === "mid" ? chain.bones[1] : chain.bones[2];
1200
- const before = driven.getWorldPosition(new THREE.Vector3());
1201
- const goal = before.clone().add(push);
1202
- goal.y = Math.max(goal.y, floorY + PUSH_FLOOR_CLEARANCE);
1203
- if (target.joint === "mid") solveMidJoint(chain, goal);
1204
- else solveIk(chain, goal);
1205
- // `ran` is "this chain's pose is no longer the one it arrived in", measured
1206
- // rather than assumed: a solver that put every bone back where it found it
1207
- // has nothing to key, and a run of nothing but those must report itself as
1208
- // the no-op it was.
1209
- const ran = chain.bones.some((bone, index) => bone.quaternion.angleTo(rotations[index]) > 1e-9);
1210
- if (ran && ikState) ikTouch(ikState, target.chain);
1211
- // A push the solver clamped away (unreachable target, floor guard, a limb
1212
- // already at full stretch) is NOT progress. Counting it as progress kept the
1213
- // pass loop alive to maxIterations re-solving a pose that cannot move.
1214
- return { ran, moved: driven.getWorldPosition(new THREE.Vector3()).distanceTo(before) > 1e-6 };
1215
- }
1216
-
1217
- /**
1218
- * Clean self-collisions out of the CURRENT pose. Iterates detect → push
1219
- * until the deepest remaining penetration fits in `epsilon` or
1220
- * maxIterations passes ran. A movable-vs-static pair pushes the limb by the
1221
- * full depth + offset; two movable capsules split it, weighted toward the
1222
- * higher-priority limb (arms yield before legs — leg moves read heavier).
1223
- *
1224
- * `onlyChains` (Set of chain ids) restricts which limbs may move — the
1225
- * "filter only selected" mode. `ikState`, when given, marks every touched
1226
- * chain as tracked so a following ikBakeKeyframe persists the fix.
1227
- * `slackFactor` rides through to detectPenetrations for callers that want a
1228
- * stricter or looser reading than the default. `blockers` rides through too:
1229
- * world-space props the limbs are pushed out of exactly as they are pushed out
1230
- * of the torso. `fkJoints` enables the capped TORSO/HEAD YIELD — without it the
1231
- * blockers stay perfectly rigid, which is what every existing caller gets.
1232
- *
1233
- * `continuity` ({ dirs: Map(chainId → unit Vector3), weight }) is the ESCAPE
1234
- * CONSISTENCY hint — the way each chain left its collision on the frame before
1235
- * — and `provenanceChains` overrides the cast the provenance gate reads off the
1236
- * entry pose. Both exist for the range walker's temporal pass and are null for
1237
- * every single-frame caller, which therefore behaves exactly as it did.
1238
- *
1239
- * Returns { supported, changed, passes, residual, touched } — residual is the
1240
- * deepest remaining penetration in metres, 0 when fully clean, and `touched` is
1241
- * the chain ids THIS run actually drove, plus any FK joint id a yield leaned on
1242
- * (both are keyable ids: ikBakeKeyframe reads a chain map first and the FK joint
1243
- * map second, so a bake naming "spine" keys the spine). `supported` is false when the rig has
1244
- * no capsule proxies to build at all (a non-Mixamo skeleton): the tool did not
1245
- * run, which is a DIFFERENT answer from "ran and found nothing", and the UI
1246
- * must say so rather than claim a clean pose.
1247
- *
1248
- * `touched` exists so the bake can name what moved. ikBakeKeyframe defaults to
1249
- * the whole tracked set, which after any earlier drag or pass is far more than
1250
- * this fix touched — keying a leg fix at frame 40 also re-keyed the hand
1251
- * dragged at frame 2, at whatever blended value the layer happened to be
1252
- * showing there.
1253
- *
1254
- * `baseQuats` is the other half of that: the rotations the key is a DELTA from.
1255
- * The returned map (chain id → the three bone quaternions BEFORE the first
1256
- * solver pass) is what a caller hands to ikBakeKeyframe so the blend window
1257
- * carries the correction rather than the whole pose difference. Pass the option
1258
- * in when you know the RAW CLIP rotations for this frame — the range walker
1259
- * does, because it samples the clip with the layer switched off — otherwise the
1260
- * pose on the rig at entry is used, which is exact whenever no earlier key is
1261
- * already blending into this frame.
1262
- *
1263
- * CLIP TRANSLATIONS THROUGHOUT. The solvers measure each frame's segment
1264
- * lengths without changing its locals. Detection, pushes and warm starts must
1265
- * therefore leave those translations alone too; the bake records them so the
1266
- * pose measured here is the one playback reproduces.
1267
- */
1268
- export function fixCollisions(rig, chains, {
1269
- radii = null,
1270
- offset = 0.002,
1271
- epsilon = 1e-4,
1272
- maxIterations = 8,
1273
- onlyChains = null,
1274
- ikState = null,
1275
- slackFactor = DEFAULT_SLACK_FACTOR,
1276
- floorY = 0,
1277
- baseQuats = null,
1278
- blockers = null,
1279
- fkJoints = null,
1280
- continuity = null,
1281
- provenanceChains = null,
1282
- } = {}) {
1283
- if (!rig || !chains) {
1284
- return { supported: false, changed: false, passes: 0, residual: 0, touched: [], baseQuats: new Map() };
1285
- }
1286
- const bases = baseQuats ?? new Map(
1287
- [...chains].map(([id, chain]) => [id, chain.bones.map((bone) => bone.quaternion.clone())]),
1288
- );
1289
- const touched = new Set();
1290
- let poseDirty = false;
1291
- /**
1292
- * PROVENANCE. The chains this frame is allowed to move, named by the FIRST
1293
- * detection on the pose as handed over. Filled in below, before the first
1294
- * pass.
1295
- *
1296
- * A solve is iterative, so the pose it detects on after pass 0 is a pose it
1297
- * made. Contacts that appear there are the fix's own doing, and acting on
1298
- * them recruits limbs the source frame never had a problem with: on the
1299
- * crouch clip a head × hand escape flung the arm through the legs, the legs
1300
- * were then "fixed" for hitting it, and the right leg came away keyed, snapped
1301
- * to bind length and smeared 164.9 mm over a dozen frames on which no leg
1302
- * pair had ever existed. A new contact between two chains that were already
1303
- * in play is still fair game — that is the projection loop doing its job —
1304
- * but the cast is closed at the door.
1305
- */
1306
- let allowedChains = null;
1307
- const drive = (capsule, vector) => {
1308
- const chainId = capsule.def.movable?.chain;
1309
- if (allowedChains && chainId && !allowedChains.has(chainId)) return false;
1310
- const outcome = pushCapsule(capsule, chains, vector, ikState, {
1311
- onlyChains, floorY,
1312
- });
1313
- if (outcome.ran) {
1314
- touched.add(capsule.def.movable.chain);
1315
- // A solve that changed the pose without moving the driven joint is not
1316
- // progress, but it IS a change, and a run that ends with one owes the
1317
- // caller a `changed: true` and a key.
1318
- poseDirty = true;
1319
- }
1320
- return outcome.moved;
1321
- };
1322
-
1323
- // Every push of a pass is computed from the pose the pass STARTED in, so the
1324
- // same penetration list can be replayed at a different weight (the side
1325
- // guard below) and produce a deterministic result either way.
1326
- const applyPass = (pens, weight) => {
1327
- let passChanged = false;
1328
- for (const pen of pens) {
1329
- const ma = pen.a.def.movable;
1330
- const mb = pen.b.def.movable;
1331
- const distance = pen.depth + offset;
1332
- // `normal` points b → a: a escapes along +normal, b along −normal.
1333
- const away = pen.normal;
1334
- const back = pen.normal.clone().negate();
1335
- const pushA = (share) => escapeVector(rig, pen.a, chains, away, pen.pointA, distance * share, weight, continuity);
1336
- const pushB = (share) => escapeVector(rig, pen.b, chains, back, pen.pointB, distance * share, weight, continuity);
1337
- if (ma && !mb) passChanged = drive(pen.a, pushA(1)) || passChanged;
1338
- else if (mb && !ma) passChanged = drive(pen.b, pushB(1)) || passChanged;
1339
- else if (ma && mb) {
1340
- // Splitting the push is only right when BOTH sides are allowed to
1341
- // move. With a filter naming one of them, the permitted side owes
1342
- // the whole separation — half a push leaves half a penetration.
1343
- const allowA = !onlyChains || onlyChains.has(ma.chain);
1344
- const allowB = !onlyChains || onlyChains.has(mb.chain);
1345
- if (allowA && allowB) {
1346
- const pa = pen.a.def.priority ?? 1;
1347
- const pb = pen.b.def.priority ?? 1;
1348
- const shareA = pa / (pa + pb); // arms (2) yield before legs (1)
1349
- const movedA = drive(pen.a, pushA(shareA));
1350
- const movedB = drive(pen.b, pushB(1 - shareA));
1351
- passChanged = movedA || movedB || passChanged;
1352
- } else if (allowA) passChanged = drive(pen.a, pushA(1)) || passChanged;
1353
- else if (allowB) passChanged = drive(pen.b, pushB(1)) || passChanged;
1354
- }
1355
- }
1356
- return passChanged;
1357
- };
1358
-
1359
- /**
1360
- * A hand must not change sides of the body because of a fix. The heuristic,
1361
- * deliberately blunt: measure each arm chain's WRIST against the sagittal
1362
- * plane, and if a pass leaves a wrist that was clearly on one side
1363
- * (> SIDE_EPSILON) clearly on the other, the escape went ACROSS the torso
1364
- * rather than back out — so that pass is rewound and replayed with the home
1365
- * bias at DOUBLE weight, which pulls harder toward the limb's own side. One
1366
- * retry per pass, and the retry is accepted whatever it produces, so the
1367
- * pass count and the output stay deterministic.
1368
- *
1369
- * TWO REFERENCE SIDES, and the second is the one that fires. A single pass
1370
- * moves the wrist by (depth + offset), so a hand does not jump the midline
1371
- * in one pass — measured over 6000 random deep poses it never once did.
1372
- * Crossings are CUMULATIVE: a few passes of a few centimetres each, and the
1373
- * hand has walked to the far shoulder. So a pass is judged against the side
1374
- * the wrist had when the pass started AND against the side it had when the
1375
- * fix started, and the pass that completes a cumulative crossing is the one
1376
- * that gets replayed.
1377
- */
1378
- const bias = hasBindPose(rig) ? HOME_BIAS : 0;
1379
- const passBones = [];
1380
- {
1381
- const seen = new Set();
1382
- for (const chain of chains.values()) {
1383
- for (const bone of chain.bones) if (!seen.has(bone)) { seen.add(bone); passBones.push(bone); }
1384
- }
1385
- }
1386
- const snapshotPass = () => passBones.map((bone) => bone.quaternion.clone());
1387
- const restorePass = (snapshot) => {
1388
- passBones.forEach((bone, index) => bone.quaternion.copy(snapshot[index]));
1389
- rig.updateMatrixWorld(true);
1390
- };
1391
- const wristSides = () => {
1392
- const sides = new Map();
1393
- for (const id of SIDE_CHAINS) {
1394
- const chain = chains.get(id);
1395
- if (!chain) continue;
1396
- const side = sagittalSide(rig, chain.bones[2].getWorldPosition(new THREE.Vector3()));
1397
- if (side !== null) sides.set(id, side);
1398
- }
1399
- return sides;
1400
- };
1401
- const crossedMidline = (before, after) => [...before].some(([id, start]) => {
1402
- const end = after.get(id);
1403
- return end !== undefined
1404
- && Math.abs(start) > SIDE_EPSILON && Math.abs(end) > SIDE_EPSILON
1405
- && start * end < 0;
1406
- });
1407
- const entrySides = bias > 0 ? wristSides() : null;
1408
-
1409
- /** The penetrations THIS run is allowed to act on: no static × static (two
1410
- * immovable things touching, which no pass can fix), no SAME-CHAIN HINGE (a
1411
- * fold is a pose, not a collision — see isHingeFold), and only pairs naming a
1412
- * permitted chain when a filter is in force. */
1413
- const fixablePens = (capsules) => fixablePenetrations(capsules, { offset, slackFactor, blockers, onlyChains });
1414
-
1415
- /** Every bone a fix or a yield can write, with its full local transform. */
1416
- const frameSnapshot = () => {
1417
- const entries = [];
1418
- const seen = new Set();
1419
- const add = (bone) => {
1420
- if (!bone || seen.has(bone)) return;
1421
- seen.add(bone);
1422
- entries.push([bone, bone.quaternion.clone(), bone.position.clone()]);
1423
- };
1424
- for (const chain of chains.values()) for (const bone of chain.bones) add(bone);
1425
- const joints = fkJoints ?? fallbackYieldJoints(rig);
1426
- if (joints) for (const joint of joints.values()) add(joint.bone);
1427
- return entries;
1428
- };
1429
- const frameRestore = (entries) => {
1430
- for (const [bone, quaternion, position] of entries) {
1431
- bone.quaternion.copy(quaternion);
1432
- bone.position.copy(position);
1433
- }
1434
- rig.updateMatrixWorld(true);
1435
- };
1436
- /** Lowest foot marker in the current pose, or +Infinity when the rig has
1437
- * none to judge. */
1438
- const lowestFoot = () => {
1439
- let lowest = Infinity;
1440
- for (const name of FLOOR_MARKERS) {
1441
- const bone = findBone(rig, name);
1442
- if (!bone) continue;
1443
- lowest = Math.min(lowest, bone.getWorldPosition(new THREE.Vector3()).y);
1444
- }
1445
- return lowest;
1446
- };
1447
- /** The deepest fixable penetration in the current pose, 0 when clean. */
1448
- const currentWorst = () => {
1449
- const capsules = buildCollisionCapsules(rig, radii);
1450
- if (!capsules) return 0;
1451
- const pens = fixablePens(capsules);
1452
- return pens.length ? pens[0].depth : 0;
1453
- };
1454
-
1455
- const entryPose = frameSnapshot();
1456
- const entryFoot = lowestFoot();
1457
- let entryWorst = 0;
1458
- {
1459
- const capsules = buildCollisionCapsules(rig, radii);
1460
- const pens = capsules ? fixablePens(capsules) : [];
1461
- entryWorst = pens.length ? pens[0].depth : 0;
1462
- // The cast, closed at the door. A chain that is not named by a pair on the
1463
- // pose as handed over cannot be recruited by a contact the fix invents.
1464
- //
1465
- // `provenanceChains` lets a caller name that cast itself, and exists for
1466
- // exactly one caller: the range walker's WARM START hands over a pose it
1467
- // has already nudged with the previous frame's correction, so the pose at
1468
- // the door is no longer the frame's own. The list it passes is read off
1469
- // the frame's UNTOUCHED pose, which keeps the gate saying what it always
1470
- // said — only limbs this frame's own clip pose put in play may move.
1471
- allowedChains = provenanceChains ? new Set(provenanceChains) : chainsInPlay(pens);
1472
- }
1473
-
1474
- let changed = false;
1475
- let residual = 0;
1476
- let pass = 0;
1477
- for (; pass < maxIterations; pass += 1) {
1478
- const capsules = buildCollisionCapsules(rig, radii);
1479
- if (!capsules) {
1480
- return { supported: false, changed, passes: pass, residual, touched: [...touched], baseQuats: bases };
1481
- }
1482
- // `changed` is cumulative across passes, so it can never report what
1483
- // THIS pass did. A pass that pushes nothing — every penetration
1484
- // belongs to a chain the rig lacks, or to a filtered-out chain — would
1485
- // otherwise keep looping to maxIterations re-solving an unmoving pose.
1486
- let passChanged = false;
1487
- const pens = fixablePens(capsules);
1488
- if (pens.length === 0) { residual = 0; break; }
1489
- residual = pens[0].depth;
1490
- const sidesBefore = bias > 0 ? wristSides() : null;
1491
- const insideBefore = blockerInsideCount(capsules, blockers);
1492
- const rewind = snapshotPass();
1493
- const dirtyBefore = poseDirty;
1494
- const touchedBefore = [...touched];
1495
- passChanged = applyPass(pens, bias);
1496
- if (passChanged && sidesBefore) {
1497
- const sidesAfter = wristSides();
1498
- if (crossedMidline(sidesBefore, sidesAfter) || crossedMidline(entrySides, sidesAfter)) {
1499
- restorePass(rewind);
1500
- passChanged = applyPass(pens, bias * 2);
1501
- }
1502
- }
1503
- // BOX GUARD. A box escape that ends with MORE of the limb inside the box
1504
- // went out through the wrong face, and no residual number can see that —
1505
- // the pose reports no contact precisely because it is buried. Such a pass
1506
- // is rewound whole and the loop stops with the pose it had, which is the
1507
- // worse-is-not-an-option rule the rest of this file already follows.
1508
- if (passChanged && insideBefore >= 0) {
1509
- const after = buildCollisionCapsules(rig, radii);
1510
- if (after && blockerInsideCount(after, blockers) > insideBefore) {
1511
- restorePass(rewind);
1512
- poseDirty = dirtyBefore;
1513
- touched.clear();
1514
- for (const id of touchedBefore) touched.add(id);
1515
- break;
1516
- }
1517
- }
1518
- changed = changed || passChanged;
1519
- if (!passChanged) break;
1520
- }
1521
- /**
1522
- * The last resort, and only ever a last one: the limb passes are over and a
1523
- * movable × static pair is still penetrating, so the STATIC side leans away
1524
- * by a few degrees.
1525
- *
1526
- * THE JOINTS ARE FOUND, NOT REQUIRED. The map is the caller's `fkJoints` when
1527
- * there is one and a minimal one built off the rig's own bones otherwise —
1528
- * because the caller that needs this most (the single-frame button) does not
1529
- * pass one, and QA measured exactly that: a head × upper-arm hit at the arm's
1530
- * reach limit, 88 mm left standing, spine and neck at 0.000°. The ids match
1531
- * the FK track ids either way, so `touched` stays bakeable.
1532
- *
1533
- * THE DIRECTION IS THE SUM OF WHAT PRESSES ON IT. Leaning away from the single
1534
- * deepest pair is no use when the blocker is caught between two of them — a
1535
- * head between both upper arms, the measured case — because every degree away
1536
- * from one is a degree into the other. The swing follows the depth-weighted
1537
- * sum of every pair pressing on that capsule, which for the symmetric case
1538
- * points backward, out of both.
1539
- *
1540
- * THE BUDGET IS SPENT IN STEPS, NOT BET ON ONE. A single 6° step cannot clear
1541
- * 88 mm, so a step that merely fails to improve is not a reason to stop:
1542
- * steps continue while the residual is NON-INCREASING, up to the cap. What is
1543
- * refused is the outcome — if the whole yield leaves the pose no better than
1544
- * it found it, every degree of it is rolled back and nothing is keyed.
1545
- */
1546
- const yieldJoints = fkJoints ?? fallbackYieldJoints(rig);
1547
- const yieldTargetOf = (pen) => {
1548
- const staticSide = !pen.a.def.movable ? pen.a : (!pen.b.def.movable ? pen.b : null);
1549
- if (!staticSide) return null;
1550
- const id = YIELD_JOINTS[staticSide.def.id];
1551
- const joint = id ? yieldJoints?.get(id) : null;
1552
- if (!joint?.bone?.parent) return null;
1553
- return { id, joint, staticSide };
1554
- };
1555
- /** Everything a yield can move, so a yield that does not pay for itself can
1556
- * be put back exactly. */
1557
- const yieldSnapshot = () => {
1558
- const entries = [];
1559
- const seen = new Set();
1560
- const add = (bone) => {
1561
- if (!bone || seen.has(bone)) return;
1562
- seen.add(bone);
1563
- entries.push([bone, bone.quaternion.clone(), bone.position.clone()]);
1564
- };
1565
- for (const chain of chains.values()) for (const bone of chain.bones) add(bone);
1566
- if (yieldJoints) for (const joint of yieldJoints.values()) add(joint.bone);
1567
- return entries;
1568
- };
1569
- const yieldRestore = (entries) => {
1570
- for (const [bone, quaternion, position] of entries) {
1571
- bone.quaternion.copy(quaternion);
1572
- bone.position.copy(position);
1573
- }
1574
- rig.updateMatrixWorld(true);
1575
- };
1576
- /** The depth-weighted direction `capsule` is being pressed FROM, with the
1577
- * contact it should pivot about and the deepest pair pressing on it. */
1578
- const yieldLoad = (pens, capsule) => {
1579
- const away = new THREE.Vector3();
1580
- const contact = new THREE.Vector3();
1581
- let weight = 0;
1582
- let deepest = 0;
1583
- for (const pen of pens) {
1584
- const isA = pen.a === capsule;
1585
- if (!isA && pen.b !== capsule) continue;
1586
- // `normal` points b → a, so the static side escapes along +normal when
1587
- // it is a and along −normal when it is b — the same rule applyPass uses.
1588
- away.addScaledVector(isA ? pen.normal : pen.normal.clone().negate(), pen.depth);
1589
- contact.addScaledVector(isA ? pen.pointA : pen.pointB, pen.depth);
1590
- weight += pen.depth;
1591
- deepest = Math.max(deepest, pen.depth);
1592
- }
1593
- if (weight <= 0 || away.lengthSq() < 1e-12) return null;
1594
- return { away: away.normalize(), contact: contact.divideScalar(weight), deepest };
1595
- };
1596
- const yieldStatic = (pens, target, limit) => {
1597
- const load = yieldLoad(pens, target.staticSide);
1598
- if (!load) return 0;
1599
- const pivot = target.joint.bone.getWorldPosition(new THREE.Vector3());
1600
- const lever = load.contact.clone().sub(pivot);
1601
- const radius = lever.length();
1602
- if (radius < 1e-4) return 0;
1603
- const axis = lever.clone().cross(load.away);
1604
- if (axis.lengthSq() < 1e-12) return 0;
1605
- const angle = Math.min(limit, (load.deepest + offset) / radius);
1606
- if (!(angle > 1e-6)) return 0;
1607
- axis.normalize();
1608
- // WHICH WAY ROUND. `lever × away` names the plane the yield should turn
1609
- // in, but not reliably its sign: `contact` is a depth-weighted mean of
1610
- // several contact points, and when a blocker is pressed from both sides
1611
- // that mean can land on the far side of the pivot from every individual
1612
- // contact, which flips the cross product and leans INTO the deeper pair.
1613
- // Both signs are cheap to try, so try both and keep the one that actually
1614
- // reduces the worst depth; if neither does, spend nothing.
1615
- const before = worstDepth();
1616
- const start = target.joint.bone.quaternion.clone();
1617
- const parentWorld = target.joint.bone.parent.getWorldQuaternion(new THREE.Quaternion());
1618
- const startPosition = target.joint.bone.position.clone();
1619
- let best = null;
1620
- for (const sign of [1, -1]) {
1621
- solveSwingAngle(target.joint, axis, sign * angle, start, parentWorld);
1622
- rig.updateMatrixWorld(true);
1623
- const now = worstDepth();
1624
- if (best === null || now < best.depth) {
1625
- best = { depth: now, quaternion: target.joint.bone.quaternion.clone() };
1626
- }
1627
- }
1628
- if (!best || best.depth >= before - 1e-9) {
1629
- target.joint.bone.quaternion.copy(start);
1630
- target.joint.bone.position.copy(startPosition);
1631
- rig.updateMatrixWorld(true);
1632
- return 0;
1633
- }
1634
- target.joint.bone.quaternion.copy(best.quaternion);
1635
- rig.updateMatrixWorld(true);
1636
- return angle;
1637
- };
1638
- /** The deepest fixable penetration right now, 0 when there is none. */
1639
- const worstDepth = () => {
1640
- const capsules = buildCollisionCapsules(rig, radii);
1641
- if (!capsules) return 0;
1642
- const pens = fixablePens(capsules);
1643
- return pens.length ? pens[0].depth : 0;
1644
- };
1645
- if (yieldJoints?.size && residual > epsilon) {
1646
- const entrySnapshot = yieldSnapshot();
1647
- const entryTouched = [...touched];
1648
- const entryDirty = poseDirty;
1649
- const startResidual = residual;
1650
- let bestResidual = residual;
1651
- let bestSnapshot = null;
1652
- const yielded = new Set();
1653
- let budget = YIELD_TOTAL_RAD;
1654
- while (budget > 1e-6) {
1655
- const capsules = buildCollisionCapsules(rig, radii);
1656
- if (!capsules) break;
1657
- const pens = fixablePens(capsules);
1658
- const worst = pens.length ? pens[0].depth : 0;
1659
- if (worst <= epsilon) break;
1660
- let picked = null;
1661
- for (const pen of pens) {
1662
- const target = yieldTargetOf(pen);
1663
- if (target) { picked = { pens, target }; break; }
1664
- }
1665
- if (!picked) break;
1666
- const spent = yieldStatic(picked.pens, picked.target, Math.min(YIELD_STEP_RAD, budget));
1667
- if (!(spent > 0)) break;
1668
- // The room a yield opens is only worth anything if the limb takes it.
1669
- const after = buildCollisionCapsules(rig, radii);
1670
- const remaining = after ? fixablePens(after) : [];
1671
- if (remaining.length) applyPass(remaining, bias);
1672
- const now = worstDepth();
1673
- // Non-increasing keeps the budget alive (one 6° step cannot clear an
1674
- // 88 mm hit); a step that makes things worse ends it there.
1675
- if (now > worst + 1e-6) break;
1676
- budget -= spent;
1677
- yielded.add(picked.target.id);
1678
- if (now < bestResidual - 1e-9) {
1679
- bestResidual = now;
1680
- bestSnapshot = yieldSnapshot();
1681
- }
1682
- }
1683
- if (bestResidual < startResidual - 1e-6) {
1684
- if (bestSnapshot) yieldRestore(bestSnapshot);
1685
- residual = bestResidual;
1686
- changed = true;
1687
- for (const id of yielded) {
1688
- touched.add(id);
1689
- if (ikState) ikTouch(ikState, id);
1690
- }
1691
- } else {
1692
- // Bought nothing: no lean, no key, and the pose the limb passes reached
1693
- // is what the caller gets.
1694
- yieldRestore(entrySnapshot);
1695
- touched.clear();
1696
- for (const id of entryTouched) touched.add(id);
1697
- poseDirty = entryDirty;
1698
- }
1699
- }
1700
-
1701
- // A solve that rearranged a chain without moving its driven joint still
1702
- // changed the pose, and the caller must key it.
1703
- changed = changed || poseDirty;
1704
-
1705
- /**
1706
- * A frame's fix is kept only if it is BETTER than the pose it started from.
1707
- * Two ways it can fail to be, each measured against the entry pose:
1708
- *
1709
- * - IT DID NOT HELP. The deepest fixable pair is no shallower than it was.
1710
- * Keying that is how a whole-clip run stayed non-idempotent: 19 frames
1711
- * re-keyed on every press with "13 still penetrate", because a frame the
1712
- * passes could not clear was baked anyway and then baked again next time.
1713
- * An unreducible frame is now reported, not written.
1714
- * - IT BURIED A FOOT. The push guard clamps the joint it drives, which is
1715
- * the ankle; the toe hangs off the far side and went 18.7 mm under the
1716
- * deck on QA's crouch. Any marker lower than it started, and lower than
1717
- * its clearance, is a fix that solved one artefact by making a worse one.
1718
- *
1719
- * Refusal is total: the pose goes back exactly as it arrived, nothing is
1720
- * touched, nothing is keyed, and `residual` reports the depth that defeated
1721
- * it so the caller can say so out loud.
1722
- */
1723
- if (changed) {
1724
- const afterWorst = currentWorst();
1725
- const afterFoot = lowestFoot();
1726
- const improved = afterWorst < entryWorst - epsilon;
1727
- const buried = Number.isFinite(afterFoot)
1728
- && afterFoot < Math.min(entryFoot, floorY + PUSH_FLOOR_CLEARANCE) - 1e-6;
1729
- if (!improved || buried) {
1730
- frameRestore(entryPose);
1731
- return {
1732
- supported: true,
1733
- changed: false,
1734
- passes: pass,
1735
- residual: entryWorst,
1736
- touched: [],
1737
- baseQuats: bases,
1738
- refused: improved ? "foot-below-floor" : "no-improvement",
1739
- };
1740
- }
1741
- }
1742
-
1743
- // Final measurement for an honest residual report.
1744
- if (changed) {
1745
- const capsules = buildCollisionCapsules(rig, radii);
1746
- // Same-chain folds are reported by detection but never acted on, so
1747
- // they must not count as residual either — otherwise a frame the fixer
1748
- // has fully handled toasts "reduced (0.8 cm)" and the next press,
1749
- // which finds nothing to do, toasts "no collisions" for the same pose.
1750
- const pens = capsules ? detectPenetrations(capsules, { offset, slackFactor, blockers }).filter((pen) => !isHingeFold(pen)) : [];
1751
- residual = pens.length ? pens[0].depth : 0;
1752
- }
1753
- return { supported: true, changed, passes: pass, residual, touched: [...touched], baseQuats: bases };
1754
- }
1755
-
1756
- /** Every bone the IK layer can write, once each: the chain bones plus the FK
1757
- * joints. These are exactly the bones a bake at one frame can change, so
1758
- * snapshotting them captures everything this pass could contaminate. */
1759
- function layerBones(chains, fkJoints) {
1760
- const bones = [];
1761
- const seen = new Set();
1762
- const add = (bone) => {
1763
- if (!bone || seen.has(bone)) return;
1764
- seen.add(bone);
1765
- bones.push(bone);
1766
- };
1767
- for (const chain of chains.values()) for (const bone of chain.bones) add(bone);
1768
- if (fkJoints) for (const joint of fkJoints.values()) add(joint.bone);
1769
- return bones;
1770
- }
1771
-
1772
- function snapshotBones(bones) {
1773
- return bones.map((bone) => ({ position: bone.position.clone(), quaternion: bone.quaternion.clone() }));
1774
- }
1775
-
1776
- function restoreBones(rig, bones, snapshot) {
1777
- for (let index = 0; index < bones.length; index += 1) {
1778
- bones[index].position.copy(snapshot[index].position);
1779
- bones[index].quaternion.copy(snapshot[index].quaternion);
1780
- }
1781
- rig.updateMatrixWorld(true);
1782
- }
1783
-
1784
- /**
1785
- * Clean a frame RANGE, baking every touched frame into the IK key layer.
1786
- * `applyFrame(frame)` must pose the rig (motion apply + ikEvaluate) before
1787
- * the fix runs — App owns that plumbing, this owns the loop. Only frames
1788
- * that actually changed get a key, so a clean clip stays keyless.
1789
- *
1790
- * External blockers come either as a static `blockers` array (the same shapes at
1791
- * every frame) or as a `blockersAt(frame)` callback, called after applyFrame for
1792
- * that frame so an ANIMATED prop can report its pose for the frame being solved.
1793
- * Returns the list of keyed frames — empty on a rig the tool cannot
1794
- * describe, which the caller should have screened with
1795
- * supportsCollisionCleanup before offering the button at all.
1796
- *
1797
- * TWO SWEEPS, for the same reason auto-physics needs them. `applyFrame`
1798
- * evaluates the IK layer, so once this loop starts writing keys it is no longer
1799
- * looking at the clip: frame 40 gets posed as clip + the blend ramp of the key
1800
- * written at frame 37, that blend introduces penetrations of its own, and those
1801
- * get keyed too. On the QA clip 4 of 11 keys landed on frames that were CLEAN
1802
- * in the source, and 58 frames finished flagged that started clean — the pass
1803
- * chasing its own tail. Sweep 1 therefore records each frame's pose with
1804
- * nothing of this run's written yet; sweep 2 restores that pose before
1805
- * detecting, so every frame is judged and fixed against the clip.
1806
- *
1807
- * Sweep 1 records TWO poses per frame, and the difference matters:
1808
- *
1809
- * - the LAYER pose (clip + whatever keys already existed) is what the fix is
1810
- * applied to, because the user's earlier corrections are part of the pose
1811
- * they are asking to clean;
1812
- * - the RAW CLIP pose — sampled with `ikState.tracked` emptied, which makes
1813
- * the caller's own ikEvaluate a no-op — is what each key's rotations are
1814
- * stored as a DELTA FROM. It has to be the raw one: ikEvaluate finds the raw
1815
- * clip on the bone when it comes to apply the key, so only a delta measured
1816
- * from there collapses back to the authored pose at the keyed frame.
1817
- *
1818
- * With no keys yet the two are identical and the second sample is skipped.
1819
- */
1820
- export function fixCollisionsRange({
1821
- rig,
1822
- chains,
1823
- ikState,
1824
- fkJoints = null,
1825
- startFrame,
1826
- endFrame,
1827
- applyFrame,
1828
- blockersAt = null,
1829
- blendWindow = 6,
1830
- ...options
1831
- } = {}) {
1832
- if (!rig || !chains || !ikState || typeof applyFrame !== "function") return [];
1833
- const start = Math.max(0, Math.round(startFrame));
1834
- const end = Math.max(start, Math.round(endFrame));
1835
-
1836
- const bones = layerBones(chains, fkJoints);
1837
- const chainIds = [...chains.keys()];
1838
- const readChainQuats = () => new Map(chainIds.map((id) => [id, chains.get(id).bones.map((b) => b.quaternion.clone())]));
1839
- /** Every chain's EFFECTOR (wrist / ankle) in world space — the one point per
1840
- * limb whose frame-to-frame travel a viewer reads as speed, and the thing the
1841
- * step cap below is written in terms of. */
1842
- const readEffectors = () => new Map(chainIds.map(
1843
- (id) => [id, chains.get(id).bones[2].getWorldPosition(new THREE.Vector3())],
1844
- ));
1845
- const layerActive = ikState.tracked.size > 0;
1846
- const basePose = [];
1847
- const clipQuats = [];
1848
- const sourceEffectors = [];
1849
- for (let frame = start; frame <= end; frame += 1) {
1850
- if (layerActive) {
1851
- // Silence the layer for one sample: ikEvaluate walks `tracked`, so an
1852
- // empty set makes the caller's applyFrame pure clip. Restored on the
1853
- // same iteration, and never observed by anyone else — this loop owns
1854
- // the rig for its duration.
1855
- const tracked = ikState.tracked;
1856
- ikState.tracked = new Set();
1857
- try {
1858
- applyFrame(frame);
1859
- clipQuats.push(readChainQuats());
1860
- } finally {
1861
- ikState.tracked = tracked;
1862
- }
1863
- }
1864
- applyFrame(frame);
1865
- basePose.push(snapshotBones(bones));
1866
- sourceEffectors.push(readEffectors());
1867
- if (!layerActive) clipQuats.push(readChainQuats());
1868
- }
1869
-
1870
- /**
1871
- * THE STEP CAP, per chain, measured on the clip the pass was handed. A
1872
- * correction is allowed to move a limb; what it is not allowed to do is move
1873
- * it FASTER THAN THE CLIP EVER DOES between two frames it both corrects. So
1874
- * the cap is the effector's own worst adjacent step over the range, times
1875
- * STEP_CAP_FACTOR — a limb that already travels 8 cm a frame gets a roomier
1876
- * allowance than one that barely moves, which is the only ruler that means
1877
- * the same thing on a crouch and on a sprint.
1878
- *
1879
- * Clamped at both ends. STEP_CAP_ABS is the ceiling: a fast clip must not
1880
- * license a half-metre teleport just because its own hand is quick. The floor
1881
- * is what keeps a perfectly static clip from setting a cap of zero and
1882
- * refusing every correction that follows another one.
1883
- *
1884
- * The cap governs the step between two CONSECUTIVE CORRECTED frames only.
1885
- * The first corrected frame of a run has no corrected predecessor and is
1886
- * uncapped — the correction itself is as big as the collision demands, and
1887
- * easing that in is the blend window's job, not this one's.
1888
- */
1889
- const stepCap = new Map();
1890
- for (const id of chainIds) {
1891
- let worst = 0;
1892
- for (let index = 1; index < sourceEffectors.length; index += 1) {
1893
- const previous = sourceEffectors[index - 1].get(id);
1894
- const current = sourceEffectors[index].get(id);
1895
- if (previous && current) worst = Math.max(worst, current.distanceTo(previous));
1896
- }
1897
- stepCap.set(id, Math.min(STEP_CAP_ABS, Math.max(STEP_CAP_FLOOR, STEP_CAP_FACTOR * worst)));
1898
- }
1899
-
1900
- const keyed = [];
1901
- // Frame → deepest penetration left standing. A map, not a list: a frame the
1902
- // blend-window round revisits reports its LAST word, and a frame that round
1903
- // cleans drops off the list rather than being reported twice.
1904
- const residuals = new Map();
1905
- const unresolvedList = () => [...residuals.entries()]
1906
- .sort((a, b) => a[0] - b[0])
1907
- .map(([frame, residual]) => ({ frame, residual }));
1908
- const blockersFor = (frame) => (typeof blockersAt === "function" ? blockersAt(frame) : options.blockers);
1909
- const noteResidual = (frame, residual) => {
1910
- if (residual > 0) residuals.set(frame, residual);
1911
- else residuals.delete(frame);
1912
- };
1913
- /**
1914
- * WHY THIS EXISTS. Every frame above is solved on its own, from the clip,
1915
- * which is what makes each one honest — and what made a RUN of them garbage.
1916
- * A deep hit has several ways out, the choice between them turns on
1917
- * millimetres, and QA measured the consequence on the crouch clip: frames
1918
- * 104..120 each had a 112 mm head × hand hit, each frame found its own escape,
1919
- * and the hand stepped 22 → 160 mm, 23 → 520 mm, 25 → 519 mm frame to frame
1920
- * on source poses that barely moved. Arm up, hands to face, arm down across
1921
- * the body: three frames of flail, every one of them collision-free.
1922
- *
1923
- * The cure is to make a run of corrected frames one correction rather than N
1924
- * unrelated ones, in three pieces that all keep the per-frame guarantees:
1925
- *
1926
- * 1. WARM START. When the frame before was corrected, this frame's solve
1927
- * starts from the pose it would have if it inherited that correction —
1928
- * the previous frame's delta (baseQ⁻¹ ∘ q, its correction relative to its
1929
- * OWN clip pose) applied to this frame's pose. If that already reads
1930
- * clean, it is accepted as it stands: the collision is gone and the
1931
- * continuity comes for free. If not, the solve carries on from there, so
1932
- * it starts in the neighbourhood of the answer its neighbour found.
1933
- * 2. ESCAPE CONSISTENCY. Whatever is solved gets the previous frame's push
1934
- * direction as a bias (see CONTINUITY_BIAS), so two frames that must both
1935
- * escape leave the same way.
1936
- * 3. THE STEP CAP. If the result still moves an effector further from its
1937
- * previous corrected position than the clip's own motion justifies, the
1938
- * frame is re-solved with the continuity bias turned up; failing that the
1939
- * closest collision-free candidate is kept, and failing THAT the frame is
1940
- * left alone and reported unresolved. A flail is never the answer.
1941
- *
1942
- * Everything is keyed exactly as before — this frame's own raw baseQuats,
1943
- * only the chains that moved — so the blend window, the pins and the delta
1944
- * bake are untouched.
1945
- */
1946
- /** The frame before this one, when it was corrected: what it did, where it
1947
- * left each effector, and which way it pushed. Null the moment a frame is
1948
- * not keyed, so continuity never reaches across a gap. */
1949
- let carried = null;
1950
- /** `baseQ⁻¹ ∘ q` per bone — the correction a frame applied to its own clip
1951
- * pose, which is exactly the delta ikEvaluate replays for its key. */
1952
- const correctionDeltas = (touched, baseQuats) => {
1953
- const deltas = new Map();
1954
- for (const id of touched) {
1955
- const chain = chains.get(id);
1956
- const bases = baseQuats?.get(id);
1957
- if (!chain || !bases) continue;
1958
- deltas.set(id, chain.bones.map((bone, index) => (bases[index]
1959
- ? bases[index].clone().invert().multiply(bone.quaternion)
1960
- : new THREE.Quaternion())));
1961
- }
1962
- return deltas;
1963
- };
1964
- /** Seed `ids` with the previous frame's rotation correction on this frame's
1965
- * own translations, the same solve space used by the fresh attempts. */
1966
- const applyWarmStart = (ids, deltas) => {
1967
- for (const id of ids) {
1968
- const chain = chains.get(id);
1969
- const delta = deltas.get(id);
1970
- if (!chain || !delta) continue;
1971
- chain.bones.forEach((bone, index) => {
1972
- if (delta[index]) bone.quaternion.multiply(delta[index]);
1973
- });
1974
- }
1975
- rig.updateMatrixWorld(true);
1976
- };
1977
- const lowestFootMarker = () => {
1978
- let lowest = Infinity;
1979
- for (const name of FLOOR_MARKERS) {
1980
- const bone = findBone(rig, name);
1981
- if (bone) lowest = Math.min(lowest, bone.getWorldPosition(new THREE.Vector3()).y);
1982
- }
1983
- return lowest;
1984
- };
1985
- const epsilonOf = options.epsilon ?? 1e-4;
1986
- const floorOf = options.floorY ?? 0;
1987
-
1988
- for (let frame = start; frame <= end; frame += 1) {
1989
- const index = frame - start;
1990
- const blockers = blockersFor(frame);
1991
- const detectOptions = { offset: options.offset, slackFactor: options.slackFactor, blockers, onlyChains: options.onlyChains };
1992
- // applyFrame still runs: it poses the bones outside the IK layer (fingers,
1993
- // toes, spine tips) that the capsules also measure. The restore then
1994
- // overwrites exactly the bones this pass could have moved.
1995
- //
1996
- // Blockers are WORLD-SPACE and posed for this frame: a static array is
1997
- // the same set at every frame, while `blockersAt(frame)` is asked for
1998
- // them AFTER applyFrame, so a caller animating a prop reports where it
1999
- // is on the frame being solved rather than where it started.
2000
- const restoreFrame = () => {
2001
- applyFrame(frame);
2002
- restoreBones(rig, bones, basePose[index]);
2003
- };
2004
- restoreFrame();
2005
-
2006
- // The frame's OWN reading, before anything is seeded into it: the cast the
2007
- // provenance gate may move, the depth any candidate has to beat, and the
2008
- // floor no candidate may go under.
2009
- let warmChains = null;
2010
- let provenance = null;
2011
- let baseWorst = 0;
2012
- const baseEffectors = readEffectors();
2013
- const baseFoot = lowestFootMarker();
2014
- if (carried?.frame === frame - 1) {
2015
- const capsules = buildCollisionCapsules(rig, options.radii ?? null);
2016
- const pens = fixablePenetrations(capsules, detectOptions);
2017
- baseWorst = pens.length ? pens[0].depth : 0;
2018
- provenance = chainsInPlay(pens);
2019
- // Only a chain THIS frame's own pose puts in play may be warm started:
2020
- // inheriting a correction onto a limb with nothing wrong with it would
2021
- // smear the fix across frames that never needed one.
2022
- warmChains = [...provenance].filter((id) => carried.deltas.has(id));
2023
- }
2024
-
2025
- /** One attempt at this frame, from the pose as recorded, optionally warm
2026
- * started and with the continuity bias at `weight`. Returns everything the
2027
- * chooser needs and leaves nothing behind: the pose is snapshotted, and
2028
- * the next attempt restores the frame before it starts. */
2029
- const attempt = ({ warm, weight }) => {
2030
- restoreFrame();
2031
- if (warm?.length) applyWarmStart(warm, carried.deltas);
2032
- const continuity = weight > 0 && carried ? { dirs: carried.dirs, weight } : null;
2033
- // A warm start that lands clean IS the answer: the collision is gone
2034
- // and the pose is the neighbour's, which is the whole point. It still
2035
- // has to clear the floor, the one guarantee a solve would have checked
2036
- // and a straight inheritance would not.
2037
- if (warm?.length) {
2038
- const capsules = buildCollisionCapsules(rig, options.radii ?? null);
2039
- if (!capsules) return { supported: false };
2040
- if (fixablePenetrations(capsules, detectOptions).length === 0) {
2041
- const foot = lowestFootMarker();
2042
- if (!(Number.isFinite(foot) && foot < Math.min(baseFoot, floorOf + PUSH_FLOOR_CLEARANCE) - 1e-6)) {
2043
- return {
2044
- supported: true, keyed: true, warm: true,
2045
- touched: [...warm], residual: 0,
2046
- pose: snapshotBones(bones), effectors: readEffectors(),
2047
- };
2048
- }
2049
- }
2050
- }
2051
- const result = fixCollisions(rig, chains, {
2052
- ...options,
2053
- blockers,
2054
- fkJoints,
2055
- // The chains a DISCARDED attempt drove must not be left tracked, so
2056
- // the bake below — which touches every id it keys — is what marks
2057
- // the layer, not the attempts.
2058
- ikState: null,
2059
- baseQuats: clipQuats[index],
2060
- continuity,
2061
- provenanceChains: warm?.length ? provenance : null,
2062
- });
2063
- if (!result.supported) return { supported: false };
2064
- // A warm attempt keys every chain it seeded, whether the solve moved it
2065
- // again or not: the pose being measured is the seeded one, and a chain
2066
- // left out of the key would be evaluated back on the clip.
2067
- const touched = new Set(result.touched);
2068
- if (result.changed && warm?.length) for (const id of warm) touched.add(id);
2069
- return {
2070
- supported: true,
2071
- keyed: result.changed && touched.size > 0,
2072
- warm: Boolean(warm?.length),
2073
- touched: [...touched],
2074
- residual: result.residual,
2075
- pose: snapshotBones(bones),
2076
- effectors: readEffectors(),
2077
- };
2078
- };
2079
-
2080
- /** How far this attempt moves an effector away from where the previous
2081
- * corrected frame left it, as a FRACTION of that chain's cap — counted
2082
- * only on chains BOTH frames correct, because a chain the previous frame
2083
- * corrected and this one does not is the blend window's business, not the
2084
- * cap's. Over 1 is a jump; below it the frames read as one motion. The
2085
- * ratio (not the raw millimetres) is also what ranks two candidates, so a
2086
- * hand and a foot are compared on the same scale. */
2087
- const stepRatio = (candidate) => {
2088
- let worst = 0;
2089
- if (!carried) return worst;
2090
- for (const id of candidate.touched) {
2091
- const before = carried.effectors.get(id);
2092
- const after = candidate.effectors?.get(id);
2093
- if (!before || !after) continue;
2094
- worst = Math.max(worst, after.distanceTo(before) / (stepCap.get(id) ?? STEP_CAP_ABS));
2095
- }
2096
- return worst;
2097
- };
2098
- const overCap = (candidate) => stepRatio(candidate) > 1;
2099
- // A warm attempt may not come out WORSE than the frame's own pose: the
2100
- // seeded pose is not this frame's, so fixCollisions's accept gate — which
2101
- // judges against the pose it was handed — cannot see that comparison.
2102
- const usable = (candidate) => candidate?.keyed
2103
- && (!candidate.warm || candidate.residual <= baseWorst + 1e-9);
2104
-
2105
- const tries = [];
2106
- if (warmChains?.length) tries.push(attempt({ warm: warmChains, weight: CONTINUITY_BIAS }));
2107
- if (tries[0]?.supported === false) { keyed.unresolved = unresolvedList(); return keyed; }
2108
- const plain = attempt({ warm: null, weight: carried ? CONTINUITY_BIAS : 0 });
2109
- // An unsupported rig is unsupported at every frame: bail on the first
2110
- // one instead of re-posing the whole clip to learn the same thing.
2111
- if (!plain.supported) { keyed.unresolved = unresolvedList(); return keyed; }
2112
- tries.push(plain);
2113
-
2114
- // Warm first, then plain: the inherited answer wins ties, which is what
2115
- // makes a run of frames one correction.
2116
- let chosen = tries.find((candidate) => usable(candidate) && !overCap(candidate)) ?? null;
2117
- if (!chosen && tries.some(usable)) {
2118
- // Every candidate jumped. One more go with the previous frame's
2119
- // direction outvoting the rest bias...
2120
- const stronger = attempt({ warm: warmChains?.length ? warmChains : null, weight: CONTINUITY_BIAS_STRONG });
2121
- if (stronger.supported === false) { keyed.unresolved = unresolvedList(); return keyed; }
2122
- if (usable(stronger) && !overCap(stronger)) chosen = stronger;
2123
- else {
2124
- // ...and failing that, the closest of the candidates that actually
2125
- // clears the collision. A frame with no collision-free candidate is
2126
- // left as the clip had it and reported, because a flail that happens
2127
- // to be collision-free is not a fix.
2128
- const free = [...tries, stronger]
2129
- .filter((candidate) => usable(candidate) && candidate.residual <= epsilonOf)
2130
- .sort((a, b) => stepRatio(a) - stepRatio(b));
2131
- chosen = free[0] ?? null;
2132
- }
2133
- }
2134
-
2135
- if (!chosen) {
2136
- // Nothing to key: either the frame was clean (residual 0, and every
2137
- // candidate reports it) or every candidate was refused. Report whichever
2138
- // depth is left standing on the pose the viewer will see.
2139
- restoreFrame();
2140
- noteResidual(frame, Math.max(baseWorst, plain.keyed ? 0 : plain.residual));
2141
- carried = null;
2142
- continue;
2143
- }
2144
- restoreBones(rig, bones, chosen.pose);
2145
- noteResidual(frame, chosen.residual);
2146
- // Only the chains THIS frame moved — the pass must not re-key an
2147
- // unrelated limb at the blended value it happens to be showing here —
2148
- // and each one as a delta from the clip's own rotations at this frame.
2149
- ikBakeKeyframe(chains, ikState, frame, fkJoints, chosen.touched, null, clipQuats[index]);
2150
- keyed.push(frame);
2151
- const dirs = new Map();
2152
- for (const id of chosen.touched) {
2153
- const before = baseEffectors.get(id);
2154
- const after = chosen.effectors.get(id);
2155
- if (!before || !after) continue;
2156
- const dir = after.clone().sub(before);
2157
- if (dir.lengthSq() > 1e-8) dirs.set(id, dir.normalize());
2158
- }
2159
- carried = {
2160
- frame,
2161
- deltas: correctionDeltas(chosen.touched, clipQuats[index]),
2162
- effectors: new Map([...chosen.effectors].filter(([id]) => chosen.touched.includes(id))),
2163
- dirs,
2164
- };
2165
- }
2166
-
2167
- /**
2168
- * Sweeps 1 and 2 judge every frame against the CLIP, which is what stops the
2169
- * pass chasing its own tail. The cost is that the ramp each new key runs out
2170
- * over is never looked at, and a big correction drags its neighbours into
2171
- * contact on the way: QA's crouch had frame 103 clean before the pass and
2172
- * 28.9 mm of forearm × thumb after it, deterministically, with no frame left
2173
- * to notice.
2174
- *
2175
- * So one bounded round, and only for frames a key can actually reach: pose
2176
- * the frame WITH the layer (the pose the viewer will see), and if that pose
2177
- * penetrates, fix and key it from there. The key is still stored as a delta
2178
- * from the RAW CLIP rotations sweep 1 recorded, so it composes with the ramp
2179
- * the same way every other key does.
2180
- *
2181
- * ONE ROUND. Keying these frames moves the ramp again, and chasing that would
2182
- * not terminate on a clip whose corrections overlap. A frame this round
2183
- * leaves dirty is reported in `unresolved` instead, which is the honest
2184
- * answer and one the caller can say out loud.
2185
- */
2186
- if (keyed.length && blendWindow > 0) {
2187
- const keyedSet = new Set(keyed);
2188
- const candidates = [];
2189
- for (const frame of keyed) {
2190
- for (let offset = -blendWindow; offset <= blendWindow; offset += 1) {
2191
- const near = frame + offset;
2192
- if (near < start || near > end || keyedSet.has(near)) continue;
2193
- if (!candidates.includes(near)) candidates.push(near);
2194
- }
2195
- }
2196
- candidates.sort((a, b) => a - b);
2197
- for (const frame of candidates) {
2198
- applyFrame(frame); // the BLENDED pose, keys included — no restore here
2199
- const result = fixCollisions(rig, chains, {
2200
- ...options,
2201
- blockers: blockersFor(frame),
2202
- fkJoints,
2203
- ikState,
2204
- baseQuats: clipQuats[frame - start],
2205
- });
2206
- if (!result.supported) break;
2207
- noteResidual(frame, result.residual);
2208
- if (!result.changed || !result.touched.length) continue;
2209
- ikBakeKeyframe(chains, ikState, frame, fkJoints, result.touched, null, result.baseQuats);
2210
- keyed.push(frame);
2211
- }
2212
- keyed.sort((a, b) => a - b);
2213
- }
2214
-
2215
- /**
2216
- * Everything above judges a frame at the moment it is solved. What the viewer
2217
- * gets is the EVALUATED pose — clip plus every key's blend ramp — and the last
2218
- * key written can put a frame back into contact after that frame was already
2219
- * measured and passed. QA's crouch ended with frame 104 at 13.5 mm, created by
2220
- * a neighbouring key's ramp, and the toast said nothing at all.
2221
- *
2222
- * So one last read-only walk over every frame a key can reach: pose it with
2223
- * the layer, measure, and let that measurement be the final word. Nothing is
2224
- * written here — a frame this finds dirty is REPORTED, because writing would
2225
- * move the ramps again and there would be no frame left to check that.
2226
- */
2227
- if (keyed.length) {
2228
- const reach = new Set();
2229
- for (const frame of keyed) {
2230
- for (let offset = -blendWindow; offset <= blendWindow; offset += 1) {
2231
- const near = frame + offset;
2232
- if (near >= start && near <= end) reach.add(near);
2233
- }
2234
- }
2235
- for (const frame of [...reach].sort((a, b) => a - b)) {
2236
- applyFrame(frame);
2237
- const capsules = buildCollisionCapsules(rig, options.radii ?? null);
2238
- const pens = (capsules
2239
- ? detectPenetrations(capsules, {
2240
- offset: options.offset,
2241
- slackFactor: options.slackFactor,
2242
- blockers: blockersFor(frame),
2243
- })
2244
- : []).filter((pen) => !isHingeFold(pen));
2245
- // The final word replaces whatever the solve-time measurement said,
2246
- // in both directions: a frame a later ramp dirtied is added, and one
2247
- // a later key cleaned drops off.
2248
- noteResidual(frame, pens.length ? pens[0].depth : 0);
2249
- }
2250
- }
2251
-
2252
- // Frames the passes could not clear, deepest remaining penetration and all,
2253
- // so the caller can say "some frames still touch" instead of implying a clean
2254
- // clip. Rides on the returned array so every existing caller keeps working.
2255
- keyed.unresolved = unresolvedList();
2256
- return keyed;
2257
- }