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.
- package/LICENSE +674 -0
- package/README.md +109 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/bin/cozyclay.mjs +194 -0
- package/dist/ardy/cskel27-rest.json +557 -0
- package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
- package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
- package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
- package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
- package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
- package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
- package/dist/assets/index-BW_S1YRy.js +4395 -0
- package/dist/assets/index-nRmmJgap.css +1 -0
- package/dist/demo/walk-then-stop.npz +0 -0
- package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
- package/dist/fonts/instrument-serif-latin.woff2 +0 -0
- package/dist/fonts/inter-latin.woff2 +0 -0
- package/dist/index.html +13 -0
- package/dist/models/x-bot-tpose.fbx +0 -0
- package/dist/models/y-bot-tpose.fbx +0 -0
- package/package.json +80 -0
- package/src/App.jsx +3840 -0
- package/src/ardy/client.js +140 -0
- package/src/ardy/convert.js +313 -0
- package/src/ardy/cskel27-neutral.js +39 -0
- package/src/ardy/cskel27.js +68 -0
- package/src/ardy/export.js +157 -0
- package/src/ardy/ik.js +610 -0
- package/src/ardy/npz.js +520 -0
- package/src/ardy/playback.js +414 -0
- package/src/ardy/prompt-clips.js +16 -0
- package/src/ardy/timeline-coordinates.js +17 -0
- package/src/ardy/timeline-resize.js +11 -0
- package/src/ardy/timeline.jsx +723 -0
- package/src/ardy/to-cskel27.js +205 -0
- package/src/ardy/waypoints.js +364 -0
- package/src/camera-follow.js +366 -0
- package/src/camera-move.js +286 -0
- package/src/controls.jsx +245 -0
- package/src/dualview.jsx +305 -0
- package/src/hierarchy-model.js +77 -0
- package/src/hierarchy-panel.jsx +365 -0
- package/src/history.js +90 -0
- package/src/main.jsx +10 -0
- package/src/object-catalog.jsx +90 -0
- package/src/object-gizmo.jsx +755 -0
- package/src/planview.jsx +653 -0
- package/src/poses.js +424 -0
- package/src/posestudio.jsx +811 -0
- package/src/props.jsx +302 -0
- package/src/room.jsx +64 -0
- package/src/scene-history.js +125 -0
- package/src/scene-objects.js +420 -0
- package/src/shot-authoring.js +113 -0
- package/src/shot.js +239 -0
- package/src/styles.css +5639 -0
- package/src/ui.jsx +391 -0
- package/src/use-render-activity.js +96 -0
- package/tools/ardy/BRIDGE.md +255 -0
- package/tools/ardy/README.md +136 -0
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +1427 -0
- package/tools/ardy/cclay_motion_edit.py +445 -0
- package/tools/ardy/cclay_sequence_generate.py +595 -0
- package/tools/ardy/dump-npz.py +205 -0
- package/tools/ardy/extract-rest.mjs +299 -0
- package/tools/ardy/npz.mjs +335 -0
- package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
- package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
- package/tools/ardy/pose-to-npz.mjs +106 -0
- package/tools/ardy/run-edit-on-box.sh +73 -0
- package/tools/ardy/run-on-box.sh +568 -0
- package/tools/ardy/run-sequence-on-box.sh +162 -0
- package/tools/ardy/visual-qa.mjs +188 -0
- package/tools/ardy/vq-car.mjs +52 -0
- package/tools/dev-full.mjs +29 -0
- package/tools/process-supervisor.mjs +63 -0
- package/tools/qa-browser.mjs +84 -0
- package/tools/qa-crop3.mjs +22 -0
- package/tools/qa-playview.mjs +49 -0
- package/tools/qa-screenshot.mjs +24 -0
- 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
|
+
}
|