cozyclay 1.9.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/bin/agent/agent-routes.mjs +74 -70
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/cozyclay.mjs +26 -8
  10. package/bin/live/cli.mjs +22 -3
  11. package/bin/mcp-runtime.mjs +35 -20
  12. package/bin/telemetry-state.mjs +5 -2
  13. package/bin/update-check.mjs +15 -0
  14. package/dist/app/index.html +6 -6
  15. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  16. package/dist/assets/app-C1sdVbg4.css +1 -0
  17. package/dist/assets/app-CA55tcI3.js +4876 -0
  18. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  19. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  20. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  21. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  22. package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
  23. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  24. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  25. package/dist/assets/workflow-dNPGY9xz.js +179 -0
  26. package/dist/cozyclay-package.json +1 -1
  27. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  28. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  29. package/dist/fonts/README.md +13 -9
  30. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  31. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  33. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  35. package/dist/index.html +7 -7
  36. package/dist/privacy/index.html +13 -8
  37. package/dist/sitemap.xml +6 -6
  38. package/dist/workflow/index.html +5 -5
  39. package/mcp/LIVE-PROTOCOL.md +1 -1
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +224 -377
  44. package/package.json +2 -1
  45. package/src/App.jsx +1273 -9075
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/collision-blockers.js +9 -3
  51. package/src/ardy/fix-collisions.js +10 -66
  52. package/src/ardy/ground.js +4 -4
  53. package/src/ardy/ik-drag.js +101 -0
  54. package/src/ardy/ik-key-json.js +43 -0
  55. package/src/ardy/ik.js +122 -76
  56. package/src/ardy/physics-panel.css +2 -0
  57. package/src/ardy/physics-panel.jsx +17 -5
  58. package/src/ardy/platform-fit-panel.css +64 -0
  59. package/src/ardy/platform-fit-panel.jsx +40 -0
  60. package/src/ardy/platform-fit.js +326 -0
  61. package/src/ardy/playback.js +166 -20
  62. package/src/ardy/range-pin.js +299 -0
  63. package/src/ardy/timeline-coordinates.js +26 -0
  64. package/src/ardy/timeline.css +1199 -0
  65. package/src/ardy/timeline.jsx +149 -41
  66. package/src/ardy/waypoints.js +2 -2
  67. package/src/asset-pane.css +462 -0
  68. package/src/asset-pane.jsx +461 -232
  69. package/src/command-bus.js +349 -0
  70. package/src/commands/ai.js +31 -0
  71. package/src/commands/cast.js +140 -0
  72. package/src/commands/elements/character.js +13 -0
  73. package/src/commands/elements/motion.js +14 -0
  74. package/src/commands/elements/object.js +22 -0
  75. package/src/commands/elements/scene.js +16 -0
  76. package/src/commands/elements/shot.js +9 -0
  77. package/src/commands/elements/stage.js +11 -0
  78. package/src/commands/elements.js +155 -0
  79. package/src/commands/export.js +20 -0
  80. package/src/commands/index.js +40 -0
  81. package/src/commands/motion.js +231 -0
  82. package/src/commands/objects.js +174 -0
  83. package/src/commands/project.js +53 -0
  84. package/src/commands/scene.js +81 -0
  85. package/src/commands/shared.js +19 -0
  86. package/src/commands/shot.js +103 -0
  87. package/src/commands/stage.js +17 -0
  88. package/src/commands/view.js +42 -0
  89. package/src/document-store.js +144 -0
  90. package/src/domains/cast.js +1007 -0
  91. package/src/domains/motion.js +3219 -0
  92. package/src/domains/objects.js +932 -0
  93. package/src/domains/scenes.js +817 -0
  94. package/src/domains/shots.js +404 -0
  95. package/src/domains/stage.js +93 -0
  96. package/src/dualview.jsx +113 -57
  97. package/src/facing-marks.js +3 -0
  98. package/src/first-success-guide.jsx +14 -14
  99. package/src/grid-view.js +18 -5
  100. package/src/hierarchy-model.js +24 -16
  101. package/src/hierarchy-panel.css +386 -0
  102. package/src/hierarchy-panel.jsx +150 -21
  103. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  104. package/src/i2v-motion-studio.jsx +180 -0
  105. package/src/ik-camera.js +3 -0
  106. package/src/main.jsx +6 -0
  107. package/src/motion/generation.js +105 -0
  108. package/src/motion-readiness-ui.jsx +4 -4
  109. package/src/motion-readiness.js +11 -0
  110. package/src/motion-trail.js +366 -9
  111. package/src/object-gizmo.jsx +7 -1
  112. package/src/otio.js +2 -1
  113. package/src/panels/CameraPanel.jsx +60 -0
  114. package/src/panels/CharacterTransformPanel.jsx +48 -0
  115. package/src/panels/EnvironmentPanel.jsx +40 -0
  116. package/src/panels/Foldout.jsx +32 -0
  117. package/src/panels/LightPanel.jsx +24 -0
  118. package/src/panels/ObjectTransformPanel.jsx +554 -0
  119. package/src/panels/PosePanel.jsx +97 -0
  120. package/src/panels/ProjectPanel.jsx +33 -0
  121. package/src/panels/PromptBlocksPanel.jsx +442 -0
  122. package/src/panels/PropsPanel.jsx +70 -0
  123. package/src/panels/ReferenceImageField.jsx +91 -0
  124. package/src/panels/RigControlPanel.jsx +78 -0
  125. package/src/panels/RigPanel.jsx +37 -0
  126. package/src/panels/SubjectBox.jsx +47 -0
  127. package/src/panels/SubjectsPanel.jsx +39 -0
  128. package/src/panels/VideoCapturePanel.jsx +180 -0
  129. package/src/panels/details.css +1171 -0
  130. package/src/panels/motion.css +197 -0
  131. package/src/panels/pose.css +39 -0
  132. package/src/planview.jsx +60 -31
  133. package/src/posestudio.jsx +59 -4
  134. package/src/project-browser.css +1073 -0
  135. package/src/project-browser.jsx +284 -123
  136. package/src/range-pin-object-transform.js +19 -0
  137. package/src/range-pin-panel.css +518 -0
  138. package/src/range-pin-panel.jsx +344 -0
  139. package/src/result-modal.jsx +5 -5
  140. package/src/room.jsx +79 -51
  141. package/src/scene-objects.js +37 -1
  142. package/src/scenes.js +5 -0
  143. package/src/semantic-edit.js +2 -0
  144. package/src/settings-menu.jsx +39 -105
  145. package/src/shell/BottomDock.jsx +450 -0
  146. package/src/shell/DetailsSlot.jsx +513 -0
  147. package/src/shell/LibrarySlot.jsx +86 -0
  148. package/src/shell/MenuBar.jsx +601 -0
  149. package/src/shell/OutlinerSlot.jsx +50 -0
  150. package/src/shell/PreferencesDialog.jsx +538 -0
  151. package/src/shell/PreferencesSlot.jsx +40 -0
  152. package/src/shell/StatusBar.jsx +125 -0
  153. package/src/shell/StudioShell.jsx +92 -0
  154. package/src/shell/TopBar.jsx +129 -0
  155. package/src/shell/ViewportToolbar.jsx +656 -0
  156. package/src/shell/agent-glass.css +296 -0
  157. package/src/shell/dock.css +93 -0
  158. package/src/shell/glass-regions.css +985 -0
  159. package/src/shell/glass.css +687 -0
  160. package/src/shell/log-store.js +52 -0
  161. package/src/shell/mode.css +9 -0
  162. package/src/shell/preferences.css +651 -0
  163. package/src/shell/shell.css +282 -0
  164. package/src/shell/studio-shell-context.js +19 -0
  165. package/src/shell/topbar.css +435 -0
  166. package/src/shell/viewport.css +451 -0
  167. package/src/store/authored-intent.js +22 -0
  168. package/src/store/runtime-adapters.js +92 -0
  169. package/src/store/scene-stage.js +21 -0
  170. package/src/store/use-document-store.js +14 -0
  171. package/src/studio-actions.js +210 -0
  172. package/src/studio-agent-commands.js +41 -271
  173. package/src/studio-agent-context.js +39 -10
  174. package/src/studio-agent-motion.js +122 -373
  175. package/src/studio-agent-protocol.js +67 -31
  176. package/src/studio-app-binding.js +379 -0
  177. package/src/studio-contact-sheet.js +73 -0
  178. package/src/studio-elements.js +58 -51
  179. package/src/styles/themes.css +200 -0
  180. package/src/styles/tokens.css +69 -0
  181. package/src/styles.css +460 -1742
  182. package/src/theme.js +44 -0
  183. package/src/timeline-extent.js +16 -0
  184. package/src/trail-key-conflicts.js +39 -0
  185. package/src/trail-pick.js +59 -0
  186. package/src/ui.jsx +8 -8
  187. package/src/use-case-question.jsx +66 -0
  188. package/src/workflow/AgentPanel.jsx +82 -69
  189. package/src/workflow/agent-client.js +12 -2
  190. package/src/workflow/agent-panel.css +34 -32
  191. package/src/workflow/cozy-scene-node.css +2 -0
  192. package/src/workflow/workflow.css +2 -0
  193. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  194. package/tools/ardy/bridge.mjs +164 -146
  195. package/tools/ardy/visual-qa.mjs +5 -5
  196. package/tools/bench/EXP3.md +61 -0
  197. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  198. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  199. package/tools/bench/cclay_bench_runner.py +42 -0
  200. package/tools/bench/cube-contact.mjs +162 -0
  201. package/tools/bench/exp3.mjs +152 -0
  202. package/tools/bench/extract-bench-lib.mjs +216 -0
  203. package/tools/bench/extract-bench.mjs +302 -0
  204. package/tools/bench/fal-generate.mjs +88 -0
  205. package/tools/bench/fit/README.md +189 -0
  206. package/tools/bench/fit/camera.mjs +59 -0
  207. package/tools/bench/fit/contact.mjs +419 -0
  208. package/tools/bench/fit/footlock.mjs +234 -0
  209. package/tools/bench/fit/motion.mjs +85 -0
  210. package/tools/bench/fit/pin.mjs +27 -0
  211. package/tools/bench/fit/remote.mjs +105 -0
  212. package/tools/bench/fit-bench.mjs +126 -0
  213. package/tools/bench/fit-sanity.mjs +45 -0
  214. package/tools/bench/metrics.mjs +313 -0
  215. package/tools/bench/obs/depth.mjs +258 -0
  216. package/tools/bench/obs/extrinsics.mjs +175 -0
  217. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  218. package/tools/bench/obs/ground.mjs +294 -0
  219. package/tools/bench/obs/heading.mjs +151 -0
  220. package/tools/bench/obs/ladder.mjs +555 -0
  221. package/tools/bench/obs/mannequin-betas.json +124 -0
  222. package/tools/bench/obs/remote.mjs +83 -0
  223. package/tools/bench/obs/rest_joints.py +52 -0
  224. package/tools/bench/obs/ybot-targets.mjs +49 -0
  225. package/tools/bench/obs-bench.mjs +520 -0
  226. package/tools/bench/score.mjs +343 -0
  227. package/tools/bench/summarize.mjs +86 -0
  228. package/tools/dev/pages/privacy.html +13 -8
  229. package/tools/gt-render/browser.mjs +159 -0
  230. package/tools/gt-render/camera-math.mjs +276 -0
  231. package/tools/gt-render/page.mjs +267 -0
  232. package/tools/gt-render/render.mjs +514 -0
  233. package/tools/gt-render/scene-box.mjs +31 -0
  234. package/tools/gt-render/take-transform.mjs +87 -0
  235. package/tools/morphgs/assets/gen_truth.py +39 -0
  236. package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
  237. package/tools/morphgs/assets/playback-check.mjs +30 -0
  238. package/tools/morphgs/demo-gate.sh +26 -0
  239. package/tools/morphgs/fbx2morphgs.mjs +68 -0
  240. package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
  241. package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
  242. package/tools/morphgs/setup-on-cluster.sh +126 -0
  243. package/tools/qa/css-rule-usage.mjs +283 -0
  244. package/tools/qa/studio-control-count.mjs +70 -32
  245. package/tools/run-tests.mjs +134 -12
  246. package/tools/track/DESIGN.md +513 -0
  247. package/tools/track/backfill-provenance.mjs +116 -0
  248. package/tools/track/budget.mjs +8 -0
  249. package/tools/track/check-rig.mjs +209 -0
  250. package/tools/track/diagnostics.schema.json +60 -0
  251. package/tools/track/export-rig.mjs +256 -0
  252. package/tools/track/fallback.mjs +7 -0
  253. package/tools/track/fk-parity-fixture.mjs +161 -0
  254. package/tools/track/gate.mjs +349 -0
  255. package/tools/track/masks.mjs +318 -0
  256. package/tools/track/metrics.mjs +201 -0
  257. package/tools/track/publish-obs.mjs +108 -0
  258. package/tools/track/py/check_env.py +61 -0
  259. package/tools/track/py/eval_lr.py +229 -0
  260. package/tools/track/py/lr_viterbi.py +294 -0
  261. package/tools/track/py/masks.py +405 -0
  262. package/tools/track/py/objective.py +382 -0
  263. package/tools/track/py/rig.py +508 -0
  264. package/tools/track/py/scene.py +326 -0
  265. package/tools/track/py/test_joint_indices.py +123 -0
  266. package/tools/track/py/test_lr_viterbi.py +195 -0
  267. package/tools/track/py/test_masks.py +229 -0
  268. package/tools/track/py/test_rig.py +288 -0
  269. package/tools/track/py/test_scene.py +354 -0
  270. package/tools/track/py/test_track.py +328 -0
  271. package/tools/track/py/track.py +411 -0
  272. package/tools/track/remote.mjs +140 -0
  273. package/tools/track/rig-dump.mjs +227 -0
  274. package/tools/track/run-box-tests.mjs +14 -0
  275. package/tools/track/run-box.mjs +168 -0
  276. package/tools/track/setup-box.sh +59 -0
  277. package/tools/track/study-2d.mjs +728 -0
  278. package/dist/assets/analytics-B1hnH66c.js +0 -1
  279. package/dist/assets/app-BWusbqgO.js +0 -4861
  280. package/dist/assets/app-qKDo4PBX.css +0 -1
  281. package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
  282. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  283. package/dist/assets/workflow-CVwzMhz2.js +0 -179
  284. package/dist/assets/workflow-DsudxKHF.css +0 -1
  285. package/src/fal-motion-studio.jsx +0 -180
  286. package/src/scene-history.js +0 -129
