cozyclay 1.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 (85) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +109 -0
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/bin/cozyclay.mjs +194 -0
  5. package/dist/ardy/cskel27-rest.json +557 -0
  6. package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  7. package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
  8. package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
  9. package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
  10. package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
  11. package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
  12. package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
  13. package/dist/assets/index-BW_S1YRy.js +4395 -0
  14. package/dist/assets/index-nRmmJgap.css +1 -0
  15. package/dist/demo/walk-then-stop.npz +0 -0
  16. package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
  17. package/dist/fonts/instrument-serif-latin.woff2 +0 -0
  18. package/dist/fonts/inter-latin.woff2 +0 -0
  19. package/dist/index.html +13 -0
  20. package/dist/models/x-bot-tpose.fbx +0 -0
  21. package/dist/models/y-bot-tpose.fbx +0 -0
  22. package/package.json +80 -0
  23. package/src/App.jsx +3840 -0
  24. package/src/ardy/client.js +140 -0
  25. package/src/ardy/convert.js +313 -0
  26. package/src/ardy/cskel27-neutral.js +39 -0
  27. package/src/ardy/cskel27.js +68 -0
  28. package/src/ardy/export.js +157 -0
  29. package/src/ardy/ik.js +610 -0
  30. package/src/ardy/npz.js +520 -0
  31. package/src/ardy/playback.js +414 -0
  32. package/src/ardy/prompt-clips.js +16 -0
  33. package/src/ardy/timeline-coordinates.js +17 -0
  34. package/src/ardy/timeline-resize.js +11 -0
  35. package/src/ardy/timeline.jsx +723 -0
  36. package/src/ardy/to-cskel27.js +205 -0
  37. package/src/ardy/waypoints.js +364 -0
  38. package/src/camera-follow.js +366 -0
  39. package/src/camera-move.js +286 -0
  40. package/src/controls.jsx +245 -0
  41. package/src/dualview.jsx +305 -0
  42. package/src/hierarchy-model.js +77 -0
  43. package/src/hierarchy-panel.jsx +365 -0
  44. package/src/history.js +90 -0
  45. package/src/main.jsx +10 -0
  46. package/src/object-catalog.jsx +90 -0
  47. package/src/object-gizmo.jsx +755 -0
  48. package/src/planview.jsx +653 -0
  49. package/src/poses.js +424 -0
  50. package/src/posestudio.jsx +811 -0
  51. package/src/props.jsx +302 -0
  52. package/src/room.jsx +64 -0
  53. package/src/scene-history.js +125 -0
  54. package/src/scene-objects.js +420 -0
  55. package/src/shot-authoring.js +113 -0
  56. package/src/shot.js +239 -0
  57. package/src/styles.css +5639 -0
  58. package/src/ui.jsx +391 -0
  59. package/src/use-render-activity.js +96 -0
  60. package/tools/ardy/BRIDGE.md +255 -0
  61. package/tools/ardy/README.md +136 -0
  62. package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
  63. package/tools/ardy/bridge.mjs +1427 -0
  64. package/tools/ardy/cclay_motion_edit.py +445 -0
  65. package/tools/ardy/cclay_sequence_generate.py +595 -0
  66. package/tools/ardy/dump-npz.py +205 -0
  67. package/tools/ardy/extract-rest.mjs +299 -0
  68. package/tools/ardy/npz.mjs +335 -0
  69. package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
  70. package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
  71. package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
  72. package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
  73. package/tools/ardy/pose-to-npz.mjs +106 -0
  74. package/tools/ardy/run-edit-on-box.sh +73 -0
  75. package/tools/ardy/run-on-box.sh +568 -0
  76. package/tools/ardy/run-sequence-on-box.sh +162 -0
  77. package/tools/ardy/visual-qa.mjs +188 -0
  78. package/tools/ardy/vq-car.mjs +52 -0
  79. package/tools/dev-full.mjs +29 -0
  80. package/tools/process-supervisor.mjs +63 -0
  81. package/tools/qa-browser.mjs +84 -0
  82. package/tools/qa-crop3.mjs +22 -0
  83. package/tools/qa-playview.mjs +49 -0
  84. package/tools/qa-screenshot.mjs +24 -0
  85. package/tools/qa-visual.mjs +51 -0
