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/poses.js
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pose data + pure logic for the pose studio. No React, no three.js imports:
|
|
3
|
+
* object3D trees are consumed structurally (`isBone`, `traverse`, `rotation`),
|
|
4
|
+
* so this module runs anywhere.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Ordered list of editable joints. `bone` is the canonical Mixamo name; the
|
|
9
|
+
* matcher also accepts the `mixamorig:`-prefixed FBX spelling and rigs that
|
|
10
|
+
* drop the prefix entirely.
|
|
11
|
+
*/
|
|
12
|
+
export const POSE_BONES = [
|
|
13
|
+
{ id: "hips", bone: "mixamorigHips", label: "Hips" },
|
|
14
|
+
{ id: "spine", bone: "mixamorigSpine", label: "Spine" },
|
|
15
|
+
{ id: "chest", bone: "mixamorigSpine1", label: "Chest" },
|
|
16
|
+
{ id: "neck", bone: "mixamorigNeck", label: "Neck" },
|
|
17
|
+
{ id: "head", bone: "mixamorigHead", label: "Head" },
|
|
18
|
+
{ id: "lShoulder", bone: "mixamorigLeftShoulder", label: "L Shoulder" },
|
|
19
|
+
{ id: "rShoulder", bone: "mixamorigRightShoulder", label: "R Shoulder" },
|
|
20
|
+
{ id: "lArm", bone: "mixamorigLeftArm", label: "L Upper Arm" },
|
|
21
|
+
{ id: "rArm", bone: "mixamorigRightArm", label: "R Upper Arm" },
|
|
22
|
+
{ id: "lForeArm", bone: "mixamorigLeftForeArm", label: "L Forearm" },
|
|
23
|
+
{ id: "rForeArm", bone: "mixamorigRightForeArm", label: "R Forearm" },
|
|
24
|
+
{ id: "lHand", bone: "mixamorigLeftHand", label: "L Hand" },
|
|
25
|
+
{ id: "rHand", bone: "mixamorigRightHand", label: "R Hand" },
|
|
26
|
+
{ id: "lUpLeg", bone: "mixamorigLeftUpLeg", label: "L Thigh" },
|
|
27
|
+
{ id: "rUpLeg", bone: "mixamorigRightUpLeg", label: "R Thigh" },
|
|
28
|
+
{ id: "lLeg", bone: "mixamorigLeftLeg", label: "L Shin" },
|
|
29
|
+
{ id: "rLeg", bone: "mixamorigRightLeg", label: "R Shin" },
|
|
30
|
+
{ id: "lFoot", bone: "mixamorigLeftFoot", label: "L Foot" },
|
|
31
|
+
{ id: "rFoot", bone: "mixamorigRightFoot", label: "R Foot" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** Strip every non-alphanumeric character and lowercase, so `mixamorig:LeftArm`,
|
|
35
|
+
* `mixamorigLeftArm` and `LeftArm` all compare equal. */
|
|
36
|
+
export function normalizeBoneName(name) {
|
|
37
|
+
return String(name ?? "").replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A bone matches a joint when the normalised names are equal or one is a
|
|
41
|
+
* suffix of the other (`mixamorigleftarm` vs bare `leftarm`). */
|
|
42
|
+
function boneMatches(boneName, entry) {
|
|
43
|
+
const norm = normalizeBoneName(boneName);
|
|
44
|
+
const target = normalizeBoneName(entry.bone);
|
|
45
|
+
return norm === target || norm.endsWith(target) || target.endsWith(norm);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Built-in pose presets. `bones` maps a POSE_BONES id to an [x, y, z] Euler
|
|
50
|
+
* triple in radians — a rotation delta composed onto the character's rest
|
|
51
|
+
* (bind) pose, so T-pose/rest is all zeros on any rig. Values were tuned
|
|
52
|
+
* against the shipped `x-bot-tpose.fbx` skeleton and verified by joint world
|
|
53
|
+
* positions on both shipped rigs.
|
|
54
|
+
*/
|
|
55
|
+
export const BUILT_IN_POSES = [
|
|
56
|
+
{
|
|
57
|
+
id: "tpose",
|
|
58
|
+
label: "T-pose",
|
|
59
|
+
prompt: "standing in a T-pose with arms straight out to the sides",
|
|
60
|
+
bones: {
|
|
61
|
+
hips: [0, 0, 0],
|
|
62
|
+
spine: [0, 0, 0],
|
|
63
|
+
chest: [0, 0, 0],
|
|
64
|
+
neck: [0, 0, 0],
|
|
65
|
+
head: [0, 0, 0],
|
|
66
|
+
lShoulder: [0, 0, 0],
|
|
67
|
+
rShoulder: [0, 0, 0],
|
|
68
|
+
lArm: [0, 0, 0],
|
|
69
|
+
rArm: [0, 0, 0],
|
|
70
|
+
lForeArm: [0, 0, 0],
|
|
71
|
+
rForeArm: [0, 0, 0],
|
|
72
|
+
lHand: [0, 0, 0],
|
|
73
|
+
rHand: [0, 0, 0],
|
|
74
|
+
lUpLeg: [0, 0, 0],
|
|
75
|
+
rUpLeg: [0, 0, 0],
|
|
76
|
+
lLeg: [0, 0, 0],
|
|
77
|
+
rLeg: [0, 0, 0],
|
|
78
|
+
lFoot: [0, 0, 0],
|
|
79
|
+
rFoot: [0, 0, 0],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "relaxed",
|
|
84
|
+
label: "Relaxed",
|
|
85
|
+
prompt: "standing relaxed with arms hanging naturally at their sides",
|
|
86
|
+
bones: {
|
|
87
|
+
lArm: [1.5632, 0.1164, -0.2889],
|
|
88
|
+
rArm: [1.5632, -0.1164, 0.2889],
|
|
89
|
+
lForeArm: [-0.0076, 0.0003, -0.0874],
|
|
90
|
+
rForeArm: [-0.0076, -0.0003, 0.0874],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "contrapposto",
|
|
95
|
+
label: "Contrapposto",
|
|
96
|
+
prompt: "standing in a contrapposto stance with the weight on one leg, hips and shoulders gently counter-rotated",
|
|
97
|
+
bones: {
|
|
98
|
+
hips: [0.04, 0.1542, 0.142],
|
|
99
|
+
spine: [-0.0264, -0.2662, -0.0167],
|
|
100
|
+
chest: [0, -0.12, 0],
|
|
101
|
+
head: [0, -0.08, 0],
|
|
102
|
+
lUpLeg: [0.2, 0, 0],
|
|
103
|
+
lLeg: [-0.3, 0, 0],
|
|
104
|
+
lArm: [1.7229, 0.1824, -0.2563],
|
|
105
|
+
rArm: [1.4178, -0.188, 0.194],
|
|
106
|
+
lForeArm: [0.0181, -0.0008, -0.0858],
|
|
107
|
+
rForeArm: [-0.0214, -0.0009, 0.0851],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: "walk",
|
|
112
|
+
label: "Walking",
|
|
113
|
+
prompt: "walking with a mid-stride gait, the right leg forward and the left arm swinging forward",
|
|
114
|
+
bones: {
|
|
115
|
+
spine: [0.0005, -0.079, 0.0128],
|
|
116
|
+
rUpLeg: [0.6191, 0, 0],
|
|
117
|
+
rLeg: [0.3386, 0, 0],
|
|
118
|
+
lUpLeg: [-0.6035, 0, 0],
|
|
119
|
+
lLeg: [-0.1861, 0, 0],
|
|
120
|
+
lArm: [1.3487, -0.2962, -0.727],
|
|
121
|
+
lForeArm: [-0.2903, 0.0931, -0.6172],
|
|
122
|
+
rArm: [1.3728, -0.6284, -0.2942],
|
|
123
|
+
rForeArm: [-0.2523, 0.0965, -0.7273],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: "seated",
|
|
128
|
+
label: "Seated",
|
|
129
|
+
prompt: "sitting upright with knees bent at right angles and hands resting near the knees",
|
|
130
|
+
bones: {
|
|
131
|
+
hips: [0.14, 0, 0],
|
|
132
|
+
spine: [-0.08, 0, 0],
|
|
133
|
+
rUpLeg: [1.3515, 0, 0],
|
|
134
|
+
lUpLeg: [1.3515, 0, 0],
|
|
135
|
+
rLeg: [1.5599, 0, 0],
|
|
136
|
+
lLeg: [1.5599, 0, 0],
|
|
137
|
+
rFoot: [-1.3043, 0, 0],
|
|
138
|
+
lFoot: [-1.3044, 0, 0],
|
|
139
|
+
lArm: [1.4894, -0.0842, -0.4769],
|
|
140
|
+
rArm: [1.4894, 0.0843, 0.4768],
|
|
141
|
+
lForeArm: [-0.1208, 0.0256, -0.4174],
|
|
142
|
+
rForeArm: [-0.1208, -0.0256, 0.4174],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: "arms-crossed",
|
|
147
|
+
label: "Arms crossed",
|
|
148
|
+
prompt: "standing with arms crossed over the chest",
|
|
149
|
+
bones: {
|
|
150
|
+
rArm: [0.3878, 0.8097, 2.4919],
|
|
151
|
+
rForeArm: [0.713, -0.1597, -0.4234],
|
|
152
|
+
lArm: [0.3878, -0.8097, -2.4919],
|
|
153
|
+
lForeArm: [0.713, 0.1597, 0.4234],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: "pointing",
|
|
158
|
+
label: "Pointing",
|
|
159
|
+
prompt: "pointing forward with the right arm extended and the left arm relaxed at the side",
|
|
160
|
+
bones: {
|
|
161
|
+
rArm: [-0.0382, 0.0586, 1.5707],
|
|
162
|
+
lArm: [1.5632, 0.1164, -0.2889],
|
|
163
|
+
lForeArm: [-0.0076, 0.0003, -0.0874],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "hands-on-hips",
|
|
168
|
+
label: "Hands on hips",
|
|
169
|
+
prompt: "standing with both hands resting on the hips",
|
|
170
|
+
bones: {
|
|
171
|
+
rArm: [1.0891, -0.2178, 0.0633],
|
|
172
|
+
rForeArm: [1.249, -0.0715, -0.0992],
|
|
173
|
+
lArm: [1.0891, 0.2178, -0.0633],
|
|
174
|
+
lForeArm: [1.249, 0.0715, 0.0992],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "looking-back",
|
|
179
|
+
label: "Looking back",
|
|
180
|
+
prompt: "standing with the torso twisted and the head turned to look back over the shoulder",
|
|
181
|
+
bones: {
|
|
182
|
+
spine: [0.0084, 0.3157, -0.0529],
|
|
183
|
+
chest: [0, 0.28, 0],
|
|
184
|
+
neck: [0, 0.52, 0],
|
|
185
|
+
head: [0, 0.74, 0],
|
|
186
|
+
lArm: [1.4354, 0.1623, -0.2244],
|
|
187
|
+
rArm: [1.7014, -0.15, 0.2785],
|
|
188
|
+
lForeArm: [-0.0519, 0.0018, -0.0706],
|
|
189
|
+
rForeArm: [0.045, 0.0017, 0.0752],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: "hands-up",
|
|
194
|
+
label: "Hands up",
|
|
195
|
+
prompt: "standing with both arms raised straight above the head",
|
|
196
|
+
bones: {
|
|
197
|
+
lArm: [-1.6214, -0.2072, -0.0104],
|
|
198
|
+
rArm: [-1.6214, 0.2072, 0.0104],
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
const STORAGE_KEY = "cozyclay_poses";
|
|
203
|
+
|
|
204
|
+
/* --- quaternion helpers (plain math, no three.js import) ------------------
|
|
205
|
+
* Pose values are Euler triples in radians, in the bone's local frame, applied
|
|
206
|
+
* as a rotation delta on top of the character's rest (bind) pose. That keeps
|
|
207
|
+
* presets rig-independent: "T-pose/rest" is all zeros on any rig, and a pose
|
|
208
|
+
* captured on one character poses the other identically.
|
|
209
|
+
* The Euler <-> quaternion conversions replicate three.js exactly (order XYZ),
|
|
210
|
+
* so values written by three (e.g. a drag handler) round-trip bit-exactly.
|
|
211
|
+
*/
|
|
212
|
+
|
|
213
|
+
/** Fallback per-root cache of every bone's rest-pose quaternion, used when the
|
|
214
|
+
* caller never mounted PoseHandles (which snapshots into `userData.poseBind`
|
|
215
|
+
* at pose-mode entry, before any drag can move a bone). */
|
|
216
|
+
const bindByRoot = new WeakMap();
|
|
217
|
+
|
|
218
|
+
function qFromEuler(x, y, z) {
|
|
219
|
+
const c1 = Math.cos(x / 2);
|
|
220
|
+
const c2 = Math.cos(y / 2);
|
|
221
|
+
const c3 = Math.cos(z / 2);
|
|
222
|
+
const s1 = Math.sin(x / 2);
|
|
223
|
+
const s2 = Math.sin(y / 2);
|
|
224
|
+
const s3 = Math.sin(z / 2);
|
|
225
|
+
return {
|
|
226
|
+
x: s1 * c2 * c3 + c1 * s2 * s3,
|
|
227
|
+
y: c1 * s2 * c3 - s1 * c2 * s3,
|
|
228
|
+
z: c1 * c2 * s3 + s1 * s2 * c3,
|
|
229
|
+
w: c1 * c2 * c3 - s1 * s2 * s3,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function qMultiply(a, b) {
|
|
234
|
+
return {
|
|
235
|
+
x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
|
|
236
|
+
y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
|
|
237
|
+
z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
|
|
238
|
+
w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function qInverse(q) {
|
|
243
|
+
return { x: -q.x, y: -q.y, z: -q.z, w: q.w };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function qToEuler(q) {
|
|
247
|
+
const m11 = 1 - 2 * (q.y * q.y + q.z * q.z);
|
|
248
|
+
const m12 = 2 * (q.x * q.y - q.w * q.z);
|
|
249
|
+
const m13 = 2 * (q.x * q.z + q.w * q.y);
|
|
250
|
+
const m22 = 1 - 2 * (q.x * q.x + q.z * q.z);
|
|
251
|
+
const m23 = 2 * (q.y * q.z - q.w * q.x);
|
|
252
|
+
const m32 = 2 * (q.y * q.z + q.w * q.x);
|
|
253
|
+
const m33 = 1 - 2 * (q.x * q.x + q.y * q.y);
|
|
254
|
+
const y = Math.asin(Math.max(-1, Math.min(1, m13)));
|
|
255
|
+
if (Math.abs(m13) < 0.9999999) {
|
|
256
|
+
return [Math.atan2(-m23, m33), y, Math.atan2(-m12, m11)];
|
|
257
|
+
}
|
|
258
|
+
return [Math.atan2(m32, m22), y, 0];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Snapshot the true bind pose, before anything has been applied to the rig.
|
|
263
|
+
*
|
|
264
|
+
* This MUST run while the rig is still untouched. PoseHandles used to be the
|
|
265
|
+
* only writer, and it mounts long after Character's own effect has already
|
|
266
|
+
* applied the default pose -- so the snapshot baked the default's deltas
|
|
267
|
+
* (~90 degrees on the arms) and called them "rest". Everything downstream that
|
|
268
|
+
* expresses a pose as a delta -- capturePose, savePose, and the ARDY export --
|
|
269
|
+
* then measured against a pose instead of against the bind, and a default
|
|
270
|
+
* character exported arms as identity.
|
|
271
|
+
*/
|
|
272
|
+
export function primeBindPose(root) {
|
|
273
|
+
if (!root || root.userData?.poseBind) return;
|
|
274
|
+
const map = new Map();
|
|
275
|
+
root.traverse((object) => {
|
|
276
|
+
if (object.isBone) {
|
|
277
|
+
const q = object.quaternion;
|
|
278
|
+
const p = object.position;
|
|
279
|
+
map.set(object, {
|
|
280
|
+
x: q.x,
|
|
281
|
+
y: q.y,
|
|
282
|
+
z: q.z,
|
|
283
|
+
w: q.w,
|
|
284
|
+
position: { x: p.x, y: p.y, z: p.z },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
root.userData.poseBind = map;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function bindOf(root) {
|
|
292
|
+
// primeBindPose stamps the untouched rig at clone time; prefer that over any
|
|
293
|
+
// later capture, which would already carry whatever pose is on the rig.
|
|
294
|
+
const primed = root.userData && root.userData.poseBind;
|
|
295
|
+
if (primed) return primed;
|
|
296
|
+
let map = bindByRoot.get(root);
|
|
297
|
+
if (!map) {
|
|
298
|
+
map = new Map();
|
|
299
|
+
root.traverse((object) => {
|
|
300
|
+
if (object.isBone) {
|
|
301
|
+
const q = object.quaternion;
|
|
302
|
+
const p = object.position;
|
|
303
|
+
map.set(object, {
|
|
304
|
+
x: q.x,
|
|
305
|
+
y: q.y,
|
|
306
|
+
z: q.z,
|
|
307
|
+
w: q.w,
|
|
308
|
+
position: { x: p.x, y: p.y, z: p.z },
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
bindByRoot.set(root, map);
|
|
313
|
+
}
|
|
314
|
+
return map;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** True when `bone` hangs underneath one of the already-rotated instances.
|
|
318
|
+
* Mixamo FBX exports nest an identity "skinned" copy of every bone under the
|
|
319
|
+
* control bone; writing the control bone moves the whole subtree, so the copy
|
|
320
|
+
* must not be rotated again. */
|
|
321
|
+
function isDescendantOf(bone, rotated) {
|
|
322
|
+
for (let p = bone.parent; p; p = p.parent) {
|
|
323
|
+
if (rotated.includes(p)) return true;
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Apply a pose to a character root: every bone whose normalised name matches a
|
|
329
|
+
* POSE_BONES entry gets the pose's Euler triple composed onto its rest rotation;
|
|
330
|
+
* unmatched bones stay untouched. Returns how many of the POSE_BONES joints
|
|
331
|
+
* were actually found and written. */
|
|
332
|
+
export function applyPose(root, pose) {
|
|
333
|
+
const total = POSE_BONES.length;
|
|
334
|
+
let applied = 0;
|
|
335
|
+
if (root && pose) {
|
|
336
|
+
const binds = bindOf(root);
|
|
337
|
+
const matchesByJoint = new Map();
|
|
338
|
+
root.traverse((object) => {
|
|
339
|
+
if (!object.isBone) return;
|
|
340
|
+
for (const entry of POSE_BONES) {
|
|
341
|
+
if (boneMatches(object.name, entry)) {
|
|
342
|
+
let list = matchesByJoint.get(entry.id);
|
|
343
|
+
if (!list) matchesByJoint.set(entry.id, (list = []));
|
|
344
|
+
list.push(object);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
for (const entry of POSE_BONES) {
|
|
349
|
+
const value = pose[entry.id];
|
|
350
|
+
const list = matchesByJoint.get(entry.id);
|
|
351
|
+
if (!list || !Array.isArray(value) || value.length < 3) continue;
|
|
352
|
+
const delta = qFromEuler(value[0], value[1], value[2]);
|
|
353
|
+
const rotated = [];
|
|
354
|
+
for (const bone of list) {
|
|
355
|
+
if (rotated.length && isDescendantOf(bone, rotated)) continue;
|
|
356
|
+
rotated.push(bone);
|
|
357
|
+
const q = qMultiply(delta, binds.get(bone) ?? binds.get(list[0]) ?? { x: 0, y: 0, z: 0, w: 1 });
|
|
358
|
+
bone.quaternion.set(q.x, q.y, q.z, q.w);
|
|
359
|
+
}
|
|
360
|
+
applied++;
|
|
361
|
+
}
|
|
362
|
+
root.updateMatrixWorld(true);
|
|
363
|
+
}
|
|
364
|
+
return { applied, total };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Read the current rotation of every POSE_BONES joint back into the same
|
|
368
|
+
* `{ id: [x, y, z] }` delta shape `applyPose` consumes. */
|
|
369
|
+
export function capturePose(root) {
|
|
370
|
+
const pose = {};
|
|
371
|
+
if (root) {
|
|
372
|
+
const binds = bindOf(root);
|
|
373
|
+
const captured = new Map();
|
|
374
|
+
root.traverse((object) => {
|
|
375
|
+
if (!object.isBone) return;
|
|
376
|
+
for (const entry of POSE_BONES) {
|
|
377
|
+
if (!captured.has(entry.id) && boneMatches(object.name, entry)) {
|
|
378
|
+
captured.set(entry.id, object);
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
for (const entry of POSE_BONES) {
|
|
384
|
+
const bone = captured.get(entry.id);
|
|
385
|
+
if (bone) {
|
|
386
|
+
const q = bone.quaternion;
|
|
387
|
+
const bind = binds.get(bone);
|
|
388
|
+
const delta = bind ? qMultiply({ x: q.x, y: q.y, z: q.z, w: q.w }, qInverse(bind)) : { x: q.x, y: q.y, z: q.z, w: q.w };
|
|
389
|
+
pose[entry.id] = qToEuler(delta);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return pose;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function loadCustomPoses() {
|
|
397
|
+
try {
|
|
398
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
399
|
+
if (!raw) return [];
|
|
400
|
+
const parsed = JSON.parse(raw);
|
|
401
|
+
if (!Array.isArray(parsed)) return [];
|
|
402
|
+
return parsed.filter(
|
|
403
|
+
(p) => p && typeof p === "object" && typeof p.id === "string" && p.bones && typeof p.bones === "object"
|
|
404
|
+
);
|
|
405
|
+
} catch {
|
|
406
|
+
// Private mode / quota errors degrade to an empty list.
|
|
407
|
+
return [];
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function saveCustomPoses(list) {
|
|
412
|
+
try {
|
|
413
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
|
|
414
|
+
return true;
|
|
415
|
+
} catch {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function deleteCustomPose(id, list) {
|
|
421
|
+
const next = list.filter((p) => p.id !== id);
|
|
422
|
+
saveCustomPoses(next);
|
|
423
|
+
return next;
|
|
424
|
+
}
|