@@ -0,0 +1,299 @@
1
+ import * as THREE from "three";
2
+ import { createIkState, ikBakeKeyframe, solveHipsTranslateToFloor, solveIk } from "./ik.js";
3
+
4
+ /**
5
+ * Range pins: hold one limb's effector on a target for every frame of an
6
+ * inclusive range, over the motion that is already there.
7
+ *
8
+ * A pin is data, the keys are its bake. Every frame of the range gets a DELTA
9
+ * IK key on the pinned chain (baseQ = the raw clip rotations of that frame, the
10
+ * same shape fix-collisions and ik-drag write) tagged `pin: pin.id`, with
11
+ * `blend: pin.blend`. The contiguous keys are one island, so ikEvaluate holds
12
+ * them at full weight inside the range and eases out over pin.blend frames.
13
+ *
14
+ * Body reach also bakes a translation-only hips delta and re-planted leg
15
+ * deltas. All participating tracks share the pin's ownership and blend.
16
+ *
17
+ * pin = { id, track, startFrame, endFrame (inclusive), blend, reach?,
18
+ * // reach: "body" for new pins; absent/"limb" preserves legacy bakes
19
+ * target: { space: "world", position: [x,y,z] }
20
+ * | { space: "object", objectId, local: [x,y,z] } }
21
+ */
22
+
23
+ export const RANGE_PIN_TRACKS = Object.freeze(["leftHand", "rightHand", "leftFoot", "rightFoot"]);
24
+ const LEGS = ["leftFoot", "rightFoot"];
25
+ const REACH_MARGIN = 0.005;
26
+ const BODY_STEP = 0.01;
27
+ const BODY_VERTICAL = 0.1;
28
+
29
+ /** Tracks a pin may own. Body pins share the hips/legs, so overlapping body
30
+ * pins cannot independently author them in this single-key-per-track layer. */
31
+ export function rangePinTracks(pin) {
32
+ return pin.reach === "body" ? [...new Set([pin.track, "hips", ...LEGS])] : [pin.track];
33
+ }
34
+ /** Same bounds character.setIkKey accepts for a key's blend and pin id. */
35
+ const BLEND_MAX = 240;
36
+ const ID_MAX = 64;
37
+
38
+ export class RangePinError extends Error {
39
+ constructor(code, message) {
40
+ super(message);
41
+ this.name = "RangePinError";
42
+ this.code = code;
43
+ }
44
+ }
45
+
46
+ const isInt = (value) => Number.isInteger(value);
47
+ const isVec3 = (value) => Array.isArray(value) && value.length === 3 && value.every((n) => typeof n === "number" && Number.isFinite(n));
48
+
49
+ /** Validated copy of a pin, or a RangePinError naming what is wrong.
50
+ * `clipFrames` (frame count) bounds endFrame when given. */
51
+ export function normalizeRangePin(pin, { clipFrames = null } = {}) {
52
+ if (!pin || typeof pin !== "object") throw new RangePinError("BAD_PIN", "A pin must be an object.");
53
+ const { id, track, startFrame, endFrame, blend, target, reach } = pin;
54
+ if (reach !== undefined && reach !== "limb" && reach !== "body") throw new RangePinError("BAD_PIN", 'reach must be "limb" or "body".');
55
+ if (typeof id !== "string" || !id.length || id.length > ID_MAX) throw new RangePinError("BAD_PIN", `Pin id must be a string of 1-${ID_MAX} characters.`);
56
+ if (!RANGE_PIN_TRACKS.includes(track)) throw new RangePinError("UNKNOWN_TRACK", `Unknown pin track "${track}" (expected ${RANGE_PIN_TRACKS.join(", ")}).`);
57
+ if (!isInt(startFrame) || !isInt(endFrame)) throw new RangePinError("BAD_RANGE", "startFrame and endFrame must be integers.");
58
+ if (startFrame > endFrame) throw new RangePinError("BAD_RANGE", `startFrame ${startFrame} is after endFrame ${endFrame}.`);
59
+ if (startFrame < 0) throw new RangePinError("OUT_OF_RANGE", `startFrame ${startFrame} is before frame 0.`);
60
+ if (clipFrames != null && endFrame > clipFrames - 1) throw new RangePinError("OUT_OF_RANGE", `endFrame ${endFrame} is past the last frame ${clipFrames - 1}.`);
61
+ if (!isInt(blend) || blend < 1 || blend > BLEND_MAX) throw new RangePinError("BAD_PIN", `blend must be an integer of 1-${BLEND_MAX} frames.`);
62
+ if (!target || typeof target !== "object") throw new RangePinError("BAD_TARGET", "A pin needs a target.");
63
+ let copy;
64
+ if (target.space === "world") {
65
+ if (!isVec3(target.position)) throw new RangePinError("BAD_TARGET", "A world target needs position [x, y, z].");
66
+ copy = { space: "world", position: [...target.position] };
67
+ } else if (target.space === "object") {
68
+ if (typeof target.objectId !== "string" || !target.objectId) throw new RangePinError("BAD_TARGET", "An object target needs objectId.");
69
+ if (!isVec3(target.local)) throw new RangePinError("BAD_TARGET", "An object target needs local [x, y, z].");
70
+ copy = { space: "object", objectId: target.objectId, local: [...target.local] };
71
+ } else {
72
+ throw new RangePinError("BAD_TARGET", `Unknown target space "${target.space}".`);
73
+ }
74
+ return { id, track, startFrame, endFrame, blend, target: copy, ...(reach === undefined ? {} : { reach }) };
75
+ }
76
+
77
+ /** The object's world matrix at `frame`, or a typed error when there is none. */
78
+ function objectMatrix(objectWorldMatrix, objectId, frame) {
79
+ const matrix = typeof objectWorldMatrix === "function" ? objectWorldMatrix(objectId, frame) : null;
80
+ if (!matrix?.elements || matrix.elements.length !== 16 || !matrix.elements.every(Number.isFinite)) {
81
+ throw new RangePinError("OBJECT_UNAVAILABLE", `No world transform for object "${objectId}" at frame ${frame}.`);
82
+ }
83
+ return matrix;
84
+ }
85
+
86
+ /** World-space target of `pin` at `frame`. Object targets ride the object's
87
+ * full world transform at that frame (translation, rotation, scale). */
88
+ export function rangePinTargetWorld(pin, frame, { objectWorldMatrix = null } = {}) {
89
+ const { target } = pin;
90
+ if (target.space === "world") return new THREE.Vector3().fromArray(target.position);
91
+ return new THREE.Vector3().fromArray(target.local).applyMatrix4(objectMatrix(objectWorldMatrix, target.objectId, frame));
92
+ }
93
+
94
+ /** Snapshot every node's local transform under `rig`; returns the restore. */
95
+ function holdRig(rig) {
96
+ const saved = [];
97
+ rig.traverse((node) => saved.push([node, node.position.clone(), node.quaternion.clone(), node.scale.clone()]));
98
+ return () => {
99
+ for (const [node, position, quaternion, scale] of saved) {
100
+ node.position.copy(position);
101
+ node.quaternion.copy(quaternion);
102
+ node.scale.copy(scale);
103
+ }
104
+ rig.updateMatrixWorld(true);
105
+ };
106
+ }
107
+
108
+ function chainFor(chains, track) {
109
+ const chain = chains?.get(track);
110
+ if (!chain) throw new RangePinError("UNKNOWN_TRACK", `The rig has no "${track}" chain.`);
111
+ return chain;
112
+ }
113
+
114
+ /** The pin target that keeps `track`'s effector where it is at `frame`, as
115
+ * posed by `applyFrame(frame)`: a world position, or the same point in the
116
+ * object's local space. The rig is left as it was found. */
117
+ export function captureRangePinTarget({ chains, track, frame, applyFrame, space = "world", objectId = null, objectWorldMatrix = null }) {
118
+ const chain = chainFor(chains, track);
119
+ const restore = holdRig(chain.rig);
120
+ let world;
121
+ try {
122
+ applyFrame(frame);
123
+ world = chain.bones[2].getWorldPosition(new THREE.Vector3());
124
+ } finally {
125
+ restore();
126
+ }
127
+ if (space === "world") return { space: "world", position: world.toArray() };
128
+ if (space !== "object") throw new RangePinError("BAD_TARGET", `Unknown target space "${space}".`);
129
+ const inverse = objectMatrix(objectWorldMatrix, objectId, frame).clone().invert();
130
+ return { space: "object", objectId, local: world.applyMatrix4(inverse).toArray() };
131
+ }
132
+
133
+ /** Pull every key tagged `pinId` out of the layer: [[frame, track, key]]. */
134
+ function takePinKeys(ikState, pinId) {
135
+ const taken = [];
136
+ for (const [frame, entry] of [...ikState.keys]) {
137
+ for (const [track, key] of [...entry]) {
138
+ if (key?.pin !== pinId) continue;
139
+ taken.push([frame, track, key]);
140
+ entry.delete(track);
141
+ }
142
+ if (!entry.size) ikState.keys.delete(frame);
143
+ }
144
+ return taken;
145
+ }
146
+
147
+ function putPinKeys(ikState, taken) {
148
+ for (const [frame, track, key] of taken) {
149
+ let entry = ikState.keys.get(frame);
150
+ if (!entry) ikState.keys.set(frame, (entry = new Map()));
151
+ entry.set(track, key);
152
+ }
153
+ }
154
+
155
+ /** Remove only the track entries tagged with `pinId` (a frame left empty goes
156
+ * too). Returns how many were removed. */
157
+ export function removeRangePinKeys(ikState, pinId) {
158
+ return takePinKeys(ikState, pinId).length;
159
+ }
160
+
161
+ function reachBall(chain, target, margin) {
162
+ const [root, mid, end] = chain.bones.map((bone) => bone.getWorldPosition(new THREE.Vector3()));
163
+ return { center: target.clone().sub(root), radius: Math.max(1e-6, root.distanceTo(mid) + mid.distanceTo(end) - margin) };
164
+ }
165
+
166
+ /** Weighted projection onto a reach sphere in world-offset space. Moving Y
167
+ * costs 16x as much as X/Z: prefer a lean, but allow a small crouch to keep
168
+ * straight legs planted. The multiplier has one monotone scalar root. */
169
+ function projectReach(offset, { center, radius }) {
170
+ const delta = offset.clone().sub(center);
171
+ if (delta.length() <= radius) return;
172
+ let low = 0, high = 16 * delta.length() / radius;
173
+ for (let i = 0; i < 32; i++) {
174
+ const t = (low + high) / 2;
175
+ const d = Math.hypot(delta.x / (1 + t), delta.y / (1 + t / 16), delta.z / (1 + t));
176
+ if (d > radius) low = t; else high = t;
177
+ }
178
+ offset.copy(center).add(new THREE.Vector3(delta.x / (1 + high), delta.y / (1 + high / 16), delta.z / (1 + high)));
179
+ }
180
+
181
+ /** Look ahead over the whole range: a causal filter cannot both follow a
182
+ * 5 cm/frame walk and introduce at most 1 cm/frame of extra hips motion.
183
+ * Alternating convex projections satisfy limb reach, both leg reaches, a
184
+ * floor-safe +/-10 cm vertical band, and adjacent-offset speed balls. This
185
+ * anticipates an upcoming shortfall rather than snapping on its first frame.
186
+ * Infeasible ranges remain best effort, bounded and honestly residualized. */
187
+ function bodyOffsets({ chains, joint, pin, applyLayer, objectWorldMatrix }) {
188
+ const samples = [];
189
+ let needsAssist = false;
190
+ let minY = -BODY_VERTICAL;
191
+ for (let frame = pin.startFrame; frame <= pin.endFrame; frame++) {
192
+ const target = rangePinTargetWorld(pin, frame, { objectWorldMatrix });
193
+ applyLayer(frame);
194
+ const limb = reachBall(chains.get(pin.track), target, REACH_MARGIN);
195
+ needsAssist ||= limb.center.length() > limb.radius;
196
+ const balls = [limb];
197
+ for (const id of LEGS) {
198
+ if (id === pin.track) continue; // the pinned foot goes to its target, not its old plant
199
+ const leg = chains.get(id);
200
+ balls.push(reachBall(leg, leg.bones[2].getWorldPosition(new THREE.Vector3()), 1e-5));
201
+ }
202
+ const y = joint.bone.getWorldPosition(new THREE.Vector3()).y;
203
+ minY = Math.max(minY, (chains.get(pin.track).contactHeights?.Hips ?? 0.01) - y);
204
+ samples.push({ balls, offset: new THREE.Vector3() });
205
+ }
206
+ if (!needsAssist) return null;
207
+ const maxY = Math.max(minY, BODY_VERTICAL); // floor safety wins even on an already sunk clip
208
+ const clampY = (offset) => { offset.y = Math.max(minY, Math.min(maxY, offset.y)); };
209
+ const origin = new THREE.Vector3();
210
+ for (let pass = 0; pass < 256; pass++) {
211
+ let change = 0;
212
+ for (const { balls, offset } of samples) {
213
+ const before = offset.clone();
214
+ for (const ball of balls) projectReach(offset, ball);
215
+ clampY(offset);
216
+ change = Math.max(change, before.distanceTo(offset));
217
+ }
218
+ for (let i = samples.length - 1; i > 0; i--) {
219
+ const a = samples[i - 1].offset, b = samples[i].offset;
220
+ const delta = b.clone().sub(a);
221
+ const limited = delta.clone();
222
+ projectReach(limited, { center: origin, radius: BODY_STEP });
223
+ const correction = delta.sub(limited).multiplyScalar(0.5);
224
+ a.add(correction); b.sub(correction);
225
+ change = Math.max(change, correction.length());
226
+ }
227
+ if (change < 1e-7) break;
228
+ }
229
+ // Guarantee speed/floor limits even when the reach constraints conflict.
230
+ // Convex interpolation within the common vertical band stays floor safe.
231
+ for (let i = 0; i < samples.length; i++) {
232
+ const offset = samples[i].offset;
233
+ clampY(offset);
234
+ if (i > 0) {
235
+ const prev = samples[i - 1].offset;
236
+ const delta = offset.clone().sub(prev).clampLength(0, BODY_STEP);
237
+ offset.copy(prev).add(delta);
238
+ }
239
+ }
240
+ return samples.map(({ offset }) => offset);
241
+ }
242
+
243
+ /**
244
+ * Solve `pin` over the existing motion and return its keys without writing
245
+ * them: { entries: Map(frame → Map(track → key)), residuals: [{frame, errorM}] }.
246
+ *
247
+ * Per frame: `applyRaw(frame)` poses the clip alone and its chain rotations
248
+ * become the key's baseQ; `applyLayer(frame)` poses clip + the current IK
249
+ * layer, which is what the limb is solved over. This pin's own keys are held
250
+ * out of `ikState` while the layer is posed, so a re-apply solves against the
251
+ * same layer as the first apply did. residual is the effector's distance from
252
+ * the target after the solve (nonzero only when the target is out of reach).
253
+ * The rig and the layer are left as they were found.
254
+ */
255
+ export function applyRangePin({ chains, fkJoints = null, ikState, pin, applyRaw, applyLayer, objectWorldMatrix = null }) {
256
+ const chain = chainFor(chains, pin.track);
257
+ const entries = new Map();
258
+ const residuals = [];
259
+ const own = takePinKeys(ikState, pin.id);
260
+ const restore = holdRig(chain.rig);
261
+ const effector = new THREE.Vector3();
262
+ try {
263
+ const joint = fkJoints?.get("hips");
264
+ if (pin.reach === "body" && !joint) throw new RangePinError("UNKNOWN_TRACK", "Body reach needs a hips joint.");
265
+ const offsets = pin.reach === "body" ? bodyOffsets({ chains, joint, pin, applyLayer, objectWorldMatrix }) : null;
266
+ const ids = offsets ? rangePinTracks(pin) : [pin.track];
267
+ for (let frame = pin.startFrame; frame <= pin.endFrame; frame += 1) {
268
+ const target = rangePinTargetWorld(pin, frame, { objectWorldMatrix });
269
+ applyRaw(frame);
270
+ const baseQuats = new Map(ids.filter((id) => chains.has(id)).map((id) => [id, chains.get(id).bones.map((bone) => bone.quaternion.clone())]));
271
+ const basePositions = offsets ? new Map([["hips", joint.bone.position.clone()]]) : null;
272
+ applyLayer(frame);
273
+ const plants = new Map();
274
+ if (offsets) {
275
+ for (const id of LEGS) plants.set(id, chains.get(id).bones[2].getWorldPosition(new THREE.Vector3()));
276
+ solveHipsTranslateToFloor(joint, offsets[frame - pin.startFrame], joint.bone.position.clone(), 0, chain.contactHeights);
277
+ for (const [id, plant] of plants) solveIk(chains.get(id), plant, { exactHinge: true });
278
+ }
279
+ solveIk(chain, target, { exactHinge: true });
280
+ chain.bones[2].getWorldPosition(effector);
281
+ const residual = { frame, errorM: effector.distanceTo(target) };
282
+ if (pin.reach === "body") {
283
+ // A pinned foot intentionally leaves its old clip position; only
284
+ // supporting feet count as planting failures.
285
+ residual.feetErrorM = Math.max(0, ...[...plants].filter(([id]) => id !== pin.track).map(([id, plant]) => chains.get(id).bones[2].getWorldPosition(new THREE.Vector3()).distanceTo(plant)));
286
+ }
287
+ residuals.push(residual);
288
+ const scratch = createIkState();
289
+ ikBakeKeyframe(chains, scratch, frame, fkJoints, ids, basePositions, baseQuats);
290
+ const entry = scratch.keys.get(frame);
291
+ for (const key of entry.values()) { key.blend = pin.blend; key.pin = pin.id; }
292
+ entries.set(frame, entry);
293
+ }
294
+ } finally {
295
+ restore();
296
+ putPinKeys(ikState, own);
297
+ }
298
+ return { entries, residuals };
299
+ }
@@ -63,3 +63,29 @@ export function groupKeyRuns(frames) {
63
63
  }