package/src/ardy/ik.js ADDED
@@ -0,0 +1,610 @@
1
+ import * as THREE from "three";
2
+ import { normalizeBoneName } from "../poses.js";
3
+
4
+ /**
5
+ * Frame-based IK layer for direct character posing, following the DCC
6
+ * standard (Blender / Maya / Cascadeur):
7
+ *
8
+ * - Non-destructive: enabling IK never changes the pose. Only a chain the
9
+ * user actually drags is ever solved; everything else keeps the FK pose.
10
+ * - Manipulation solves: dragging a handle solves the limb backward
11
+ * (two-bone analytic IK) with BEND CONTINUITY — the elbow/knee keeps its
12
+ * current side of the bone line, so it never snaps mid-drag.
13
+ * - Keys store FK LOCAL ROTATIONS of the solved chain bones (b0/b1), not
14
+ * effector targets — Cascadeur's "FK keyframe". Interpolation is plain
15
+ * quaternion slerp between keys, no re-solve at playback. Because the
16
+ * stored values are local, a moved or rotated character needs no
17
+ * re-anchoring at all.
18
+ */
19
+
20
+ /** The four IK chain handles, mapped to the timeline lanes of the same
21
+ * names. */
22
+ export const IK_TRACKS = [
23
+ { id: "leftHand", label: "Left Hand", kind: "arm", side: "Left" },
24
+ { id: "rightHand", label: "Right Hand", kind: "arm", side: "Right" },
25
+ { id: "leftFoot", label: "Left Foot", kind: "leg", side: "Left" },
26
+ { id: "rightFoot", label: "Right Foot", kind: "leg", side: "Right" },
27
+ ];
28
+
29
+ /** Mid-joint position handles: dragging repositions the elbow/knee with
30
+ * BOTH ends pinned (shoulder+wrist / hip+ankle) — the classic mid-chain
31
+ * handle. `chain` links to the IK chain whose bones it edits. */
32
+ export const MID_TRACKS = [
33
+ { id: "leftElbow", label: "Left Elbow", chain: "leftHand" },
34
+ { id: "rightElbow", label: "Right Elbow", chain: "rightHand" },
35
+ { id: "leftKnee", label: "Left Knee", chain: "leftFoot" },
36
+ { id: "rightKnee", label: "Right Knee", chain: "rightFoot" },
37
+ ];
38
+
39
+ /** FK swing handles: dragging swings the part toward the pointer (rotation
40
+ * only, around the joint itself). `child` gives the bone that defines the
41
+ * swing direction; null means use the bone's own +Y (Mixamo head). Colours
42
+ * follow the FK PoseHandles coding: torso yellow, head purple, arms orange,
43
+ * legs blue. */
44
+ export const FK_TRACKS = [
45
+ { id: "hips", label: "Hips", bone: "mixamorigHips", child: "mixamorigSpine", color: "#ffd23d" },
46
+ { id: "spine", label: "Spine", bone: "mixamorigSpine", child: "mixamorigSpine1", color: "#ffd23d" },
47
+ { id: "chest", label: "Chest", bone: "mixamorigSpine1", child: "mixamorigSpine2", color: "#ffd23d" },
48
+ { id: "neck", label: "Neck", bone: "mixamorigNeck", child: "mixamorigHead", color: "#b98cff" },
49
+ { id: "head", label: "Head", bone: "mixamorigHead", child: null, color: "#b98cff" },
50
+ { id: "leftShoulder", label: "Left Shoulder", bone: "mixamorigLeftShoulder", child: "mixamorigLeftArm", color: "#ff8a3d" },
51
+ { id: "rightShoulder", label: "Right Shoulder", bone: "mixamorigRightShoulder", child: "mixamorigRightArm", color: "#ff8a3d" },
52
+ ];
53
+
54
+ /** Bone chains per handle, root → effector. Mixamo spelling; the matcher
55
+ * accepts the `mixamorig:` prefix and prefix-less rigs. Shoulder stays out of
56
+ * the arm chain — clavicle rotation swings the whole shoulder mass and reads
57
+ * wrong for a blocking tool. */
58
+ const CHAINS = {
59
+ arm: (side) => [`mixamorig${side}Arm`, `mixamorig${side}ForeArm`, `mixamorig${side}Hand`],
60
+ leg: (side) => [`mixamorig${side}UpLeg`, `mixamorig${side}Leg`, `mixamorig${side}Foot`],
61
+ };
62
+
63
+ function findBone(root, name) {
64
+ const target = normalizeBoneName(name);
65
+ let found = null;
66
+ root.traverse((object) => {
67
+ if (found || !object.isBone) return;
68
+ const norm = normalizeBoneName(object.name);
69
+ if (norm === target || norm.endsWith(target)) found = object;
70
+ });
71
+ return found;
72
+ }
73
+
74
+ /**
75
+ * Resolve the IK rig against a character: the four two-bone chains, plus
76
+ * the FK swing joints. Returns null when any chain is short a bone — the
77
+ * caller then hides IK mode entirely rather than half-posing. Captures
78
+ * per-chain segment lengths and a character-local POLE used ONLY as the
79
+ * bend hint when the chain is perfectly straight. Elbows bend backward-
80
+ * down, knees forward-down.
81
+ */
82
+ export function resolveIkRig(rig) {
83
+ if (!rig) return null;
84
+ rig.updateMatrixWorld(true);
85
+ // Character facing, from the toes: the toe sticks out of the foot in the
86
+ // facing direction at any upright pose. Falls back to the group's +Z.
87
+ const charQ = rig.quaternion;
88
+ let forward = null;
89
+ for (const side of ["Left", "Right"]) {
90
+ const foot = findBone(rig, `mixamorig${side}Foot`);
91
+ const toe = findBone(rig, `mixamorig${side}ToeBase`);
92
+ if (foot && toe) {
93
+ const f = foot.getWorldPosition(new THREE.Vector3());
94
+ const t = toe.getWorldPosition(new THREE.Vector3());
95
+ if (f.distanceTo(t) > 1e-6) {
96
+ forward = t.sub(f).normalize();
97
+ break;
98
+ }
99
+ }
100
+ }
101
+ if (!forward) forward = new THREE.Vector3(0, 0, 1).applyQuaternion(charQ);
102
+ const invCharQ = charQ.clone().invert();
103
+ const armPoleLocal = forward.clone().multiplyScalar(-1).add(new THREE.Vector3(0, -0.5, 0)).normalize().applyQuaternion(invCharQ);
104
+ const legPoleLocal = forward.clone().add(new THREE.Vector3(0, -0.2, 0)).normalize().applyQuaternion(invCharQ);
105
+
106
+ const out = new Map();
107
+ const rootPos = new THREE.Vector3();
108
+ const childPos = new THREE.Vector3();
109
+ for (const track of IK_TRACKS) {
110
+ const names = CHAINS[track.kind](track.side);
111
+ const bones = names.map((n) => findBone(rig, n));
112
+ if (bones.some((b) => !b)) return null;
113
+ const lengths = [];
114
+ for (let i = 0; i < bones.length - 1; i += 1) {
115
+ bones[i].getWorldPosition(rootPos);
116
+ bones[i + 1].getWorldPosition(childPos);
117
+ const len = rootPos.distanceTo(childPos);
118
+ if (len < 1e-6) return null;
119
+ lengths.push(len);
120
+ }
121
+ out.set(track.id, {
122
+ track,
123
+ bones,
124
+ bindPositions: bones.map((bone) => {
125
+ const saved = rig.userData?.poseBind?.get(bone)?.position;
126
+ return saved
127
+ ? new THREE.Vector3(saved.x, saved.y, saved.z)
128
+ : bone.position.clone();
129
+ }),
130
+ lengths,
131
+ poleLocal: track.kind === "arm" ? armPoleLocal : legPoleLocal,
132
+ rig,
133
+ });
134
+ }
135
+ // FK swing joints: bone + the child that defines the swing direction. The
136
+ // hips also carries its bind LOCAL position — the body root control
137
+ // (height/lean translation for crouching and lying poses) works in
138
+ // parent-local space, and keys must restore the exact bind spot.
139
+ const fkJoints = new Map();
140
+ for (const track of FK_TRACKS) {
141
+ const bone = findBone(rig, track.bone);
142
+ if (!bone) return null;
143
+ const child = track.child ? findBone(rig, track.child) : null;
144
+ fkJoints.set(track.id, {
145
+ track,
146
+ bone,
147
+ child,
148
+ bindPos: (() => {
149
+ const saved = rig.userData?.poseBind?.get(bone)?.position;
150
+ return saved
151
+ ? new THREE.Vector3(saved.x, saved.y, saved.z)
152
+ : bone.position.clone();
153
+ })(),
154
+ });
155
+ }
156
+ return { chains: out, fkJoints };
157
+ }
158
+
159
+ /** Back-compat wrapper for callers that only need the chains map. */
160
+ export function resolveIkChains(rig) {
161
+ const resolved = resolveIkRig(rig);
162
+ return resolved ? resolved.chains : null;
163
+ }
164
+
165
+ /**
166
+ * Two-bone analytic IK for one 3-bone chain (shoulder/hip → elbow/knee →
167
+ * wrist/ankle). `target` is a world-space effector position. The root bone
168
+ * is pinned; the elbow/knee is placed exactly by the law of cosines, so the
169
+ * effector ALWAYS reaches a reachable target in one step.
170
+ *
171
+ * BEND CONTINUITY (the elbow-flip rule): the hinge direction follows the
172
+ * elbow's CURRENT offset from the root→target line whenever one exists, so
173
+ * a dragged limb keeps its own side and never mirror-flips when the target
174
+ * crosses the bone line. Only a perfectly straight chain (no offset to
175
+ * continue) falls back to the character-local pole hint.
176
+ */
177
+ export function solveIk(chain, targetWorld) {
178
+ restoreChainPositions(chain);
179
+ const { bones, lengths, poleLocal, rig } = chain;
180
+ const [b0, b1, b2] = bones;
181
+ const p0 = b0.getWorldPosition(new THREE.Vector3());
182
+ const p1cur = b1.getWorldPosition(new THREE.Vector3());
183
+ const t = targetWorld.clone();
184
+ const l0 = lengths[0];
185
+ const l1 = lengths[1];
186
+ let d = p0.distanceTo(t);
187
+
188
+ const dir = t.clone().sub(p0);
189
+ if (dir.lengthSq() < 1e-12) {
190
+ dir.copy(b2.getWorldPosition(new THREE.Vector3())).sub(p0);
191
+ if (dir.lengthSq() < 1e-12) dir.set(0, -1, 0);
192
+ }
193
+ dir.normalize();
194
+
195
+ // Clamp into the annulus [|l0-l1|, l0+l1] so the law of cosines is exact:
196
+ // an unreachable target stretches straight at it, never overshoots.
197
+ const maxD = l0 + l1 - 1e-6;
198
+ const minD = Math.abs(l0 - l1) + 1e-6;
199
+ if (d > maxD) {
200
+ d = maxD;
201
+ t.copy(p0).addScaledVector(dir, d);
202
+ } else if (d < minD) {
203
+ d = minD;
204
+ t.copy(p0).addScaledVector(dir, d);
205
+ }
206
+
207
+ // Law of cosines: the elbow sits at p0 + dir·proj + bend·off.
208
+ const cosA = Math.max(-1, Math.min(1, (l0 * l0 + d * d - l1 * l1) / (2 * l0 * d)));
209
+ const proj = l0 * cosA;
210
+ const off = Math.sqrt(Math.max(0, l0 * l0 - proj * proj));
211
+
212
+ const linePoint = p0.clone().addScaledVector(dir, proj);
213
+ // Is the chain currently BENT? Only a bent chain has an elbow side worth
214
+ // continuing — a straight chain's offset from the new target line is an
215
+ // artifact of the line direction, not a hinge, so it must not be reused.
216
+ const seg0 = p1cur.clone().sub(p0).normalize();
217
+ const seg1 = b2.getWorldPosition(new THREE.Vector3()).sub(p1cur).normalize();
218
+ const isBent = seg0.dot(seg1) < 0.999;
219
+ let bend;
220
+ if (isBent) {
221
+ // Continuity: reuse the elbow's current offset from the line.
222
+ bend = p1cur.clone().sub(linePoint);
223
+ }
224
+ if (!bend || bend.lengthSq() < 1e-8) {
225
+ // Straight chain — no side to continue; use the pole hint.
226
+ const poleWorld = poleLocal.clone().applyQuaternion(rig.quaternion);
227
+ bend = poleWorld.clone().addScaledVector(dir, -poleWorld.dot(dir));
228
+ if (bend.lengthSq() < 1e-8) {
229
+ bend = new THREE.Vector3(0, 1, 0).addScaledVector(dir, -dir.y);
230
+ if (bend.lengthSq() < 1e-8) bend = new THREE.Vector3(0, 0, 1).addScaledVector(dir, -dir.z);
231
+ }
232
+ }
233
+ bend.normalize();
234
+
235
+ const p1 = linePoint.clone().addScaledVector(bend, off);
236
+ const p2 = p0.clone().addScaledVector(dir, d);
237
+ aimChain(bones, [p0, p1, p2]);
238
+ }
239
+
240
+ /** Re-aim each bone so its bone→child direction points at the solved joint
241
+ * positions. Only b0 and b1 rotate; the effector (b2) keeps its local
242
+ * rotation, so the hand follows the forearm and the foot follows the shin —
243
+ * the same convention for every limb. */
244
+ function aimChain(bones, points) {
245
+ const qDeltaWorld = new THREE.Quaternion();
246
+ const qWorld = new THREE.Quaternion();
247
+ const qParentWorldInv = new THREE.Quaternion();
248
+ const currentDir = new THREE.Vector3();
249
+ const wantedDir = new THREE.Vector3();
250
+ const bonePos = new THREE.Vector3();
251
+ const childPos = new THREE.Vector3();
252
+ for (let i = 0; i < bones.length - 1; i += 1) {
253
+ const bone = bones[i];
254
+ bone.updateMatrixWorld(true);
255
+ bone.getWorldPosition(bonePos);
256
+ bones[i + 1].getWorldPosition(childPos);
257
+ currentDir.subVectors(childPos, bonePos);
258
+ if (currentDir.lengthSq() < 1e-10) continue;
259
+ currentDir.normalize();
260
+ wantedDir.subVectors(points[i + 1], points[i]);
261
+ if (wantedDir.lengthSq() < 1e-10) continue;
262
+ wantedDir.normalize();
263
+ qDeltaWorld.setFromUnitVectors(currentDir, wantedDir);
264
+ // New world orientation = delta ⊗ current world, expressed in the
265
+ // parent's frame: qLocal = qParentWorldInv ⊗ qDelta ⊗ qWorld. The
266
+ // parent frame must be the parent's WORLD rotation — on a real Mixamo
267
+ // rig the bind locals are NOT identity (shoulder aims down the arm),
268
+ // and reading parent.quaternion alone drops the ancestors' rotation,
269
+ // which folds the chain instead of aiming it. getWorldQuaternion is
270
+ // safe here: the rig's cm scale is uniform, so decompose extracts a
271
+ // clean rotation, and the parent's matrixWorld is fresh from the
272
+ // previous iteration's updateMatrixWorld.
273
+ bone.getWorldQuaternion(qWorld);
274
+ bone.parent.getWorldQuaternion(qParentWorldInv).invert();
275
+ bone.quaternion.copy(qParentWorldInv.multiply(qDeltaWorld).multiply(qWorld));
276
+ bone.updateMatrixWorld(true);
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Mid-joint drag: reposition the elbow/knee directly. The mid joint sits on
282
+ * the sphere of radius l0 around the pinned root, so the dragged point is
283
+ * clamped radially onto it; the forearm/shin KEEPS ITS CURRENT WORLD
284
+ * DIRECTION and the effector follows (Cascadeur's grab-the-point model:
285
+ * dragging a mid point bends the limb and the end comes along). This always
286
+ * works — crucially on a STRAIGHT chain too, where both-ends-pinned
287
+ * models (and Maya's pole plane) have zero elbow freedom and read as
288
+ * "the elbow doesn't move". Segment lengths are preserved exactly.
289
+ */
290
+ export function solveMidJoint(chain, midTargetWorld) {
291
+ restoreChainPositions(chain);
292
+ const { bones, lengths } = chain;
293
+ const [b0, b1, b2] = bones;
294
+ const p0 = b0.getWorldPosition(new THREE.Vector3());
295
+ const p1cur = b1.getWorldPosition(new THREE.Vector3());
296
+ const p2cur = b2.getWorldPosition(new THREE.Vector3());
297
+ // Mid joint at the drag point, clamped to the root sphere (|p0→p1| = l0).
298
+ const dir = midTargetWorld.clone().sub(p0);
299
+ if (dir.lengthSq() < 1e-12) dir.copy(p1cur).sub(p0);
300
+ dir.normalize();
301
+ const p1 = p0.clone().addScaledVector(dir, lengths[0]);
302
+ // Forearm keeps its current world direction; the wrist/ankle follows.
303
+ const foreDir = p2cur.clone().sub(p1cur);
304
+ if (foreDir.lengthSq() < 1e-12) foreDir.copy(dir);
305
+ foreDir.normalize();
306
+ const p2 = p1.clone().addScaledVector(foreDir, lengths[1]);
307
+ aimChain(bones, [p0, p1, p2]);
308
+ return p1; // the clamped position the caller should snap the handle to
309
+ }
310
+
311
+ /**
312
+ * FK swing drag as a TRACKBALL rotation (three.js TransformControls rotate
313
+ * model): the drag layer supplies a world rotation axis (drag direction ×
314
+ * the joint→camera eye) and an angle (offset · tangent × speed/camDist),
315
+ * and the rotation applies ABSOLUTELY from the drag-start orientation, so
316
+ * repeated pointer moves never compound. Sensitivity is normalised by the
317
+ * camera distance, so every joint responds identically in screen space —
318
+ * the fix for the old aim-at-pointer model, whose sensitivity swung with
319
+ * each joint's child-bone length (hips lurching 1.6 m on a small drag).
320
+ */
321
+ export function solveSwingAngle(joint, axisWorld, angleRad, startQuat, startParentQuat) {
322
+ const { bone } = joint;
323
+ if (!Number.isFinite(angleRad) || Math.abs(angleRad) < 1e-9) return;
324
+ if (joint.track.id !== "hips" && joint.bindPos) bone.position.copy(joint.bindPos);
325
+ const qDelta = new THREE.Quaternion().setFromAxisAngle(axisWorld.clone().normalize(), angleRad);
326
+ const qWorldStart = startParentQuat.clone().multiply(startQuat);
327
+ const qParentInv = startParentQuat.clone().invert();
328
+ bone.quaternion.copy(qParentInv.multiply(qDelta).multiply(qWorldStart));
329
+ bone.updateMatrixWorld(true);
330
+ }
331
+
332
+ /**
333
+ * Effector swing: rotate a chain's end bone (the hand/foot) around its own
334
+ * centre — the IK solve positions the wrist/ankle, this orients it. Same
335
+ * absolute-from-drag-start trackball math as solveSwingAngle, but nothing
336
+ * else moves: b0/b1 keep their solved pose, so the limb stays put while the
337
+ * hand or foot tilts, twists and turns.
338
+ */
339
+ export function solveEffectorSwing(chain, axisWorld, angleRad, startQuat, startParentQuat) {
340
+ if (!Number.isFinite(angleRad) || Math.abs(angleRad) < 1e-9) return;
341
+ const bone = chain.bones[2];
342
+ const qDelta = new THREE.Quaternion().setFromAxisAngle(axisWorld.clone().normalize(), angleRad);
343
+ const qWorldStart = startParentQuat.clone().multiply(startQuat);
344
+ const qParentInv = startParentQuat.clone().invert();
345
+ bone.quaternion.copy(qParentInv.multiply(qDelta).multiply(qWorldStart));
346
+ bone.updateMatrixWorld(true);
347
+ }
348
+
349
+ /**
350
+ * Body-root translate: set the hips bone's parent-local position to
351
+ * `startLocalPos` plus a world delta (height for crouching/kneeling,
352
+ * lean-shift for lying). Absolute from the drag start, so repeated pointer
353
+ * moves never compound. The whole skeleton follows (legs, spine, arms are
354
+ * children), so this is how flat-on-floor poses become possible at all —
355
+ * the Character blocking (charA) is untouched. The world delta is converted
356
+ * through the parent's yaw and its cm scale (Mixamo rigs are 100× in
357
+ * bone-local units).
358
+ */
359
+ export function solveHipsTranslate(joint, worldDelta, startLocalPos) {
360
+ const { bone } = joint;
361
+ const parentInv = bone.parent.getWorldQuaternion(new THREE.Quaternion()).invert();
362
+ // Parent world scale (uniform on a Mixamo rig): world → parent-local
363
+ const scale = bone.parent.getWorldScale(new THREE.Vector3()).x || 1;
364
+ const local = worldDelta.clone().applyQuaternion(parentInv).divideScalar(scale);
365
+ bone.position.copy(startLocalPos).add(local);
366
+ bone.updateMatrixWorld(true);
367
+ }
368
+
369
+ /* --- state ----------------------------------------------------------------- */
370
+
371
+ /** Mutable per-rig IK layer state. `targets` are the live handle positions
372
+ * (world space) the handles render at and the solver reaches for. `keys`
373
+ * maps frame → Map(trackId → {q, p}) — FK LOCAL rotations of the solved
374
+ * chain bones (+ hips local position), stored only for parts the user has
375
+ * dragged. `plants` holds the captured ankle positions for foot snapping. */
376
+ export function createIkState() {
377
+ return { chains: null, targets: new Map(), keys: new Map(), tracked: new Set(), plants: new Map() };
378
+ }
379
+
380
+ /** Mark a chain as user-dragged (focused). Only tracked chains are keyed or
381
+ * evaluated — everything else stays on the pure FK pose. */
382
+ export function ikTouch(ikState, trackId) {
383
+ ikState.tracked.add(trackId);
384
+ }
385
+
386
+ /** Initialise the live targets from the rig's CURRENT effector positions.
387
+ * Runs when IK mode opens: handles appear exactly on the wrists/ankles and
388
+ * nothing moves. */
389
+ export function ikSeedTargets(chains, ikState) {
390
+ const pos = new THREE.Vector3();
391
+ for (const [id, chain] of chains) {
392
+ chain.bones[2].getWorldPosition(pos);
393
+ ikState.targets.set(id, pos.clone());
394
+ }
395
+ }
396
+
397
+ /** Snapshot the current local rotations of every chain and FK bone — plus
398
+ * the hips' LOCAL POSITION (the body root's height/lean) — the pre-IK state
399
+ * restored when IK mode closes. */
400
+ export function ikSnapshot(rig, fkJoints) {
401
+ const out = new Map();
402
+ for (const [id, chain] of rig) {
403
+ out.set(id, { quats: chain.bones.map((b) => b.quaternion.clone()), pos: null });
404
+ }
405
+ if (fkJoints) {
406
+ for (const [id, joint] of fkJoints) {
407
+ out.set(id, {
408
+ quats: [joint.bone.quaternion.clone()],
409
+ pos: joint.bindPos ? joint.bone.position.clone() : null,
410
+ });
411
+ }
412
+ }
413
+ return out;
414
+ }
415
+
416
+ /** Restore a snapshot taken by ikSnapshot. */
417
+ export function ikRestore(rig, snapshot, fkJoints) {
418
+ if (!snapshot) return;
419
+ for (const [id, saved] of snapshot) {
420
+ const chain = rig.get(id);
421
+ if (chain) {
422
+ chain.bones.forEach((b, i) => {
423
+ if (saved.quats[i]) b.quaternion.copy(saved.quats[i]);
424
+ });
425
+ chain.bones[0].updateMatrixWorld(true);
426
+ continue;
427
+ }
428
+ const joint = fkJoints?.get(id);
429
+ if (joint) {
430
+ if (saved.quats[0]) joint.bone.quaternion.copy(saved.quats[0]);
431
+ if (saved.pos && joint.bindPos) joint.bone.position.copy(saved.pos);
432
+ joint.bone.updateMatrixWorld(true);
433
+ }
434
+ }
435
+ }
436
+
437
+ /** Bake the CURRENT local rotations of every TRACKED part into the key at
438
+ * `frame`: solved chain bones (b0, b1) for chains, the single bone for FK
439
+ * joints — and the hips' LOCAL POSITION when the body root was moved.
440
+ * Entries are uniform { q: [...quats], p: localPos | null }. Local values
441
+ * are character-position independent, so keys need no re-anchoring ever. */
442
+ export function ikBakeKeyframe(rig, ikState, frame, fkJoints) {
443
+ let entry = ikState.keys.get(frame);
444
+ for (const id of ikState.tracked) {
445
+ const chain = rig.get(id);
446
+ const joint = fkJoints?.get(id);
447
+ let q = null;
448
+ let p = null;
449
+ if (chain) q = [chain.bones[0].quaternion.clone(), chain.bones[1].quaternion.clone(), chain.bones[2].quaternion.clone()];
450
+ else if (joint) {
451
+ q = [joint.bone.quaternion.clone()];
452
+ if (joint.bindPos) p = joint.bone.position.clone();
453
+ }
454
+ if (!q) continue;
455
+ if (!entry) ikState.keys.set(frame, (entry = new Map()));
456
+ entry.set(id, { q, p });
457
+ }
458
+ }
459
+
460
+ /** Drop the whole key at `frame`. */
461
+ export function ikRemoveKeyframe(ikState, frame) {
462
+ ikState.keys.delete(frame);
463
+ }
464
+
465
+ /* --- foot planting (ground snap) -------------------------------------------- */
466
+
467
+ /** The two leg chains that get planted. */
468
+ const LEG_IDS = ["leftFoot", "rightFoot"];
469
+
470
+ /** Capture the current ankle world positions as the plant points. Called
471
+ * once when a body (hips) drag starts, BEFORE the hips move — the feet
472
+ * then stay exactly here for the whole drag (Cascadeur's always-active
473
+ * ankle controllers / UE's foot-pin, over the existing two-bone solver). */
474
+ export function ikPlantFeet(chains, ikState) {
475
+ const pos = new THREE.Vector3();
476
+ for (const id of LEG_IDS) {
477
+ const chain = chains.get(id);
478
+ if (!chain) continue;
479
+ chain.bones[2].getWorldPosition(pos);
480
+ ikState.plants.set(id, pos.clone());
481
+ }
482
+ }
483
+
484
+ /** Re-solve each planted leg chain so its ankle returns to its plant point.
485
+ * Runs after every hips transform while the body drag is active: the hips
486
+ * move, the feet stay, the knees bend. Legs that cannot reach their plant
487
+ * stretch toward it and the foot comes off — the natural reach limit. */
488
+ export function ikSolvePlantedFeet(chains, ikState) {
489
+ for (const id of LEG_IDS) {
490
+ const plant = ikState.plants.get(id);
491
+ const chain = chains.get(id);
492
+ if (!plant || !chain) continue;
493
+ solveIk(chain, plant);
494
+ }
495
+ }
496
+
497
+ /** Frames with an authored key, sorted — the timeline markers. */
498
+ export function ikKeyframes(ikState) {
499
+ return [...ikState.keys.keys()].sort((a, b) => a - b);
500
+ }
501
+
502
+ /**
503
+ * Evaluate the IK layer at `frame`: for every TRACKED part with keys,
504
+ * slerp each stored bone's local rotation between the nearest keyed frames
505
+ * and apply it — chain bones (b0, b1) and FK joints alike. No re-solve:
506
+ * playback reproduces exactly what was dragged. Untracked parts, and
507
+ * tracked parts with no keys, are never written.
508
+ *
509
+ * `blendWindow` > 0 turns the layer into a LOCAL correction (used when a
510
+ * generated motion plays underneath): the correction eases from full
511
+ * strength at the edge keys down to zero over that many frames outside the
512
+ * keyed range, blending against whatever pose is already on the bone (the
513
+ * motion). Between keys the weight stays 1 — both endpoints are authored,
514
+ * so the slerp is the design. With the default 0 the keys hold forever
515
+ * (constant extrapolation), the no-motion behaviour.
516
+ */
517
+ export function ikEvaluate(rig, ikState, frame, fkJoints, blendWindow = 0) {
518
+ if (!rig) return;
519
+ for (const id of ikState.tracked) {
520
+ const sampled = sampleChain(ikState.keys, id, frame);
521
+ if (!sampled) continue;
522
+ const chain = rig.get(id);
523
+ const joint = fkJoints?.get(id);
524
+ const w = blendWindow > 0 ? correctionWeight(ikState.keys, id, frame, blendWindow) : 1;
525
+ if (w <= 0) continue;
526
+ if (chain) {
527
+ restoreChainPositions(chain, w);
528
+ if (w >= 1) {
529
+ chain.bones[0].quaternion.copy(sampled.q[0]);
530
+ chain.bones[1].quaternion.copy(sampled.q[1]);
531
+ // The effector's authored rotation rides along when the key has it
532
+ // (pre-rotation keys hold only b0/b1 — apply whatever is stored).
533
+ if (sampled.q[2]) chain.bones[2].quaternion.copy(sampled.q[2]);
534
+ } else {
535
+ // current quats are the base layer's (motion) — ease toward the key
536
+ chain.bones[0].quaternion.slerp(sampled.q[0], w);
537
+ chain.bones[1].quaternion.slerp(sampled.q[1], w);
538
+ if (sampled.q[2]) chain.bones[2].quaternion.slerp(sampled.q[2], w);
539
+ }
540
+ chain.bones[0].updateMatrixWorld(true);
541
+ } else if (joint) {
542
+ if (!sampled.p && joint.bindPos) joint.bone.position.lerp(joint.bindPos, w);
543
+ if (w >= 1) joint.bone.quaternion.copy(sampled.q[0]);
544
+ else joint.bone.quaternion.slerp(sampled.q[0], w);
545
+ if (sampled.p && joint.bindPos) {
546
+ if (w >= 1) joint.bone.position.copy(sampled.p);
547
+ else joint.bone.position.lerp(sampled.p, w);
548
+ }
549
+ joint.bone.updateMatrixWorld(true);
550
+ }
551
+ }
552
+ }
553
+
554
+ /**
555
+ * ARDY playback positions mapped joints independently. Once IK authors a
556
+ * chain's rotations, those generated translations no longer describe the
557
+ * same FK pose and can visually separate the limb. Return the edited chain
558
+ * to its Mixamo bind translations before applying IK rotations so parent
559
+ * rotation and fixed segment lengths own all descendants.
560
+ */
561
+ function restoreChainPositions(chain, weight = 1) {
562
+ if (!chain?.bindPositions) return;
563
+ for (let index = 0; index < chain.bones.length; index += 1) {
564
+ const bone = chain.bones[index];
565
+ const bind = chain.bindPositions[index];
566
+ if (weight >= 1) bone.position.copy(bind);
567
+ else bone.position.lerp(bind, weight);
568
+ }
569
+ chain.bones[0].updateMatrixWorld(true);
570
+ }
571
+
572
+ /**
573
+ * Correction strength at `frame` for one track: 1 inside the keyed range
574
+ * (both neighbours authored), easing 1 → 0 across `blendWindow` frames
575
+ * outside it, so a single key at frame 39 stops stomping the whole earlier
576
+ * motion and instead blends back to the clip.
577
+ */
578
+ function correctionWeight(keys, trackId, frame, blendWindow) {
579
+ let first = null;
580
+ let last = null;
581
+ for (const f of keys.keys()) {
582
+ if (!keys.get(f).has(trackId)) continue;
583
+ if (first == null || f < first) first = f;
584
+ if (last == null || f > last) last = f;
585
+ }
586
+ if (first == null) return 0;
587
+ if (frame >= first && frame <= last) return 1;
588
+ const d = frame < first ? first - frame : frame - last;
589
+ return Math.max(0, 1 - d / blendWindow);
590
+ }
591
+
592
+ function sampleChain(keys, trackId, frame) {
593
+ let prevFrame = null;
594
+ let nextFrame = null;
595
+ for (const f of keys.keys()) {
596
+ if (!keys.get(f).has(trackId)) continue;
597
+ if (f <= frame && (prevFrame == null || f > prevFrame)) prevFrame = f;
598
+ if (f >= frame && (nextFrame == null || f < nextFrame)) nextFrame = f;
599
+ }
600
+ if (prevFrame == null && nextFrame == null) return null;
601
+ if (prevFrame == null) return keys.get(nextFrame).get(trackId);
602
+ if (nextFrame == null || prevFrame === nextFrame) return keys.get(prevFrame).get(trackId);
603
+ const a = keys.get(prevFrame).get(trackId);
604
+ const b = keys.get(nextFrame).get(trackId);
605
+ const t = (frame - prevFrame) / (nextFrame - prevFrame);
606
+ return {
607
+ q: a.q.map((q, i) => q.clone().slerp(b.q[i], t)),
608
+ p: a.p && b.p ? a.p.clone().lerp(b.p, t) : (a.p || b.p || null),
609
+ };
610
+ }