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
@@ -0,0 +1,850 @@
1
+ import * as THREE from "three";
2
+ import {
3
+ findBone,
4
+ measureContactRadii,
5
+ hasBindPose,
6
+ bindWorldPosition,
7
+ restWorldPosition,
8
+ } from "./ik.js";
9
+
10
+ /**
11
+ * Body-collision DETECTION: capsule proxies per body segment (bone → child
12
+ * bone axis, radius measured from the bind-pose skinned mesh by
13
+ * measureContactRadii) and the penetrations between them and external
14
+ * blockers. Read-only: motion verification (studio-agent-motion.js) and the
15
+ * collision blockers build on it. The 2.x solver that pushed limbs apart was
16
+ * removed with ARDY; motion3 QA (src/motion3/qa) reports collisions for
17
+ * compiled takes.
18
+ *
19
+ * Torso/head capsules are static; limb capsules name the IK chain they ride.
20
+ */
21
+
22
+ /**
23
+ * Body capsules. `movable.chain`/`movable.joint` name the IK chain and the
24
+ * joint of that chain to drive ("mid" = elbow/knee, "effector" = wrist/
25
+ * ankle). Torso, chest and head have no chain: they are blockers the limbs
26
+ * get pushed out of. `radiusJoint` keys into the measured contact radii.
27
+ *
28
+ * `endOptional` marks an end bone the rig is allowed not to have: the hands
29
+ * span wrist → middle-finger base so a fingertip driven into a thigh is
30
+ * judged at the FINGERS, not at the wrist a palm's length behind them (a
31
+ * wrist-only sphere reads that pose as clean). Rigs exported without finger
32
+ * bones simply fall back to the wrist sphere instead of disqualifying the
33
+ * whole tool.
34
+ */
35
+ // `maxRadius` caps the measured radius where the mesh extent is not a
36
+ // thickness: the head measurement runs crown-to-chin (a length), the foot
37
+ // measurement follows the toe (also a length). Uncapped, those fat capsules
38
+ // flag permanent false contacts with the torso and with the other foot at
39
+ // any normal stance.
40
+ const CAPSULE_DEFS = [
41
+ { id: "torso", start: "mixamorigHips", end: "mixamorigSpine1", radiusJoint: "Spine", maxRadius: 0.17, movable: null },
42
+ { id: "chest", start: "mixamorigSpine1", end: "mixamorigSpine2", radiusJoint: "Spine", maxRadius: 0.16, movable: null },
43
+ { id: "head", start: "mixamorigNeck", end: "mixamorigHead", radiusJoint: "Head", maxRadius: 0.11, movable: null },
44
+ { id: "leftUpperArm", start: "mixamorigLeftArm", end: "mixamorigLeftForeArm", radiusJoint: "LeftArm", movable: { chain: "leftHand", joint: "mid" }, priority: 2 },
45
+ { id: "leftForeArm", start: "mixamorigLeftForeArm", end: "mixamorigLeftHand", radiusJoint: "LeftForeArm", movable: { chain: "leftHand", joint: "effector" }, priority: 2 },
46
+ { id: "leftHand", start: "mixamorigLeftHand", end: "mixamorigLeftHandMiddle1", endOptional: true, radiusJoint: "LeftHand", maxRadius: 0.06, movable: { chain: "leftHand", joint: "effector" }, priority: 2 },
47
+ { id: "rightUpperArm", start: "mixamorigRightArm", end: "mixamorigRightForeArm", radiusJoint: "RightArm", movable: { chain: "rightHand", joint: "mid" }, priority: 2 },
48
+ { id: "rightForeArm", start: "mixamorigRightForeArm", end: "mixamorigRightHand", radiusJoint: "RightForeArm", movable: { chain: "rightHand", joint: "effector" }, priority: 2 },
49
+ { id: "rightHand", start: "mixamorigRightHand", end: "mixamorigRightHandMiddle1", endOptional: true, radiusJoint: "RightHand", maxRadius: 0.06, movable: { chain: "rightHand", joint: "effector" }, priority: 2 },
50
+ { id: "leftThigh", start: "mixamorigLeftUpLeg", end: "mixamorigLeftLeg", radiusJoint: "LeftUpLeg", movable: { chain: "leftFoot", joint: "mid" }, priority: 1 },
51
+ { id: "leftShin", start: "mixamorigLeftLeg", end: "mixamorigLeftFoot", radiusJoint: "LeftLeg", movable: { chain: "leftFoot", joint: "effector" }, priority: 1 },
52
+ { id: "leftFoot", start: "mixamorigLeftFoot", end: "mixamorigLeftToeBase", radiusJoint: "LeftFoot", maxRadius: 0.07, movable: { chain: "leftFoot", joint: "effector" }, priority: 1 },
53
+ { id: "rightThigh", start: "mixamorigRightUpLeg", end: "mixamorigRightLeg", radiusJoint: "RightUpLeg", movable: { chain: "rightFoot", joint: "mid" }, priority: 1 },
54
+ { id: "rightShin", start: "mixamorigRightLeg", end: "mixamorigRightFoot", radiusJoint: "RightLeg", movable: { chain: "rightFoot", joint: "effector" }, priority: 1 },
55
+ { id: "rightFoot", start: "mixamorigRightFoot", end: "mixamorigRightToeBase", radiusJoint: "RightFoot", maxRadius: 0.07, movable: { chain: "rightFoot", joint: "effector" }, priority: 1 },
56
+ ];
57
+
58
+ /**
59
+ * FINGER CAPSULES. The hand capsule spans wrist → middle-finger base, which is
60
+ * a palm: it still reads a pose where the FINGERS are buried in a thigh and the
61
+ * palm is not as clean, because the fingers stick out another 8 cm past its end
62
+ * cap. Mixamo hands carry mixamorig{Side}Hand{Finger}1..4, so each finger gets
63
+ * its own thin capsule from the base joint to the tip.
64
+ *
65
+ * OPTIONAL, ONE FINGER AT A TIME. Game rigs ship every mixture of these bones:
66
+ * all four joints, three, a thumb only, none at all. A finger falls back from
67
+ * 1→4 to 1→3 and, failing both, is simply left out — `optional` means a MISSING
68
+ * finger is not a missing capsule but a missing entry, so a fingerless rig is
69
+ * still fully supported (supportsCollisionCleanup ignores optional defs) and
70
+ * every other proxy is built exactly as before.
71
+ *
72
+ * RADIUS is a constant. measureContactRadii's CONTACT_JOINTS carry no finger
73
+ * entries — there is nothing to look up — and a finger is the one body part
74
+ * whose thickness barely varies between characters.
75
+ */
76
+ export const FINGER_RADIUS = 0.011;
77
+
78
+ const FINGER_NAMES = ["Thumb", "Index", "Middle", "Ring", "Pinky"];
79
+
80
+ for (const side of ["Left", "Right"]) {
81
+ const lower = side === "Left" ? "left" : "right";
82
+ for (const finger of FINGER_NAMES) {
83
+ CAPSULE_DEFS.push({
84
+ id: `${lower}Hand${finger}`,
85
+ start: `mixamorig${side}Hand${finger}1`,
86
+ // First spelling that resolves wins: the tip, else the last knuckle.
87
+ ends: [`mixamorig${side}Hand${finger}4`, `mixamorig${side}Hand${finger}3`],
88
+ optional: true,
89
+ fixedRadius: FINGER_RADIUS,
90
+ // The two ids a finger must never be tested against beyond its own
91
+ // kind — see pairExcluded.
92
+ handId: `${lower}Hand`,
93
+ foreArmId: `${lower}ForeArm`,
94
+ movable: { chain: `${lower}Hand`, joint: "effector" },
95
+ priority: 2,
96
+ });
97
+ }
98
+ }
99
+
100
+ /** Fallback radius when no measurement exists (matches ik.js's floor). */
101
+ const RADIUS_FALLBACK = 0.01;
102
+
103
+ /** Soft-tissue compression allowance, as a fraction of the thinner capsule.
104
+ * See detectPenetrations for why this is 0.25 and not something roomier. */
105
+ export const DEFAULT_SLACK_FACTOR = 0.25;
106
+
107
+ /** How much closer than its BIND pose a pair must come before the extra
108
+ * closeness counts as a collision. See restPairOverlaps. 5 mm is under the
109
+ * amplitude of the proxy error itself and far under any visible interpenetration. */
110
+ export const PAIR_TOLERANCE = 0.005;
111
+
112
+ /**
113
+ * REPORTING FLOOR. A capsule proxy is a coarse stand-in for a specific mesh, and
114
+ * its agreement with that mesh is worth a few millimetres at best; the pushes it
115
+ * takes to resolve one are not small (a 0.2 mm thigh × thigh reading during a
116
+ * normal walk stride moved the pose 16 mm and keyed the frame). So a pair under
117
+ * this depth is not reported at all — not by detection, not to the fixer, not to
118
+ * the readout — and the tool spends its passes on contact a viewer can actually
119
+ * see.
120
+ */
121
+ export const MIN_DEPTH = 0.004;
122
+
123
+ /**
124
+ * True when the rig carries every bone the capsule table REQUIRES — the
125
+ * cheap pre-flight the UI runs to decide whether to offer the tool at all.
126
+ * Bone lookups only: no mesh measurement, so it is safe to call per render.
127
+ * Optional ends (the finger bones) never count against a rig.
128
+ */
129
+ export function supportsCollisionCleanup(rig) {
130
+ if (!rig) return false;
131
+ for (const def of CAPSULE_DEFS) {
132
+ // An OPTIONAL capsule (the fingers) is an extra proxy, never a
133
+ // requirement: a rig without them is described just as well without them.
134
+ if (def.optional) continue;
135
+ if (!findBone(rig, def.start)) return false;
136
+ if (def.end && !def.endOptional && !findBone(rig, def.end)) return false;
137
+ }
138
+ return true;
139
+ }
140
+
141
+ /** The capsule table against an arbitrary source of bone positions — the
142
+ * current pose for detection, the bind pose for calibration. Returns null when
143
+ * a REQUIRED bone is missing. */
144
+ function buildCapsules(rig, radiiMap, positionOf) {
145
+ const capsules = new Map();
146
+ for (const def of CAPSULE_DEFS) {
147
+ const start = findBone(rig, def.start);
148
+ // An OPTIONAL capsule the rig has no bones for is left out, not fatal.
149
+ if (!start) {
150
+ if (def.optional) continue;
151
+ return null;
152
+ }
153
+ let end = null;
154
+ if (def.ends) {
155
+ for (const name of def.ends) {
156
+ end = findBone(rig, name);
157
+ if (end) break;
158
+ }
159
+ // A finger with a base joint but no further joint has no segment worth
160
+ // testing: skip it rather than park a knuckle-sized sphere on the hand.
161
+ if (!end) continue;
162
+ } else if (def.end) {
163
+ end = findBone(rig, def.end);
164
+ // A missing OPTIONAL end degrades to the wrist sphere; a missing
165
+ // required end means this is not a rig we can build proxies for.
166
+ if (!end && !def.endOptional) return null;
167
+ }
168
+ const a = positionOf(start);
169
+ const b = end ? positionOf(end) : a.clone();
170
+ const measured = Math.max(RADIUS_FALLBACK, radiiMap[def.radiusJoint] ?? RADIUS_FALLBACK);
171
+ const radius = def.fixedRadius ?? (def.maxRadius ? Math.min(measured, def.maxRadius) : measured);
172
+ capsules.set(def.id, {
173
+ def,
174
+ bones: end ? [start, end] : [start],
175
+ a,
176
+ b,
177
+ radius,
178
+ });
179
+ }
180
+ return capsules;
181
+ }
182
+
183
+ /**
184
+ * Build world-space capsules for the rig's CURRENT pose. Cheap (a bone
185
+ * lookup + two world positions per entry), so callers rebuild per frame.
186
+ * `radii` overrides the measured map (tests, or rigs without skin weights).
187
+ * Returns null when a REQUIRED bone is missing — a non-Mixamo rig the tool
188
+ * cannot describe, same policy as resolveIkRig. Callers must tell that apart
189
+ * from "found no collision".
190
+ */
191
+ export function buildCollisionCapsules(rig, radii = null) {
192
+ if (!rig) return null;
193
+ rig.updateMatrixWorld(true);
194
+ const radiiMap = radii ?? measureContactRadii(rig);
195
+ const capsules = buildCapsules(rig, radiiMap, (bone) => bone.getWorldPosition(new THREE.Vector3()));
196
+ // Ride the pair calibration along on the map so detectPenetrations gets it
197
+ // for free — it is a property of the RIG, which detection alone cannot see.
198
+ if (capsules) capsules.pairAllowances = restPairOverlaps(rig, radiiMap);
199
+ return capsules;
200
+ }
201
+
202
+ /** Stable key for an unordered capsule pair. */
203
+ function pairKey(idA, idB) {
204
+ return idA < idB ? `${idA}|${idB}` : `${idB}|${idA}`;
205
+ }
206
+
207
+ // rig → (radii object identity → pair overlap map). The reference poses never
208
+ // change, so this is measured once per character.
209
+ const restOverlapCache = new WeakMap();
210
+
211
+ /** Every testable pair's overlap in one reference pose, positive entries only,
212
+ * merged into `into` by taking the larger of the two. Measured through the SAME
213
+ * pairGeometry detection uses, so a trimmed (hinged) pair is calibrated on the
214
+ * portions it will actually be judged on — the natural fold a character rests
215
+ * in is a legitimate overlap there exactly as it is anywhere else. */
216
+ function collectPairOverlaps(rig, radiiMap, positionOf, into) {
217
+ const capsules = buildCapsules(rig, radiiMap, positionOf);
218
+ if (!capsules) return into;
219
+ const list = [...capsules.values()];
220
+ const pa = new THREE.Vector3();
221
+ const pb = new THREE.Vector3();
222
+ for (let i = 0; i < list.length; i += 1) {
223
+ for (let j = i + 1; j < list.length; j += 1) {
224
+ const geo = pairGeometry(list[i], list[j]);
225
+ if (!geo) continue;
226
+ closestPointsSegmentSegment(geo.a0, geo.a1, geo.b0, geo.b1, pa, pb);
227
+ const overlap = list[i].radius + list[j].radius - pa.distanceTo(pb);
228
+ if (overlap <= 0) continue;
229
+ const key = pairKey(list[i].def.id, list[j].def.id);
230
+ if (overlap > (into.get(key) ?? 0)) into.set(key, overlap);
231
+ }
232
+ }
233
+ return into;
234
+ }
235
+
236
+ /**
237
+ * How much each non-adjacent capsule pair ALREADY overlaps in a pose the
238
+ * character legitimately holds, in metres (positive entries only; a pair that is
239
+ * clear in every reference pose has no entry). Returns null on a rig with no
240
+ * bind snapshot to compose from.
241
+ *
242
+ * Capsules are a coarse proxy for a specific mesh, and on real characters the
243
+ * proxy error is not small: on the shipped default rig the REST pose reports
244
+ * torso × upper arm at 9.2 mm of "penetration" (torso 0.17 + upper arm 0.05447
245
+ * + offset 0.002 against a 0.19547 axis distance, minus a 21.8 mm slack
246
+ * allowance). One press of Fix collisions on a brand-new project baked a phantom
247
+ * key and moved the rig 13.7 mm. Slack cannot fix this: the rig would need
248
+ * slackFactor ≥ 0.569 to read clean, and a global allowance that loose re-masks
249
+ * every real contact on every other pair.
250
+ *
251
+ * So the pair's own reference overlap becomes its zero point — the same
252
+ * clip-relative-ground move auto-physics made for the floor.
253
+ *
254
+ * TWO REFERENCE POSES, and the second is the one that matters. `poseBind` is a
255
+ * T-POSE: arms straight out, nowhere near the torso (LeftForeArm at x = 0.4616).
256
+ * The pose the app actually shows is DEFAULT_POSE — arms hanging at the sides,
257
+ * the same forearm at x = 0.1876 — and that is where the arm capsules graze the
258
+ * torso. Calibrating against bind alone therefore recorded only leftThigh ×
259
+ * rightThigh and left torso × upper arm at zero allowance, which is exactly the
260
+ * pair that false-positives. Both poses are measured and the LARGER allowance
261
+ * wins, so a T-posed character and a standing one both read clean, while a pair
262
+ * that overlaps in neither (a hand driven into a thigh) still fires at full
263
+ * sensitivity. Both are composed virtually through ik.js, so measuring never
264
+ * disturbs — or depends on — the pose currently on screen.
265
+ *
266
+ * The cost, stated plainly: contact that the rest pose already has is invisible
267
+ * here. An arm pressed flat against the ribs cannot be distinguished from an arm
268
+ * hanging beside them, because the capsule proxies genuinely cannot tell those
269
+ * apart. Excluding those pairs outright (the alternative) would lose the deep
270
+ * hits too; this keeps them.
271
+ */
272
+ function restPairOverlaps(rig, radii = null) {
273
+ if (!rig || !hasBindPose(rig)) return null;
274
+ const radiiMap = radii ?? measureContactRadii(rig);
275
+ let perRadii = restOverlapCache.get(rig);
276
+ if (!perRadii) restOverlapCache.set(rig, (perRadii = new Map()));
277
+ const cached = perRadii.get(radiiMap);
278
+ if (cached) return cached;
279
+ rig.updateMatrixWorld(true);
280
+ const overlaps = new Map();
281
+ collectPairOverlaps(rig, radiiMap, (bone) => bindWorldPosition(rig, bone, new THREE.Vector3()), overlaps);
282
+ collectPairOverlaps(rig, radiiMap, (bone) => restWorldPosition(rig, bone, new THREE.Vector3()), overlaps);
283
+ perRadii.set(radiiMap, overlaps);
284
+ return overlaps;
285
+ }
286
+
287
+ /** Closest points between two segments (Ericson, Real-Time Collision
288
+ * Detection — the textbook segment/segment distance). Writes into outA/outB
289
+ * and returns the squared distance. */
290
+ function closestPointsSegmentSegment(p1, q1, p2, q2, outA, outB) {
291
+ const d1 = q1.clone().sub(p1);
292
+ const d2 = q2.clone().sub(p2);
293
+ const r = p1.clone().sub(p2);
294
+ const a = d1.dot(d1);
295
+ const e = d2.dot(d2);
296
+ const f = d2.dot(r);
297
+ let s;
298
+ let t;
299
+ if (a <= 1e-12 && e <= 1e-12) {
300
+ outA.copy(p1); outB.copy(p2);
301
+ return r.dot(r);
302
+ }
303
+ if (a <= 1e-12) {
304
+ s = 0;
305
+ t = Math.max(0, Math.min(1, f / e));
306
+ } else {
307
+ const c = d1.dot(r);
308
+ if (e <= 1e-12) {
309
+ t = 0;
310
+ s = Math.max(0, Math.min(1, -c / a));
311
+ } else {
312
+ const b = d1.dot(d2);
313
+ const denom = a * e - b * b;
314
+ s = denom > 1e-12 ? Math.max(0, Math.min(1, (b * f - c * e) / denom)) : 0;
315
+ t = (b * s + f) / e;
316
+ if (t < 0) { t = 0; s = Math.max(0, Math.min(1, -c / a)); }
317
+ else if (t > 1) { t = 1; s = Math.max(0, Math.min(1, (b - c) / a)); }
318
+ }
319
+ }
320
+ outA.copy(p1).addScaledVector(d1, s);
321
+ outB.copy(p2).addScaledVector(d2, t);
322
+ return outA.distanceToSquared(outB);
323
+ }
324
+
325
+ /** Ancestor hop count between two bones, or Infinity when neither is an
326
+ * ancestor of the other within `limit` hops. */
327
+ const HOP_SEARCH = 8;
328
+ function boneHops(b0, b1, limit = HOP_SEARCH) {
329
+ let hops = 0;
330
+ for (let node = b0; node && hops <= limit; node = node.parent, hops += 1) {
331
+ if (node === b1) return hops;
332
+ }
333
+ hops = 0;
334
+ for (let node = b1; node && hops <= limit; node = node.parent, hops += 1) {
335
+ if (node === b0) return hops;
336
+ }
337
+ return Infinity;
338
+ }
339
+
340
+ /** Capsules whose endpoints sit within HOP_LIMIT hops on the skeleton are
341
+ * NEIGHBOURS (upper arm against chest at the shoulder, thigh against the
342
+ * pelvis, forearm against upper arm at the elbow): they always overlap AT the
343
+ * joint they share, so they get the trimmed test below rather than the plain
344
+ * one. */
345
+ const HOP_LIMIT = 2;
346
+ function bonesRelated(b0, b1) {
347
+ return boneHops(b0, b1, HOP_LIMIT) <= HOP_LIMIT;
348
+ }
349
+
350
+ function capsulesAdjacent(ca, cb) {
351
+ return ca.bones.some((ba) => cb.bones.some((bb) => bonesRelated(ba, bb)));
352
+ }
353
+
354
+ /**
355
+ * HINGED PAIRS. A blanket "skip everything within HOP_LIMIT" made whole classes
356
+ * of real interpenetration invisible: a knee driven into the belly is
357
+ * thigh × torso, an arm folded past its own elbow is forearm × upper arm, and
358
+ * neither pair was ever tested. The skip existed for a good reason — the two
359
+ * capsules MEET at the joint they share, so an untrimmed test reports a
360
+ * permanent collision at every pose and pins the fixer forever — but that
361
+ * reason only covers the part of each capsule NEXT TO the joint.
362
+ *
363
+ * So the pair is tested on the portions FAR from the shared joint: TRIM_FRACTION
364
+ * of each segment is cut away from the joint side, and the segment-segment test
365
+ * runs on what is left. A normally bent elbow keeps its far portions well apart;
366
+ * a fully folded one lays them on top of each other, which is exactly the pose
367
+ * that has to be caught.
368
+ *
369
+ * The joint side is the endpoint CLOSER to the other capsule in the hierarchy
370
+ * (fewest ancestor hops), so the trim always eats the shared end and never the
371
+ * free one.
372
+ */
373
+ export const TRIM_FRACTION = 0.45;
374
+
375
+ /**
376
+ * Compression allowance for a pair that SHARES a bone — the two segments of one
377
+ * hinge (elbow, knee, ankle, wrist, waist). Flesh-to-flesh contact is the normal
378
+ * state of a deep fold there, and the capsule proxies overlap by construction
379
+ * long before the pose is wrong, so the thin-capsule slack rule is the wrong
380
+ * ruler: this one is a fraction of the COMBINED radii, and only a fold that puts
381
+ * the far end of one segment inside the body of the other clears it.
382
+ */
383
+ export const HINGE_SLACK_FACTOR = 0.65;
384
+
385
+ /** Which end of `ca` faces `cb` on the skeleton: true = the START endpoint. */
386
+ function jointSideIsStart(ca, cb) {
387
+ const hopsOf = (bone) => Math.min(...cb.bones.map((other) => boneHops(bone, other)));
388
+ const first = hopsOf(ca.bones[0]);
389
+ const last = ca.bones.length > 1 ? hopsOf(ca.bones[ca.bones.length - 1]) : first;
390
+ return first <= last;
391
+ }
392
+
393
+ /** `ca` with the joint-side TRIM_FRACTION cut off, written into out0/out1. */
394
+ function trimTowardJoint(ca, cb, out0, out1) {
395
+ if (jointSideIsStart(ca, cb)) {
396
+ out0.copy(ca.a).lerp(ca.b, TRIM_FRACTION);
397
+ out1.copy(ca.b);
398
+ } else {
399
+ out0.copy(ca.a);
400
+ out1.copy(ca.b).lerp(ca.a, TRIM_FRACTION);
401
+ }
402
+ }
403
+
404
+ /** How many bones the two capsules have in common (0, 1 or 2). */
405
+ function sharedBoneCount(ca, cb) {
406
+ return ca.bones.filter((bone) => cb.bones.includes(bone)).length;
407
+ }
408
+
409
+ /**
410
+ * Pairs that must never be tested, whatever the pose.
411
+ *
412
+ * Adjacency already covers most of the finger cases (a finger base is one hop
413
+ * from the hand, two from the forearm), but the trimmed test above means
414
+ * "adjacent" no longer means "skipped", so the finger rules are spelled out
415
+ * rather than inherited: a finger against its own hand, its own forearm, or any
416
+ * other finger is a pose the proxies cannot judge — fingers touch each other and
417
+ * lie against the palm constantly — while a finger against a thigh, a shin, the
418
+ * torso, the chest, the head or the opposite arm is exactly what the finger
419
+ * capsules were added to catch.
420
+ */
421
+ function pairExcluded(ca, cb) {
422
+ const fa = ca.def.handId;
423
+ const fb = cb.def.handId;
424
+ if (fa && fb) return true; // finger × finger, either hand
425
+ if (fa && (cb.def.id === fa || cb.def.id === ca.def.foreArmId)) return true;
426
+ if (fb && (ca.def.id === fb || ca.def.id === cb.def.foreArmId)) return true;
427
+ // SAME CHAIN, no shared joint: a hand against its own upper arm, a foot
428
+ // against its own thigh, a finger against its own forearm. Two things are
429
+ // true of every such pair and neither is true of a hinge: the pose is
430
+ // legitimate (a hand rests on its own shoulder, a heel touches its own
431
+ // thigh) and the fixer could not separate them anyway, because ONE solver
432
+ // drives both sides and each push undoes the other — measured as a fold
433
+ // that oscillated to maxIterations without converging. The fold that IS
434
+ // impossible — the segments either side of the elbow or the knee — shares a
435
+ // bone, and stays tested (trimmed) by the rule above.
436
+ const chainA = ca.def.movable?.chain;
437
+ const chainB = cb.def.movable?.chain;
438
+ if (chainA && chainA === chainB && sharedBoneCount(ca, cb) === 0) return true;
439
+ return false;
440
+ }
441
+
442
+ /** Scratch segments for the pair geometry below. One pair is measured at a time
443
+ * (detection is a plain double loop), so these can be module-level. */
444
+ const geoA0 = new THREE.Vector3();
445
+ const geoA1 = new THREE.Vector3();
446
+ const geoB0 = new THREE.Vector3();
447
+ const geoB1 = new THREE.Vector3();
448
+ const geometry = { a0: geoA0, a1: geoA1, b0: geoB0, b1: geoB1, hinge: false };
449
+
450
+ /**
451
+ * The segments to test for one capsule pair, or null when the pair is not
452
+ * testable at all. Unrelated capsules are tested whole; neighbours are tested
453
+ * on their trimmed portions (see HINGED PAIRS). The returned object is REUSED
454
+ * between calls — read it before the next call.
455
+ */
456
+ function pairGeometry(ca, cb) {
457
+ if (pairExcluded(ca, cb)) return null;
458
+ if (!capsulesAdjacent(ca, cb)) {
459
+ geoA0.copy(ca.a); geoA1.copy(ca.b);
460
+ geoB0.copy(cb.a); geoB1.copy(cb.b);
461
+ geometry.hinge = false;
462
+ return geometry;
463
+ }
464
+ // Same segment, or two capsules over the same two bones: there is no "far
465
+ // portion" to compare and nothing a fixer could do about it either way.
466
+ const shared = sharedBoneCount(ca, cb);
467
+ if (shared >= 2 || ca.bones.length === 0 || cb.bones.length === 0) return null;
468
+ // Two static blockers meeting at the spine (torso × chest, chest × head) are
469
+ // permanent by construction and unfixable by anything: no limb chain drives
470
+ // them, so reporting them would only add noise.
471
+ if (!ca.def.movable && !cb.def.movable) return null;
472
+ trimTowardJoint(ca, cb, geoA0, geoA1);
473
+ trimTowardJoint(cb, ca, geoB0, geoB1);
474
+ geometry.hinge = shared === 1;
475
+ return geometry;
476
+ }
477
+
478
+ /**
479
+ * EXTERNAL BLOCKERS. Everything above is the character against itself; a hand
480
+ * swinging through a chair is the same problem against the SET. Callers pass
481
+ * `blockers`: world-space static shapes, already posed for the frame being
482
+ * solved (the caller owns their animation — this owns the push).
483
+ *
484
+ * capsule: { id, kind: "capsule", a: Vector3, b: Vector3, radius }
485
+ * box: { id, kind: "box", center: Vector3, halfExtents: Vector3, yaw }
486
+ *
487
+ * `yaw` is radians about world +Y, the only rotation a prop standing on a floor
488
+ * normally needs and the only one the closest-point test below has to invert.
489
+ *
490
+ * They behave exactly like torso and head: never movable, never adjacent to
491
+ * anything, and never rest-calibrated — a character's rest pose says nothing
492
+ * about where the furniture is. The full slack rule applies, with one wrinkle: a
493
+ * box has no radius to take the min against, so the movable capsule's own slack
494
+ * is used (a box is a surface, not a soft body).
495
+ *
496
+ * A blocker's records carry its id VERBATIM, so the existing pair readouts
497
+ * ("leftHand×obj:chair") identify which prop was hit without any caller change.
498
+ * The id is the caller's to namespace — collision-blockers.js already ships
499
+ * "obj:cube" and "char:char-2:torso" — and a prefix added here would only double
500
+ * it up ("obj:obj:cube", as QA read out).
501
+ */
502
+ function blockerEntry(shape) {
503
+ if (!shape || typeof shape.id !== "string") return null;
504
+ const point = (value) => (value && Number.isFinite(value.x) && Number.isFinite(value.y) && Number.isFinite(value.z)
505
+ ? new THREE.Vector3(value.x, value.y, value.z)
506
+ : null);
507
+ const def = { id: shape.id, movable: null, blocker: true };
508
+ if (shape.kind === "capsule") {
509
+ const a = point(shape.a);
510
+ const b = point(shape.b);
511
+ if (!a || !b || !Number.isFinite(shape.radius) || shape.radius < 0) return null;
512
+ return { def, kind: "capsule", bones: [], a, b, radius: shape.radius };
513
+ }
514
+ if (shape.kind === "box") {
515
+ const center = point(shape.center);
516
+ const halfExtents = point(shape.halfExtents);
517
+ if (!center || !halfExtents) return null;
518
+ halfExtents.set(Math.abs(halfExtents.x), Math.abs(halfExtents.y), Math.abs(halfExtents.z));
519
+ return {
520
+ def,
521
+ kind: "box",
522
+ bones: [],
523
+ center,
524
+ halfExtents,
525
+ yaw: Number.isFinite(shape.yaw) ? shape.yaw : 0,
526
+ // The slack rule reads `radius`; a box surface has none.
527
+ radius: 0,
528
+ };
529
+ }
530
+ return null;
531
+ }
532
+
533
+ /** The capsule whose START is a chain's root joint (shoulder / hip) — the
534
+ * anchor a box escape aims back toward. */
535
+ const CHAIN_ROOT_CAPSULE = {
536
+ leftHand: "leftUpperArm",
537
+ rightHand: "rightUpperArm",
538
+ leftFoot: "leftThigh",
539
+ rightFoot: "rightThigh",
540
+ };
541
+
542
+ function normalizeBlockers(blockers) {
543
+ if (!Array.isArray(blockers) || blockers.length === 0) return [];
544
+ const out = [];
545
+ for (const shape of blockers) {
546
+ const entry = blockerEntry(shape);
547
+ if (entry) out.push(entry);
548
+ }
549
+ return out;
550
+ }
551
+
552
+ /**
553
+ * Signed distance from `point` to an oriented box: positive outside, NEGATIVE
554
+ * inside (the depth to the nearest face). Writes the closest SURFACE point into
555
+ * `outSurface` and the outward surface normal there into `outNormal`.
556
+ *
557
+ * The box is axis-aligned once the yaw is undone, so the whole thing is a clamp
558
+ * in box space plus a rotation back.
559
+ */
560
+ const boxLocal = new THREE.Vector3();
561
+ const boxClamped = new THREE.Vector3();
562
+ const boxPrefer = new THREE.Vector3();
563
+ function boxSignedDistance(box, point, outSurface, outNormal, prefer = null) {
564
+ const cos = Math.cos(box.yaw);
565
+ const sin = Math.sin(box.yaw);
566
+ const dx = point.x - box.center.x;
567
+ const dy = point.y - box.center.y;
568
+ const dz = point.z - box.center.z;
569
+ // world → box: rotate by −yaw about Y.
570
+ boxLocal.set(cos * dx - sin * dz, dy, sin * dx + cos * dz);
571
+ const h = box.halfExtents;
572
+ const outward = (local) => {
573
+ // box → world.
574
+ outSurface.set(cos * local.x + sin * local.z, local.y, -sin * local.x + cos * local.z).add(box.center);
575
+ };
576
+ const outsideX = Math.abs(boxLocal.x) - h.x;
577
+ const outsideY = Math.abs(boxLocal.y) - h.y;
578
+ const outsideZ = Math.abs(boxLocal.z) - h.z;
579
+ if (outsideX > 0 || outsideY > 0 || outsideZ > 0) {
580
+ boxClamped.set(
581
+ Math.max(-h.x, Math.min(h.x, boxLocal.x)),
582
+ Math.max(-h.y, Math.min(h.y, boxLocal.y)),
583
+ Math.max(-h.z, Math.min(h.z, boxLocal.z)),
584
+ );
585
+ const distance = boxLocal.distanceTo(boxClamped);
586
+ outward(boxClamped);
587
+ if (distance > 1e-9) {
588
+ const nx = (boxLocal.x - boxClamped.x) / distance;
589
+ const ny = (boxLocal.y - boxClamped.y) / distance;
590
+ const nz = (boxLocal.z - boxClamped.z) / distance;
591
+ outNormal.set(cos * nx + sin * nz, ny, -sin * nx + cos * nz).normalize();
592
+ } else {
593
+ // Exactly ON the surface: fall through to the face normal.
594
+ const gaps = [h.x - Math.abs(boxLocal.x), h.y - Math.abs(boxLocal.y), h.z - Math.abs(boxLocal.z)];
595
+ const axis = gaps.indexOf(Math.min(...gaps));
596
+ const sign = [boxLocal.x, boxLocal.y, boxLocal.z][axis] < 0 ? -1 : 1;
597
+ const local = [0, 0, 0];
598
+ local[axis] = sign;
599
+ outNormal.set(cos * local[0] + sin * local[2], local[1], -sin * local[0] + cos * local[2]).normalize();
600
+ }
601
+ return distance;
602
+ }
603
+ // Inside: THE EXIT FACE, which is not always the nearest one.
604
+ //
605
+ // A limb buried in a crate is nearest to whichever face it happened to sink
606
+ // past, and for a deep hit that is regularly the FAR one — a push out through
607
+ // it drags the whole arm through the box (QA measured 16 bones inside after a
608
+ // "fix"). So of the six faces the exit prefers one the limb's own ROOT is on:
609
+ // the shallowest whose outward normal points back toward the shoulder or hip.
610
+ // With no root hint — or with the root itself inside the box — the nearest
611
+ // face is still the answer, which is the plain signed distance.
612
+ //
613
+ // `prefer` therefore changes the DIRECTION and the depth of the escape, never
614
+ // the geometry: the plain nearest-face value is what the segment search below
615
+ // minimises (a max of linear functions, so convex, which is what makes the
616
+ // ternary search exact), and the preference is applied once, to the point that
617
+ // search returns.
618
+ const components = [boxLocal.x, boxLocal.y, boxLocal.z];
619
+ const extents = [h.x, h.y, h.z];
620
+ let bestAxis = 0;
621
+ let bestSign = 1;
622
+ let bestExit = Infinity;
623
+ let bestAligned = false;
624
+ for (let axis = 0; axis < 3; axis += 1) {
625
+ for (const sign of [-1, 1]) {
626
+ const exit = extents[axis] - sign * components[axis];
627
+ let aligned = false;
628
+ if (prefer) {
629
+ boxPrefer.set(
630
+ cos * (axis === 0 ? sign : 0) + sin * (axis === 2 ? sign : 0),
631
+ axis === 1 ? sign : 0,
632
+ -sin * (axis === 0 ? sign : 0) + cos * (axis === 2 ? sign : 0),
633
+ );
634
+ aligned = boxPrefer.dot(prefer) > 0;
635
+ }
636
+ // An aligned face beats an unaligned one outright; within a class the
637
+ // shallower exit wins.
638
+ if ((aligned && !bestAligned) || (aligned === bestAligned && exit < bestExit)) {
639
+ bestAxis = axis;
640
+ bestSign = sign;
641
+ bestExit = exit;
642
+ bestAligned = aligned;
643
+ }
644
+ }
645
+ }
646
+ boxClamped.copy(boxLocal);
647
+ if (bestAxis === 0) boxClamped.x = bestSign * h.x;
648
+ else if (bestAxis === 1) boxClamped.y = bestSign * h.y;
649
+ else boxClamped.z = bestSign * h.z;
650
+ outward(boxClamped);
651
+ outNormal.set(
652
+ cos * (bestAxis === 0 ? bestSign : 0) + sin * (bestAxis === 2 ? bestSign : 0),
653
+ bestAxis === 1 ? bestSign : 0,
654
+ -sin * (bestAxis === 0 ? bestSign : 0) + cos * (bestAxis === 2 ? bestSign : 0),
655
+ ).normalize();
656
+ return -bestExit;
657
+ }
658
+
659
+ /** How many ternary-search steps the segment/box solve takes. Each step keeps
660
+ * 2/3 of the interval, so 48 of them pin the parameter to ~1e-8 of the segment
661
+ * — far under the millimetre the depths are reported in. */
662
+ const BOX_SEARCH_STEPS = 48;
663
+
664
+ /**
665
+ * Closest approach between a segment and an oriented box. The distance from a
666
+ * point to a convex set is convex along a line, and the SIGNED distance of a
667
+ * convex set is convex too (which is what makes the inside case work), so a
668
+ * ternary search on the parameter finds the minimum without any case analysis of
669
+ * edges, faces and corners.
670
+ *
671
+ * Writes the segment point into `outA` and the box's surface point into `outB`,
672
+ * with the outward surface normal in `outNormal`, and returns the SIGNED
673
+ * distance (negative when the segment runs through the box).
674
+ */
675
+ const boxProbe = new THREE.Vector3();
676
+ const boxSurface = new THREE.Vector3();
677
+ const boxNormal = new THREE.Vector3();
678
+ function closestSegmentBox(p, q, box, outA, outB, outNormal, prefer = null) {
679
+ const at = (t) => {
680
+ boxProbe.copy(p).lerp(q, t);
681
+ return boxSignedDistance(box, boxProbe, boxSurface, boxNormal);
682
+ };
683
+ let lo = 0;
684
+ let hi = 1;
685
+ for (let step = 0; step < BOX_SEARCH_STEPS; step += 1) {
686
+ const third = (hi - lo) / 3;
687
+ const m1 = lo + third;
688
+ const m2 = hi - third;
689
+ if (at(m1) < at(m2)) hi = m2;
690
+ else lo = m1;
691
+ }
692
+ const t = (lo + hi) / 2;
693
+ let distance = at(t);
694
+ if (distance < 0 && prefer) {
695
+ // Deepest point found; now pick the face it leaves by.
696
+ boxProbe.copy(p).lerp(q, t);
697
+ distance = boxSignedDistance(box, boxProbe, boxSurface, boxNormal, prefer);
698
+ }
699
+ outA.copy(p).lerp(q, t);
700
+ outB.copy(boxSurface);
701
+ outNormal.copy(boxNormal);
702
+ return distance;
703
+ }
704
+
705
+ /**
706
+ * One capsule against one blocker. Returns { gap, normal, pointA, pointB } with
707
+ * `gap` the signed distance from the capsule's AXIS to the blocker's SURFACE and
708
+ * `normal` pointing from the blocker toward the capsule — the same b → a
709
+ * convention detectPenetrations uses everywhere else.
710
+ */
711
+ const blockNormal = new THREE.Vector3();
712
+ const blockPrefer = new THREE.Vector3();
713
+ function blockerContact(capsule, blocker, outA, outB, rootAnchor = null) {
714
+ if (blocker.kind === "box") {
715
+ // The way OUT of a box is the way the limb came in, and the limb's root is
716
+ // the only anchor that knows which way that was.
717
+ let prefer = null;
718
+ if (rootAnchor) {
719
+ blockPrefer.copy(rootAnchor).sub(capsule.a.clone().lerp(capsule.b, 0.5));
720
+ if (blockPrefer.lengthSq() > 1e-12) prefer = blockPrefer.normalize();
721
+ }
722
+ const gap = closestSegmentBox(capsule.a, capsule.b, blocker, outA, outB, blockNormal, prefer);
723
+ return { gap, normal: blockNormal.clone() };
724
+ }
725
+ closestPointsSegmentSegment(capsule.a, capsule.b, blocker.a, blocker.b, outA, outB);
726
+ const axisGap = outA.distanceTo(outB);
727
+ const normal = outA.clone().sub(outB);
728
+ if (normal.lengthSq() < 1e-12) {
729
+ normal.set(0, 0, 1).cross(capsule.b.clone().sub(capsule.a));
730
+ if (normal.lengthSq() < 1e-12) normal.set(0, 1, 0);
731
+ }
732
+ normal.normalize();
733
+ // Report the contact ON the blocker's surface, not on its axis.
734
+ outB.addScaledVector(normal, blocker.radius);
735
+ return { gap: axisGap - blocker.radius, normal };
736
+ }
737
+
738
+ /**
739
+ * Find every penetrating capsule pair in the current pose. Returns records
740
+ * { a, b, depth, normal, pointA, pointB } sorted deepest-first. `normal`
741
+ * points from b toward a. `offset` inflates the contact distance HERE, in
742
+ * the test itself — a pair counts as penetrating once it is closer than
743
+ * rA + rB + offset — so the same gap the fixer pushes out to is the gap it
744
+ * checks for. Honouring offset only in the push distance would make the
745
+ * fixer chase a target detection never asked for, and the very gap it just
746
+ * opened would read as clean at exactly the surface-grazing pose it exists
747
+ * to avoid.
748
+ *
749
+ * SLACK: capsules are rigid proxies for soft tissue — an upper arm resting
750
+ * against the torso, or thighs touching, legitimately overlaps the proxies.
751
+ * A pair only counts once it penetrates past `slackFactor × min(radius)`,
752
+ * the soft-tissue compression allowance; `depth` is the amount PAST that
753
+ * allowance, which is also the distance the fixer pushes.
754
+ *
755
+ * The default is 0.25, and it used to be 0.4. The looser value was doing two
756
+ * jobs: covering genuine soft-tissue compression AND covering the overlaps a
757
+ * character legitimately holds at rest, which the proxies cannot tell from a
758
+ * real hit. The rest/bind calibration below now does the second job precisely,
759
+ * per pair, so the global allowance no longer has to be blunt enough for the
760
+ * worst pair on the rig — and at 0.4 it hid a centimetre and a half of forearm
761
+ * inside a thigh. (Loosening it further was never an option either: at 0.6 a
762
+ * hand sunk most of the way into a thigh still fit inside the allowance and the
763
+ * tool answered "no collisions".)
764
+ *
765
+ * HINGED PAIRS use their own, wider allowance instead — see HINGE_SLACK_FACTOR —
766
+ * because two segments meeting at a joint are in flesh-to-flesh contact at any
767
+ * deep fold. BLOCKERS use the plain rule, with a box's missing radius standing
768
+ * in as the movable capsule's own.
769
+ *
770
+ * BIND CALIBRATION: on top of the global slack, a pair that already overlaps in
771
+ * the rig's bind pose gets that overlap plus PAIR_TOLERANCE as its own
772
+ * allowance (see restPairOverlaps), so a rest or bind pose can never report a
773
+ * collision. `pairAllowances` overrides the map buildCollisionCapsules attached;
774
+ * pass null to measure a pose against nothing but the slack rule.
775
+ *
776
+ * `blockers` adds the world-space static shapes described under EXTERNAL
777
+ * BLOCKERS; they are tested against every MOVABLE capsule and named `obj:<id>`
778
+ * in the records.
779
+ */
780
+ export function detectPenetrations(capsules, {
781
+ offset = 0.002,
782
+ slackFactor = DEFAULT_SLACK_FACTOR,
783
+ pairAllowances,
784
+ blockers = null,
785
+ } = {}) {
786
+ if (!capsules) return [];
787
+ const calibration = pairAllowances === undefined ? (capsules.pairAllowances ?? null) : pairAllowances;
788
+ const list = [...capsules.values()];
789
+ const out = [];
790
+ const pa = new THREE.Vector3();
791
+ const pb = new THREE.Vector3();
792
+ for (let i = 0; i < list.length; i += 1) {
793
+ for (let j = i + 1; j < list.length; j += 1) {
794
+ const ca = list[i];
795
+ const cb = list[j];
796
+ const geo = pairGeometry(ca, cb);
797
+ if (!geo) continue;
798
+ const hinge = geo.hinge;
799
+ closestPointsSegmentSegment(geo.a0, geo.a1, geo.b0, geo.b1, pa, pb);
800
+ const overlap = ca.radius + cb.radius + offset - pa.distanceTo(pb);
801
+ const slack = hinge
802
+ ? HINGE_SLACK_FACTOR * (ca.radius + cb.radius)
803
+ : slackFactor * Math.min(ca.radius, cb.radius);
804
+ const bindOverlap = calibration?.get(pairKey(ca.def.id, cb.def.id)) ?? 0;
805
+ const allowance = Math.max(slack, bindOverlap > 0 ? bindOverlap + PAIR_TOLERANCE : 0);
806
+ const depth = overlap - allowance;
807
+ // MIN_DEPTH, not epsilon: see the constant. A pair under it is not a
808
+ // contact anyone can see and not one worth moving a limb for.
809
+ if (depth < MIN_DEPTH) continue;
810
+ const normal = pa.clone().sub(pb);
811
+ if (normal.lengthSq() < 1e-12) {
812
+ // Coincident axes: any perpendicular direction separates them.
813
+ normal.set(0, 0, 1).cross(ca.b.clone().sub(ca.a));
814
+ if (normal.lengthSq() < 1e-12) normal.set(0, 1, 0);
815
+ }
816
+ normal.normalize();
817
+ out.push({ a: ca, b: cb, depth, normal, pointA: pa.clone(), pointB: pb.clone() });
818
+ }
819
+ }
820
+ // External blockers: only MOVABLE capsules are worth testing — a blocker
821
+ // against the torso is two immovable things touching, which no pass can fix.
822
+ const shapes = normalizeBlockers(blockers);
823
+ for (const blocker of shapes) {
824
+ for (const capsule of list) {
825
+ if (!capsule.def.movable) continue;
826
+ const rootId = CHAIN_ROOT_CAPSULE[capsule.def.movable.chain];
827
+ const rootAnchor = rootId ? capsules.get(rootId)?.a ?? null : null;
828
+ const contact = blockerContact(capsule, blocker, pa, pb, rootAnchor);
829
+ const overlap = capsule.radius + offset - contact.gap;
830
+ // A box has no radius to take the min against, so the limb's own
831
+ // slack is the whole allowance. No rest calibration: the character's
832
+ // reference poses say nothing about where the set dressing is.
833
+ const slack = slackFactor * (blocker.kind === "box"
834
+ ? capsule.radius
835
+ : Math.min(capsule.radius, blocker.radius));
836
+ const depth = overlap - slack;
837
+ if (depth < MIN_DEPTH) continue;
838
+ out.push({
839
+ a: capsule,
840
+ b: blocker,
841
+ depth,
842
+ normal: contact.normal.clone(),
843
+ pointA: pa.clone(),
844
+ pointB: pb.clone(),
845
+ });
846
+ }
847
+ }
848
+ out.sort((x, y) => y.depth - x.depth);
849
+ return out;
850
+ }