64
64
  return runs;
65
65
  }
66
+
67
+ /** Inclusive range-pin geometry for the Full-Body lane. The visible band uses
68
+ * the exact keyed endpoints; the ramp endpoints extend by the pin's blend
69
+ * window and are clamped to the displayed timeline. */
70
+ export function rangePinBandGeometry(pin, frameCount, displayFrameCount = frameCount) {
71
+ if (!pin || !Number.isInteger(pin.startFrame) || !Number.isInteger(pin.endFrame) || pin.startFrame > pin.endFrame) return null;
72
+ const count = Math.max(1, Math.round(Number(frameCount) || 0));
73
+ const displayed = Math.max(1, Math.round(Number(displayFrameCount) || count));
74
+ const maxFrame = count - 1;
75
+ const startFrame = Math.max(0, Math.min(maxFrame, pin.startFrame));
76
+ const endFrame = Math.max(startFrame, Math.min(maxFrame, pin.endFrame));
77
+ const blend = Math.max(0, Math.round(Number(pin.blend) || 0));
78
+ const rampStartFrame = Math.max(0, startFrame - blend);
79
+ const rampEndFrame = Math.min(maxFrame, endFrame + blend);
80
+ const denominator = Math.max(1, displayed - 1);
81
+ return {
82
+ startFrame,
83
+ endFrame,
84
+ rampStartFrame,
85
+ rampEndFrame,
86
+ startPct: startFrame / denominator,
87
+ endPct: endFrame / denominator,
88
+ rampStartPct: rampStartFrame / denominator,
89
+ rampEndPct: rampEndFrame / denominator,
90
+ };
91
+ }